Skip to content

Commit

Permalink
Merge branch 'main' into feat-309
Browse files Browse the repository at this point in the history
  • Loading branch information
Xemdo authored Mar 29, 2024
2 parents 1c842bc + e7d0943 commit 0937d3f
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 33 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
15 changes: 6 additions & 9 deletions internal/events/websocket/mock_server/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,23 +170,23 @@ func printWelcomeMsg() {

log.Printf(lightBlue("Started WebSocket server on %v:%v"), serverManager.ip, serverManager.port)
if serverManager.strictMode {
log.Printf(lightBlue("--require-subscription enabled. Clients will have 10 seconds to subscribe before being disconnected."))
log.Println(lightBlue("--require-subscription enabled. Clients will have 10 seconds to subscribe before being disconnected."))
}

fmt.Println()

log.Printf(yellow("Simulate subscribing to events at: %v://%v:%v/eventsub/subscriptions"), serverManager.protocolHttp, serverManager.ip, serverManager.port)
log.Printf(yellow("POST, GET, and DELETE are supported"))
log.Printf(yellow("For more info: https://dev.twitch.tv/docs/cli/websocket-event-command/#simulate-subscribing-to-mock-eventsub"))
log.Println(yellow("POST, GET, and DELETE are supported"))
log.Println(yellow("For more info: https://dev.twitch.tv/docs/cli/websocket-event-command/#simulate-subscribing-to-mock-eventsub"))

fmt.Println()

log.Printf(lightYellow("Events can be forwarded to this server from another terminal with --transport=websocket\nExample: \"twitch event trigger channel.ban --transport=websocket\""))
log.Println(lightYellow("Events can be forwarded to this server from another terminal with --transport=websocket\nExample: \"twitch event trigger channel.ban --transport=websocket\""))
fmt.Println()
log.Printf(lightYellow("You can send to a specific client after its connected with --session\nExample: \"twitch event trigger channel.ban --transport=websocket --session=e411cc1e_a2613d4e\""))
log.Println(lightYellow("You can send to a specific client after its connected with --session\nExample: \"twitch event trigger channel.ban --transport=websocket --session=e411cc1e_a2613d4e\""))

fmt.Println()
log.Printf(lightGreen("For further usage information, please see our official documentation:\nhttps://dev.twitch.tv/docs/cli/websocket-event-command/"))
log.Println(lightGreen("For further usage information, please see our official documentation:\nhttps://dev.twitch.tv/docs/cli/websocket-event-command/"))
fmt.Println()

log.Printf(lightBlue("Connect to the WebSocket server at: ")+"%v://%v:%v/ws", serverManager.protocolWs, serverManager.ip, serverManager.port)
Expand Down Expand Up @@ -392,7 +392,6 @@ func subscriptionPageHandlerPost(w http.ResponseWriter, r *http.Request) {
Version: body.Version,
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
Status: STATUS_ENABLED, // https://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions
SessionClientName: clientName,
Conditions: body.Condition,
ClientConnectedAt: client.ConnectedAtTimestamp,
}
Expand Down Expand Up @@ -444,8 +443,6 @@ func subscriptionPageHandlerPost(w http.ResponseWriter, r *http.Request) {
subscription.SubscriptionID,
)
}

return
}

func subscriptionPageHandlerDelete(w http.ResponseWriter, r *http.Request) {
Expand Down
20 changes: 8 additions & 12 deletions internal/events/websocket/mock_server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,12 @@ func (ws *WebSocketServer) WsPageHandler(w http.ResponseWriter, r *http.Request)
client.mustSubscribeTimer = time.NewTimer(10 * time.Second)
if ws.StrictMode {
go func() {
select {
case <-client.mustSubscribeTimer.C:
if len(ws.Subscriptions[client.clientName]) == 0 {
client.CloseWithReason(closeConnectionUnused)
ws.handleClientConnectionClose(client, closeConnectionUnused)
<-client.mustSubscribeTimer.C
if len(ws.Subscriptions[client.clientName]) == 0 {
client.CloseWithReason(closeConnectionUnused)
ws.handleClientConnectionClose(client, closeConnectionUnused)

return
}
return
}
}()
}
Expand Down Expand Up @@ -270,8 +268,6 @@ func (ws *WebSocketServer) WsPageHandler(w http.ResponseWriter, r *http.Request)
ws.handleClientConnectionClose(client, closeClientSentInboundTraffic)
ws.muClients.Unlock()
}

break
}
}

Expand Down Expand Up @@ -457,13 +453,13 @@ func (ws *WebSocketServer) HandleRPCEventSubForwarding(eventsubBody string, clie
subscriptionCreatedAtTimestamp := "" // Used below if in strict mode
if ws.StrictMode {
found := false
for _, clientSubscriptions := range ws.Subscriptions {
for subscriptionClientName, clientSubscriptions := range ws.Subscriptions {
if found {
break
}

for _, sub := range clientSubscriptions {
if sub.SessionClientName == client.clientName && sub.Type == eventObj.Subscription.Type && sub.Version == eventObj.Subscription.Version {
if subscriptionClientName == client.clientName && sub.Type == eventObj.Subscription.Type && sub.Version == eventObj.Subscription.Version {
found = true
subscriptionCreatedAtTimestamp = sub.CreatedAt
}
Expand Down Expand Up @@ -514,7 +510,7 @@ func (ws *WebSocketServer) HandleRPCEventSubForwarding(eventsubBody string, clie
}

if !didSend {
msg := fmt.Sprintf("Error executing remote triggered EventSub: No clients with the subscribed to [%v / %v]", eventObj.Subscription.Type, eventObj.Subscription.Version)
msg := fmt.Sprintf("Error executing remote triggered EventSub: No clients are subscribed to [%v / %v]", eventObj.Subscription.Type, eventObj.Subscription.Version)
log.Println(msg)
return false, msg
}
Expand Down
15 changes: 7 additions & 8 deletions internal/events/websocket/mock_server/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import (
)

type Subscription struct {
SubscriptionID string // Random GUID for the subscription
ClientID string // Client ID included in headers
Type string // EventSub topic
Version string // EventSub topic version
CreatedAt string // Timestamp of when the subscription was created
DisabledAt *time.Time // Not public; Timestamp of when the subscription was disabled
Status string // Status of the subscription
SessionClientName string // Client name of the session this is associated with.
SubscriptionID string // Random GUID for the subscription
ClientID string // Client ID included in headers
Type string // EventSub topic
Version string // EventSub topic version
CreatedAt string // Timestamp of when the subscription was created
DisabledAt *time.Time // Not public; Timestamp of when the subscription was disabled
Status string // Status of the subscription

ClientConnectedAt string // Time client connected
ClientDisconnectedAt string // Time client disconnected
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 0937d3f

Please sign in to comment.