diff --git a/cmd/token.go b/cmd/token.go index b7b8556..8ea6c55 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -4,6 +4,7 @@ package cmd import ( "fmt" + "log" "strconv" "time" @@ -18,7 +19,9 @@ var isUserToken bool var userScopes string var revokeToken string var validateToken string +var refreshToken string var overrideClientId string +var overrideClientSecret string var tokenServerPort int var tokenServerIP string var redirectHost string @@ -37,7 +40,9 @@ func init() { loginCmd.Flags().StringVarP(&userScopes, "scopes", "s", "", "Space separated list of scopes to request with your user token.") loginCmd.Flags().StringVarP(&revokeToken, "revoke", "r", "", "Instead of generating a new token, revoke the one passed to this parameter.") loginCmd.Flags().StringVarP(&validateToken, "validate", "v", "", "Instead of generating a new token, validate the one passed to this parameter.") - loginCmd.Flags().StringVar(&overrideClientId, "client-id", "", "Override/manually set client ID for token actions. By default client ID from CLI config will be used.") + loginCmd.Flags().StringVarP(&refreshToken, "refresh", "R", "", "Instead of generating a new token, refresh the token associated with the Refresh Token passed to this parameter.") + loginCmd.Flags().StringVar(&overrideClientId, "client-id", "", "Override/manually set Client ID for token actions. By default Client ID from CLI config will be used.") + loginCmd.Flags().StringVar(&overrideClientSecret, "secret", "", "Override/manually set Client Secret for token actions. By default Client Secret from CLI config will be used.") loginCmd.Flags().StringVar(&tokenServerIP, "ip", "", "Manually set the IP address to be bound to for the User Token web server.") loginCmd.Flags().IntVarP(&tokenServerPort, "port", "p", 3000, "Manually set the port to be used for the User Token web server.") loginCmd.Flags().StringVar(&redirectHost, "redirect-host", "localhost", "Manually set the host to be used for the redirect URL") @@ -65,6 +70,10 @@ func loginCmdRun(cmd *cobra.Command, args []string) error { clientID = overrideClientId } + if overrideClientSecret != "" { + clientSecret = overrideClientSecret + } + var p = login.LoginParameters{ ClientID: clientID, ClientSecret: clientSecret, @@ -76,13 +85,20 @@ func loginCmdRun(cmd *cobra.Command, args []string) error { if revokeToken != "" { p.Token = revokeToken p.URL = login.RevokeTokenURL - login.CredentialsLogout(p) + _, err := login.CredentialsLogout(p) + + if err != nil { + return err + } + + log.Printf("Token %s has been successfully revoked", p.Token) + } else if validateToken != "" { p.Token = validateToken p.URL = login.ValidateTokenURL r, err := login.ValidateCredentials(p) if err != nil { - return fmt.Errorf("failed to validate: %v", err.Error()) + return err } tokenType := "App Access Token" @@ -111,12 +127,68 @@ func loginCmdRun(cmd *cobra.Command, args []string) error { fmt.Println(white("- %v\n", s)) } } + + } else if refreshToken != "" { + p.URL = login.RefreshTokenURL + + // If we are overriding the Client ID then we shouldn't store this in the config. + shouldStoreInConfig := (overrideClientId == "") + + resp, err := login.RefreshUserToken(login.RefreshParameters{ + RefreshToken: refreshToken, + ClientID: clientID, + ClientSecret: clientSecret, + URL: login.RefreshTokenURL, + }, shouldStoreInConfig) + + if err != nil { + errDescription := "" + if overrideClientId == "" { + errDescription = "Check `--refresh` flag to ensure the provided Refresh Token is valid for the Client ID set with `twitch config`." + } else { + errDescription = "Check `--refresh` and `--client-id` flags to ensure the provided Refresh Token is valid for the provided Client ID." + } + + return fmt.Errorf("%v\n%v", err.Error(), errDescription) + } + + lightYellow := color.New(color.FgHiYellow).SprintfFunc() + + log.Println("Successfully refreshed Access Token.") + log.Println(lightYellow("Access Token: ") + resp.Response.AccessToken) + log.Println(lightYellow("Refresh Token: ") + resp.Response.RefreshToken) + log.Println(lightYellow("Expires At: ") + resp.ExpiresAt.String()) + } else if isUserToken { p.URL = login.UserCredentialsURL - login.UserCredentialsLogin(p, tokenServerIP, webserverPort) + resp, err := login.UserCredentialsLogin(p, tokenServerIP, webserverPort) + + if err != nil { + return err + } + + lightYellow := color.New(color.FgHiYellow).SprintfFunc() + + log.Println("Successfully generated User Access Token.") + log.Println(lightYellow("User Access Token: ") + resp.Response.AccessToken) + log.Println(lightYellow("Refresh Token: ") + resp.Response.RefreshToken) + log.Println(lightYellow("Expires At: ") + resp.ExpiresAt.String()) + log.Println(lightYellow("Scopes: ") + fmt.Sprintf("%v", resp.Response.Scope)) + } else { p.URL = login.ClientCredentialsURL - login.ClientCredentialsLogin(p) + resp, err := login.ClientCredentialsLogin(p) + + if err != nil { + return err + } + + lightYellow := color.New(color.FgHiYellow).SprintfFunc() + + log.Println("Successfully generated App Access Token.") + log.Println(lightYellow("App Access Token: ") + resp.Response.AccessToken) + log.Println(lightYellow("Expires At: ") + resp.ExpiresAt.String()) + log.Println(lightYellow("Scopes: ") + fmt.Sprintf("%v", resp.Response.Scope)) } return nil diff --git a/docs/token.md b/docs/token.md index 41fe450..a3149d5 100644 --- a/docs/token.md +++ b/docs/token.md @@ -97,6 +97,21 @@ Access tokens can be revoked with: twitch token -r 0123456789abcdefghijABCDEFGHIJ ``` +## Refreshing Access Tokens + +Access tokens can be refreshed using a refresh token: + +``` +twitch token --refresh ABCDEfghij0123456789abcdefghijABCDEFGHIJ +``` + +By default, this uses the Client ID and Client Secret stored in your config file. You can override this with `--client-id` and `--secret`, as such: + +``` +twitch token --refresh ABCDEfghij0123456789abcdefghijABCDEFGHIJ --client-id uo6dggojyb8d6soh92zknwmi5ej1q2 --secret yigv8zib6nuczcoy08u8g1nxh6wjgu +``` +When overriding the Client ID, your config file will **not** be updated with the new access token, client ID, or secret. + ## Alternate IP for User Token Webserver If you'd like to bind the webserver used for user tokens (`-u` flag), you can override it with the `--ip` flag. For example: @@ -147,16 +162,18 @@ None. **Flags** -| Flag | Shorthand | Description | Example | Required? (Y/N) | -|-------------------|-----------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------|-----------------| -| `--user-token` | `-u` | Whether to fetch a user token or not. Default is false. | `token -u` | N | -| `--scopes` | `-s` | The space separated scopes to use when getting a user token. | `-s "user:read:email user_read"` | N | -| `--revoke` | `-r` | Instead of generating a new token, revoke the one passed to this parameter. | `-r 0123456789abcdefghijABCDEFGHIJ` | N | -| `--validate` | `-v` | Instead of generating a new token, validate the one passed to this parameter. | `-v 0123456789abcdefghijABCDEFGHIJ` | N | -| `--ip` | | Manually set the port to be used for the User Token web server. The default binds to all interfaces. (0.0.0.0) | `--ip 127.0.0.1` | N | -| `--port` | `-p` | Override/manually set the port for token actions. (The default is 3000) | `-p 3030` | N | -| `--client-id` | | Override/manually set client ID for token actions. By default client ID from CLI config will be used. | `--client-id uo6dggojyb8d6soh92zknwmi5ej1q2` | N | -| `--redirect-host` | | Override/manually set the redirect host token actions. The default is `localhost` | `--redirect-host contoso.com` | N | +| Flag | Shorthand | Description | Example | Required? (Y/N) | +|-------------------|-----------|------------------------------------------------------------------------------------------------------------------|-----------------------------------------------|-----------------| +| `--user-token` | `-u` | Whether to fetch a user token or not. Default is false. | `token -u` | N | +| `--scopes` | `-s` | The space separated scopes to use when getting a user token. | `-s "user:read:email user_read"` | N | +| `--revoke` | `-r` | Instead of generating a new token, revoke the one passed to this parameter. | `-r 0123456789abcdefghijABCDEFGHIJ` | N | +| `--validate` | `-v` | Instead of generating a new token, validate the one passed to this parameter. | `-v 0123456789abcdefghijABCDEFGHIJ` | N | +| `--refresh` | `-R` | Instead of generating a new token, refresh the token associated with the Refresh Token passed to this parameter. | `-R ABCDEfghij0123456789abcdefghijABCDEFGHIJ` | N | +| `--ip` | | Manually set the port to be used for the User Token web server. The default binds to all interfaces. (0.0.0.0) | `--ip 127.0.0.1` | N | +| `--port` | `-p` | Override/manually set the port for token actions. (The default is 3000) | `-p 3030` | N | +| `--client-id` | | Override/manually set Client ID for token actions. By default Client ID from CLI config will be used. | `--client-id uo6dggojyb8d6soh92zknwmi5ej1q2` | N | +| `--secret` | | Override/manually set Client Secret for token actions. By default Client Secret from CLI config will be used. | `--secret yigv8zib6nuczcoy08u8g1nxh6wjgu` | N | +| `--redirect-host` | | Override/manually set the redirect host token actions. The default is `localhost` | `--redirect-host contoso.com` | N | ## Notes diff --git a/internal/api/api.go b/internal/api/api.go index aab77f3..8e22105 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -4,6 +4,7 @@ package api import ( "encoding/json" + "errors" "fmt" "log" "net/http" @@ -313,9 +314,9 @@ func GetClientInformation() (clientInformation, error) { ClientID: clientID, ClientSecret: clientSecret, URL: login.RefreshTokenURL, - }) + }, true) if err != nil { - return clientInformation{}, err + return clientInformation{}, errors.New(err.Error() + "\nPlease rerun `twitch configure`") } token = r.Response.AccessToken } diff --git a/internal/login/login.go b/internal/login/login.go index ba7cdf0..efa17ed 100644 --- a/internal/login/login.go +++ b/internal/login/login.go @@ -15,6 +15,7 @@ import ( "net/url" "os/exec" "runtime" + "strings" "time" "github.com/spf13/viper" @@ -66,20 +67,20 @@ type ValidateResponse struct { } const ClientCredentialsURL = "https://id.twitch.tv/oauth2/token?grant_type=client_credentials" - const UserCredentialsURL = "https://id.twitch.tv/oauth2/token?grant_type=authorization_code" + const UserAuthorizeURL = "https://id.twitch.tv/oauth2/authorize?response_type=code" const RefreshTokenURL = "https://id.twitch.tv/oauth2/token?grant_type=refresh_token" - const RevokeTokenURL = "https://id.twitch.tv/oauth2/revoke" - const ValidateTokenURL = "https://id.twitch.tv/oauth2/validate" +// Sends `https://id.twitch.tv/oauth2/token?grant_type=client_credentials`. +// Generates a new App Access Token. Stores new token information in the CLI's config. func ClientCredentialsLogin(p LoginParameters) (LoginResponse, error) { u, err := url.Parse(p.URL) if err != nil { - log.Fatal(err) + return LoginResponse{}, fmt.Errorf("Internal error: %v", err.Error()) } q := u.Query() q.Set("client_id", p.ClientID) @@ -88,28 +89,27 @@ func ClientCredentialsLogin(p LoginParameters) (LoginResponse, error) { resp, err := loginRequest(http.MethodPost, u.String(), nil) if err != nil { - log.Fatal(err.Error()) + return LoginResponse{}, fmt.Errorf("Error processing request: %v", err.Error()) } if resp.StatusCode != http.StatusOK { - log.Printf("API responded with an error while generating token: %v", string(resp.Body)) - return LoginResponse{}, errors.New("API responded with an error while revoking token") + return LoginResponse{}, errors.New("API responded with an error while revoking token: " + string(resp.Body)) } - r, err := handleLoginResponse(resp.Body) + r, err := handleLoginResponse(resp.Body, true) if err != nil { - log.Printf("Error handling login: %v", err) - return LoginResponse{}, nil + return LoginResponse{}, fmt.Errorf("Error processing login response: %v", err.Error()) } - log.Printf("App Access Token: %s", r.Response.AccessToken) return r, nil } +// Sends `https://id.twitch.tv/oauth2/token?grant_type=authorization_code`. +// Generates a new App Access Token, requiring the use of a web browser. Stores new token information in the CLI's config. func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort string) (LoginResponse, error) { u, err := url.Parse(p.AuthorizeURL) if err != nil { - log.Fatal(err) + return LoginResponse{}, fmt.Errorf("Internal error (parsing AuthorizeURL): %v", err.Error()) } q := u.Query() q.Set("client_id", p.ClientID) @@ -120,7 +120,7 @@ func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort s state, err := generateState() if err != nil { - log.Fatal(err.Error()) + return LoginResponse{}, fmt.Errorf("Internal error (generating state): %v", err.Error()) } q.Set("state", state) @@ -136,18 +136,17 @@ func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort s urp, err := userAuthServer(webserverIP, webserverPort, execOpenBrowser) if err != nil { - fmt.Printf("Error processing request; %v\n", err.Error()) - return LoginResponse{}, err + return LoginResponse{}, fmt.Errorf("Error processing request: %v", err.Error()) } ur := *urp if ur.State != state { - log.Fatal("state mismatch") + return LoginResponse{}, fmt.Errorf("Error processing request: state mismatch") } u2, err := url.Parse(p.URL) if err != nil { - log.Fatal(err.Error()) + return LoginResponse{}, fmt.Errorf("Internal error (parsing URL): %v", err.Error()) } q = u2.Query() @@ -159,23 +158,23 @@ func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort s resp, err := loginRequest(http.MethodPost, u2.String(), nil) if err != nil { - log.Fatalf("Error reading body: %v", err) + return LoginResponse{}, fmt.Errorf("Error reading body: %v", err.Error()) } - r, err := handleLoginResponse(resp.Body) + r, err := handleLoginResponse(resp.Body, true) if err != nil { - log.Printf("Error handling login: %v", err) - return LoginResponse{}, nil + return LoginResponse{}, fmt.Errorf("Error handling login: %v", err.Error()) } - log.Printf("User Access Token: %s\nRefresh Token: %s\nExpires At: %s\nScopes: %s", r.Response.AccessToken, r.Response.RefreshToken, r.ExpiresAt, r.Response.Scope) return r, nil } +// Sends `https://id.twitch.tv/oauth2/revoke`. +// Revokes the provided token. Does not change the CLI's config at all. func CredentialsLogout(p LoginParameters) (LoginResponse, error) { u, err := url.Parse(p.URL) if err != nil { - log.Fatal(err) + return LoginResponse{}, fmt.Errorf("Internal error (parsing URL): %v", err.Error()) } q := u.Query() q.Set("client_id", p.ClientID) @@ -184,23 +183,22 @@ func CredentialsLogout(p LoginParameters) (LoginResponse, error) { resp, err := loginRequest(http.MethodPost, u.String(), nil) if err != nil { - log.Print(err.Error()) - return LoginResponse{}, err + return LoginResponse{}, fmt.Errorf("Error reading body: %v", err.Error()) } if resp.StatusCode != http.StatusOK { - log.Printf("API responded with an error while revoking token: [%v] %v", resp.StatusCode, string(resp.Body)) - return LoginResponse{}, errors.New("API responded with an error while revoking token") + return LoginResponse{}, fmt.Errorf("API responded with an error while revoking token: [%v] %v", resp.StatusCode, string(resp.Body)) } - log.Printf("Token %s has been successfully revoked.", p.Token) return LoginResponse{}, nil } -func RefreshUserToken(p RefreshParameters) (LoginResponse, error) { +// Sends `POST https://id.twitch.tv/oauth2/token`. +// Refreshes the provided token and optionally stores the result in the CLI's config. +func RefreshUserToken(p RefreshParameters, shouldStoreInConfig bool) (LoginResponse, error) { u, err := url.Parse(p.URL) if err != nil { - log.Fatal(err) + return LoginResponse{}, fmt.Errorf("Internal error (parsing URL): %v", err) } q := u.Query() q.Set("client_id", p.ClientID) @@ -210,26 +208,27 @@ func RefreshUserToken(p RefreshParameters) (LoginResponse, error) { resp, err := loginRequest(http.MethodPost, u.String(), nil) if err != nil { - return LoginResponse{}, err + return LoginResponse{}, fmt.Errorf("Error processing request: %v", err.Error()) } if resp.StatusCode != http.StatusOK { - return LoginResponse{}, errors.New("error with client while refreshing. Please rerun twitch configure") + return LoginResponse{}, fmt.Errorf("Error with client while refreshing: [%v - `%v`]", resp.StatusCode, strings.TrimSpace(string(resp.Body))) } - r, err := handleLoginResponse(resp.Body) + r, err := handleLoginResponse(resp.Body, shouldStoreInConfig) if err != nil { - log.Printf("Error handling login: %v", err) - return LoginResponse{}, err + return LoginResponse{}, fmt.Errorf("Error handling login: %v", err.Error()) } return r, nil } +// Sends `GET https://id.twitch.tv/oauth2/validate`. +// Only validates. Does not store this information in the CLI's config. func ValidateCredentials(p LoginParameters) (ValidateResponse, error) { u, err := url.Parse(p.URL) if err != nil { - log.Fatal(err) + return ValidateResponse{}, fmt.Errorf("Internal error (parsing URL): %v", err) } resp, err := loginRequestWithHeaders(http.MethodGet, u.String(), nil, []loginHeader{ @@ -239,25 +238,28 @@ func ValidateCredentials(p LoginParameters) (ValidateResponse, error) { }, }) if err != nil { - return ValidateResponse{}, err + return ValidateResponse{}, fmt.Errorf("Error processing request: %v", err) } // Handle validate response body var r ValidateResponse if err = json.Unmarshal(resp.Body, &r); err != nil { - return ValidateResponse{}, err + return ValidateResponse{}, fmt.Errorf("Error handling response: %v", err) } return r, nil } -func handleLoginResponse(body []byte) (LoginResponse, error) { +func handleLoginResponse(body []byte, shouldStoreInConfig bool) (LoginResponse, error) { var r AuthorizationResponse if err := json.Unmarshal(body, &r); err != nil { return LoginResponse{}, err } expiresAt := util.GetTimestamp().Add(time.Duration(int64(time.Second) * int64(r.ExpiresIn))) - storeInConfig(r.AccessToken, r.RefreshToken, r.Scope, expiresAt) + + if shouldStoreInConfig { + storeInConfig(r.AccessToken, r.RefreshToken, r.Scope, expiresAt) + } return LoginResponse{ Response: r, diff --git a/internal/login/login_test.go b/internal/login/login_test.go index 1f67b8a..b9c5b0f 100644 --- a/internal/login/login_test.go +++ b/internal/login/login_test.go @@ -126,7 +126,7 @@ func TestRefreshUserToken(t *testing.T) { URL: ts.URL + "?foo=bar", ClientID: params.ClientID, ClientSecret: params.ClientSecret, - }) + }, true) a.Nil(err) a.NotNil(resp)