Skip to content

Commit

Permalink
Merge branch 'main' into fix-315
Browse files Browse the repository at this point in the history
  • Loading branch information
Xemdo authored Mar 29, 2024
2 parents aebad85 + bf64ffa commit 4af033b
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 4 deletions.
13 changes: 11 additions & 2 deletions cmd/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/token.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down
74 changes: 72 additions & 2 deletions internal/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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) {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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())
Expand All @@ -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) {
Expand Down
60 changes: 60 additions & 0 deletions internal/login/login_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
package login

import (
"bytes"
"io"
"mime/multipart"
"net/http"
"time"

Expand Down Expand Up @@ -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
}

0 comments on commit 4af033b

Please sign in to comment.