From de5ddfa1d6482f1afddc57b308822e26fcd71642 Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Sun, 10 Mar 2024 17:19:38 -0700 Subject: [PATCH 1/2] Added DCF support for twitch token; Added error message when trying to use non-dcf flow on a public client --- cmd/token.go | 13 +++++- internal/login/login.go | 74 ++++++++++++++++++++++++++++++++- internal/login/login_request.go | 60 ++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/cmd/token.go b/cmd/token.go index 8ea6c55..d68f2cc 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -25,6 +25,7 @@ var overrideClientSecret string var tokenServerPort int var tokenServerIP string var redirectHost string +var useDeviceCodeFlow bool // loginCmd represents the login command var loginCmd = &cobra.Command{ @@ -46,6 +47,7 @@ func init() { 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") + loginCmd.Flags().BoolVar(&useDeviceCodeFlow, "dcf", false, "Uses Device Code Flow for your User Access Token. Can only be used with --user-token") } func loginCmdRun(cmd *cobra.Command, args []string) error { @@ -160,8 +162,15 @@ func loginCmdRun(cmd *cobra.Command, args []string) error { log.Println(lightYellow("Expires At: ") + resp.ExpiresAt.String()) } else if isUserToken { - p.URL = login.UserCredentialsURL - resp, err := login.UserCredentialsLogin(p, tokenServerIP, webserverPort) + var resp login.LoginResponse + var err error + + if useDeviceCodeFlow { + resp, err = login.UserCredentialsLogin_DeviceCodeFlow(p) + } else { + p.URL = login.UserCredentialsURL + resp, err = login.UserCredentialsLogin_AuthorizationCodeFlow(p, tokenServerIP, webserverPort) + } if err != nil { return err diff --git a/internal/login/login.go b/internal/login/login.go index efa17ed..c1f318e 100644 --- a/internal/login/login.go +++ b/internal/login/login.go @@ -66,6 +66,14 @@ type ValidateResponse struct { ExpiresIn int64 `json:"expires_in"` } +type DeviceCodeFlowInitResponse struct { + DeviceCode string `json:"device_code"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + UserCode string `json:"user_code"` + VerificationUri string `json:"verification_uri"` +} + const ClientCredentialsURL = "https://id.twitch.tv/oauth2/token?grant_type=client_credentials" const UserCredentialsURL = "https://id.twitch.tv/oauth2/token?grant_type=authorization_code" @@ -75,6 +83,10 @@ const RefreshTokenURL = "https://id.twitch.tv/oauth2/token?grant_type=refresh_to const RevokeTokenURL = "https://id.twitch.tv/oauth2/revoke" const ValidateTokenURL = "https://id.twitch.tv/oauth2/validate" +const DeviceCodeFlowUrl = "https://id.twitch.tv/oauth2/device" +const DeviceCodeFlowTokenURL = "https://id.twitch.tv/oauth2/token" +const DeviceCodeFlowGrantType = "urn:ietf:params:oauth:grant-type:device_code" + // 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) { @@ -104,9 +116,10 @@ func ClientCredentialsLogin(p LoginParameters) (LoginResponse, error) { return r, nil } +// Uses Authorization Code Flow: https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow // 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) { +// Generates a new User Access Token, requiring the use of a web browser. Stores new token information in the CLI's config. +func UserCredentialsLogin_AuthorizationCodeFlow(p LoginParameters, webserverIP string, webserverPort string) (LoginResponse, error) { u, err := url.Parse(p.AuthorizeURL) if err != nil { return LoginResponse{}, fmt.Errorf("Internal error (parsing AuthorizeURL): %v", err.Error()) @@ -161,6 +174,14 @@ func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort s return LoginResponse{}, fmt.Errorf("Error reading body: %v", err.Error()) } + if resp.StatusCode == 400 { + // If 400 is returned, the applications' Client Type was set up as "Public", and you can only use Implicit Auth or Device Code Flow to get a User Access Token + return LoginResponse{}, fmt.Errorf( + "This Client Type of this Client ID is set to \"Public\", which doesn't allow the use of Authorization Code Grant Flow.\n" + + "Please call the token command with the --dcf flag to use Device Code Flow. For example: twitch token -u --dcf", + ) + } + r, err := handleLoginResponse(resp.Body, true) if err != nil { return LoginResponse{}, fmt.Errorf("Error handling login: %v", err.Error()) @@ -169,6 +190,55 @@ func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort s return r, nil } +// Uses Device Code Flow: https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow +// Generates a new User Access Token, requiring the use of a web browser from any device. Stores new token information in the CLI's config. +func UserCredentialsLogin_DeviceCodeFlow(p LoginParameters) (LoginResponse, error) { + // Initiate DCF flow + deviceResp, err := dcfInitiateRequest(DeviceCodeFlowUrl, p.ClientID, p.Scopes) + if err != nil { + return LoginResponse{}, fmt.Errorf("Error initiating Device Code Flow: %v", err.Error()) + } + + var deviceObj DeviceCodeFlowInitResponse + if err := json.Unmarshal(deviceResp.Body, &deviceObj); err != nil { + return LoginResponse{}, fmt.Errorf("Error reading body: %v", err.Error()) + } + expirationTime := time.Now().Add(time.Second * time.Duration(deviceObj.ExpiresIn)) + + fmt.Printf("Started Device Code Flow login.\n") + fmt.Printf("Use this URL to log in: %v\n", deviceObj.VerificationUri) + fmt.Printf("Use this code when prompted at the above URL: %v\n\n", deviceObj.UserCode) + fmt.Printf("This system will check every %v seconds, and will expire after %v minutes.\n", deviceObj.Interval, (deviceObj.ExpiresIn / 60)) + + // Loop and check for user login. Respects given interval, and times out after expiration + tokenResp := loginRequestResponse{StatusCode: 999} + for tokenResp.StatusCode != 0 { + // Check for expiration + if time.Now().After(expirationTime) { + return LoginResponse{}, fmt.Errorf("The Device Code used for getting access token has expired. Run token command again to generate a new user.") + } + + // Wait interval + time.Sleep(time.Second * time.Duration(deviceObj.Interval)) + + // Check for token + tokenResp, err = dcfTokenRequest(DeviceCodeFlowTokenURL, p.ClientID, p.Scopes, deviceObj.DeviceCode, DeviceCodeFlowGrantType) + if err != nil { + return LoginResponse{}, fmt.Errorf("Error getting token via Device Code Flow: %v", err) + } + + if tokenResp.StatusCode == 200 { + r, err := handleLoginResponse(tokenResp.Body, true) + if err != nil { + return LoginResponse{}, fmt.Errorf("Error handling login: %v", err.Error()) + } + return r, nil + } + } + + return LoginResponse{}, 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) { diff --git a/internal/login/login_request.go b/internal/login/login_request.go index 92ed721..bdb8e55 100644 --- a/internal/login/login_request.go +++ b/internal/login/login_request.go @@ -3,7 +3,9 @@ package login import ( + "bytes" "io" + "mime/multipart" "net/http" "time" @@ -56,3 +58,61 @@ func loginRequestWithHeaders(method string, url string, payload io.Reader, heade Body: body, }, nil } + +func dcfInitiateRequest(url string, clientId string, scopes string) (loginRequestResponse, error) { + formData := map[string]string{ + "client_id": clientId, + "scopes": scopes, + } + + return sendMultipartPostRequest(url, formData) +} + +func dcfTokenRequest(url string, clientId string, scopes string, deviceCode string, grantType string) (loginRequestResponse, error) { + formData := map[string]string{ + "client_id": clientId, + "scopes": scopes, + "device_code": deviceCode, + "grant_type": grantType, + } + + return sendMultipartPostRequest(url, formData) +} + +// Creates and sends a request with the content type multipart/form-data +func sendMultipartPostRequest(url string, formData map[string]string) (loginRequestResponse, error) { + // Create form's body using the provided data + formBody := new(bytes.Buffer) + mp := multipart.NewWriter(formBody) + for k, v := range formData { + mp.WriteField(k, v) + } + mp.Close() // If you do defer on this instead, it gets an "unexpected EOF" error from Twitch's servers + + req, err := request.NewRequest("POST", url, formBody) + if err != nil { + return loginRequestResponse{}, err + } + + // Add Content-Type header, generated with the boundary associated with the form + req.Header.Add("Content-Type", mp.FormDataContentType()) + + client := &http.Client{ + Timeout: time.Second * 10, + } + resp, err := client.Do(req) + if err != nil { + return loginRequestResponse{}, err + } + + responseBody, err := io.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + return loginRequestResponse{}, err + } + + return loginRequestResponse{ + StatusCode: resp.StatusCode, + Body: responseBody, + }, nil +} From 3e3a577297362b21241147b47f97527fb0ddb739 Mon Sep 17 00:00:00 2001 From: Marc Szanto <11840265+Xemdo@users.noreply.github.com> Date: Tue, 12 Mar 2024 11:48:34 -0700 Subject: [PATCH 2/2] Updated token docs for dcf --- docs/token.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/token.md b/docs/token.md index a3149d5..b8ea005 100644 --- a/docs/token.md +++ b/docs/token.md @@ -89,6 +89,36 @@ Expires At: 2023-08-23 22:06:47.036137 +0000 UTC Scopes: [moderator:manage:shield_mode moderator:manage:shoutouts] ``` +## Device Code Flow + +If you wish to use Device Code Flow, the `--dcf` flag can be used alongside the `--user-token` flag. + +Run the command as you would for a regular User Access Token, but include the `--dcf` flag: + +``` +twitch token -u -s "moderator:manage:shoutouts moderator:manage:shield_mode" --dcf +``` + +The terminal will then output information about how to authenticate in your web browser: + +``` +Started Device Code Flow login. +Use this URL to log in: https://www.twitch.tv/activate?device-code=SZPPRMFW +Use this code when prompted at the above URL: SZPPRMFW + +This system will check every 5 seconds, and will expire after 30 minutes. +``` + +The application will then check Twitch's servers every 5 seconds to see if you have authenticated in your web browser. When it detects you have authenticated, it will output the tokens as expected: + +``` +2024/03/12 11:42:24 Successfully generated User Access Token. +2024/03/12 11:42:24 User Access Token: c012345asdfetc... +2024/03/12 11:42:24 Refresh Token: 012345asdfetc... +2024/03/12 11:42:24 Expires At: 2024-03-12 22:30:46.696108405 +0000 UTC +2024/03/12 11:42:24 Scopes: [moderator:manage:shield_mode moderator:manage:shoutouts] +``` + ## Revoking Access Tokens Access tokens can be revoked with: @@ -165,6 +195,7 @@ None. | Flag | Shorthand | Description | Example | Required? (Y/N) | |-------------------|-----------|------------------------------------------------------------------------------------------------------------------|-----------------------------------------------|-----------------| | `--user-token` | `-u` | Whether to fetch a user token or not. Default is false. | `token -u` | N | +| `--dcf` | | Uses Device Code Flow for your User Access Token. Can only be used with --user-token | `token -u --dcf` | 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 |