Skip to content

Commit 0937d3f

Browse files
authored
Merge branch 'main' into feat-309
2 parents 1c842bc + e7d0943 commit 0937d3f

File tree

7 files changed

+195
-33
lines changed

7 files changed

+195
-33
lines changed

cmd/token.go

+11-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ var overrideClientSecret string
2525
var tokenServerPort int
2626
var tokenServerIP string
2727
var redirectHost string
28+
var useDeviceCodeFlow bool
2829

2930
// loginCmd represents the login command
3031
var loginCmd = &cobra.Command{
@@ -46,6 +47,7 @@ func init() {
4647
loginCmd.Flags().StringVar(&tokenServerIP, "ip", "", "Manually set the IP address to be bound to for the User Token web server.")
4748
loginCmd.Flags().IntVarP(&tokenServerPort, "port", "p", 3000, "Manually set the port to be used for the User Token web server.")
4849
loginCmd.Flags().StringVar(&redirectHost, "redirect-host", "localhost", "Manually set the host to be used for the redirect URL")
50+
loginCmd.Flags().BoolVar(&useDeviceCodeFlow, "dcf", false, "Uses Device Code Flow for your User Access Token. Can only be used with --user-token")
4951
}
5052

5153
func loginCmdRun(cmd *cobra.Command, args []string) error {
@@ -160,8 +162,15 @@ func loginCmdRun(cmd *cobra.Command, args []string) error {
160162
log.Println(lightYellow("Expires At: ") + resp.ExpiresAt.String())
161163

162164
} else if isUserToken {
163-
p.URL = login.UserCredentialsURL
164-
resp, err := login.UserCredentialsLogin(p, tokenServerIP, webserverPort)
165+
var resp login.LoginResponse
166+
var err error
167+
168+
if useDeviceCodeFlow {
169+
resp, err = login.UserCredentialsLogin_DeviceCodeFlow(p)
170+
} else {
171+
p.URL = login.UserCredentialsURL
172+
resp, err = login.UserCredentialsLogin_AuthorizationCodeFlow(p, tokenServerIP, webserverPort)
173+
}
165174

166175
if err != nil {
167176
return err

docs/token.md

+31
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,36 @@ Expires At: 2023-08-23 22:06:47.036137 +0000 UTC
8989
Scopes: [moderator:manage:shield_mode moderator:manage:shoutouts]
9090
```
9191

92+
## Device Code Flow
93+
94+
If you wish to use Device Code Flow, the `--dcf` flag can be used alongside the `--user-token` flag.
95+
96+
Run the command as you would for a regular User Access Token, but include the `--dcf` flag:
97+
98+
```
99+
twitch token -u -s "moderator:manage:shoutouts moderator:manage:shield_mode" --dcf
100+
```
101+
102+
The terminal will then output information about how to authenticate in your web browser:
103+
104+
```
105+
Started Device Code Flow login.
106+
Use this URL to log in: https://www.twitch.tv/activate?device-code=SZPPRMFW
107+
Use this code when prompted at the above URL: SZPPRMFW
108+
109+
This system will check every 5 seconds, and will expire after 30 minutes.
110+
```
111+
112+
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:
113+
114+
```
115+
2024/03/12 11:42:24 Successfully generated User Access Token.
116+
2024/03/12 11:42:24 User Access Token: c012345asdfetc...
117+
2024/03/12 11:42:24 Refresh Token: 012345asdfetc...
118+
2024/03/12 11:42:24 Expires At: 2024-03-12 22:30:46.696108405 +0000 UTC
119+
2024/03/12 11:42:24 Scopes: [moderator:manage:shield_mode moderator:manage:shoutouts]
120+
```
121+
92122
## Revoking Access Tokens
93123

94124
Access tokens can be revoked with:
@@ -165,6 +195,7 @@ None.
165195
| Flag | Shorthand | Description | Example | Required? (Y/N) |
166196
|-------------------|-----------|------------------------------------------------------------------------------------------------------------------|-----------------------------------------------|-----------------|
167197
| `--user-token` | `-u` | Whether to fetch a user token or not. Default is false. | `token -u` | N |
198+
| `--dcf` | | Uses Device Code Flow for your User Access Token. Can only be used with --user-token | `token -u --dcf` | N |
168199
| `--scopes` | `-s` | The space separated scopes to use when getting a user token. | `-s "user:read:email user_read"` | N |
169200
| `--revoke` | `-r` | Instead of generating a new token, revoke the one passed to this parameter. | `-r 0123456789abcdefghijABCDEFGHIJ` | N |
170201
| `--validate` | `-v` | Instead of generating a new token, validate the one passed to this parameter. | `-v 0123456789abcdefghijABCDEFGHIJ` | N |

internal/events/websocket/mock_server/manager.go

+6-9
Original file line numberDiff line numberDiff line change
@@ -170,23 +170,23 @@ func printWelcomeMsg() {
170170

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

176176
fmt.Println()
177177

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

182182
fmt.Println()
183183

184-
log.Printf(lightYellow("Events can be forwarded to this server from another terminal with --transport=websocket\nExample: \"twitch event trigger channel.ban --transport=websocket\""))
184+
log.Println(lightYellow("Events can be forwarded to this server from another terminal with --transport=websocket\nExample: \"twitch event trigger channel.ban --transport=websocket\""))
185185
fmt.Println()
186-
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\""))
186+
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\""))
187187

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

192192
log.Printf(lightBlue("Connect to the WebSocket server at: ")+"%v://%v:%v/ws", serverManager.protocolWs, serverManager.ip, serverManager.port)
@@ -392,7 +392,6 @@ func subscriptionPageHandlerPost(w http.ResponseWriter, r *http.Request) {
392392
Version: body.Version,
393393
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
394394
Status: STATUS_ENABLED, // https://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions
395-
SessionClientName: clientName,
396395
Conditions: body.Condition,
397396
ClientConnectedAt: client.ConnectedAtTimestamp,
398397
}
@@ -444,8 +443,6 @@ func subscriptionPageHandlerPost(w http.ResponseWriter, r *http.Request) {
444443
subscription.SubscriptionID,
445444
)
446445
}
447-
448-
return
449446
}
450447

451448
func subscriptionPageHandlerDelete(w http.ResponseWriter, r *http.Request) {

internal/events/websocket/mock_server/server.go

+8-12
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,12 @@ func (ws *WebSocketServer) WsPageHandler(w http.ResponseWriter, r *http.Request)
154154
client.mustSubscribeTimer = time.NewTimer(10 * time.Second)
155155
if ws.StrictMode {
156156
go func() {
157-
select {
158-
case <-client.mustSubscribeTimer.C:
159-
if len(ws.Subscriptions[client.clientName]) == 0 {
160-
client.CloseWithReason(closeConnectionUnused)
161-
ws.handleClientConnectionClose(client, closeConnectionUnused)
157+
<-client.mustSubscribeTimer.C
158+
if len(ws.Subscriptions[client.clientName]) == 0 {
159+
client.CloseWithReason(closeConnectionUnused)
160+
ws.handleClientConnectionClose(client, closeConnectionUnused)
162161

163-
return
164-
}
162+
return
165163
}
166164
}()
167165
}
@@ -270,8 +268,6 @@ func (ws *WebSocketServer) WsPageHandler(w http.ResponseWriter, r *http.Request)
270268
ws.handleClientConnectionClose(client, closeClientSentInboundTraffic)
271269
ws.muClients.Unlock()
272270
}
273-
274-
break
275271
}
276272
}
277273

@@ -457,13 +453,13 @@ func (ws *WebSocketServer) HandleRPCEventSubForwarding(eventsubBody string, clie
457453
subscriptionCreatedAtTimestamp := "" // Used below if in strict mode
458454
if ws.StrictMode {
459455
found := false
460-
for _, clientSubscriptions := range ws.Subscriptions {
456+
for subscriptionClientName, clientSubscriptions := range ws.Subscriptions {
461457
if found {
462458
break
463459
}
464460

465461
for _, sub := range clientSubscriptions {
466-
if sub.SessionClientName == client.clientName && sub.Type == eventObj.Subscription.Type && sub.Version == eventObj.Subscription.Version {
462+
if subscriptionClientName == client.clientName && sub.Type == eventObj.Subscription.Type && sub.Version == eventObj.Subscription.Version {
467463
found = true
468464
subscriptionCreatedAtTimestamp = sub.CreatedAt
469465
}
@@ -514,7 +510,7 @@ func (ws *WebSocketServer) HandleRPCEventSubForwarding(eventsubBody string, clie
514510
}
515511

516512
if !didSend {
517-
msg := fmt.Sprintf("Error executing remote triggered EventSub: No clients with the subscribed to [%v / %v]", eventObj.Subscription.Type, eventObj.Subscription.Version)
513+
msg := fmt.Sprintf("Error executing remote triggered EventSub: No clients are subscribed to [%v / %v]", eventObj.Subscription.Type, eventObj.Subscription.Version)
518514
log.Println(msg)
519515
return false, msg
520516
}

internal/events/websocket/mock_server/subscription.go

+7-8
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,13 @@ import (
77
)
88

99
type Subscription struct {
10-
SubscriptionID string // Random GUID for the subscription
11-
ClientID string // Client ID included in headers
12-
Type string // EventSub topic
13-
Version string // EventSub topic version
14-
CreatedAt string // Timestamp of when the subscription was created
15-
DisabledAt *time.Time // Not public; Timestamp of when the subscription was disabled
16-
Status string // Status of the subscription
17-
SessionClientName string // Client name of the session this is associated with.
10+
SubscriptionID string // Random GUID for the subscription
11+
ClientID string // Client ID included in headers
12+
Type string // EventSub topic
13+
Version string // EventSub topic version
14+
CreatedAt string // Timestamp of when the subscription was created
15+
DisabledAt *time.Time // Not public; Timestamp of when the subscription was disabled
16+
Status string // Status of the subscription
1817

1918
ClientConnectedAt string // Time client connected
2019
ClientDisconnectedAt string // Time client disconnected

internal/login/login.go

+72-2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ type ValidateResponse struct {
6666
ExpiresIn int64 `json:"expires_in"`
6767
}
6868

69+
type DeviceCodeFlowInitResponse struct {
70+
DeviceCode string `json:"device_code"`
71+
ExpiresIn int `json:"expires_in"`
72+
Interval int `json:"interval"`
73+
UserCode string `json:"user_code"`
74+
VerificationUri string `json:"verification_uri"`
75+
}
76+
6977
const ClientCredentialsURL = "https://id.twitch.tv/oauth2/token?grant_type=client_credentials"
7078
const UserCredentialsURL = "https://id.twitch.tv/oauth2/token?grant_type=authorization_code"
7179

@@ -75,6 +83,10 @@ const RefreshTokenURL = "https://id.twitch.tv/oauth2/token?grant_type=refresh_to
7583
const RevokeTokenURL = "https://id.twitch.tv/oauth2/revoke"
7684
const ValidateTokenURL = "https://id.twitch.tv/oauth2/validate"
7785

86+
const DeviceCodeFlowUrl = "https://id.twitch.tv/oauth2/device"
87+
const DeviceCodeFlowTokenURL = "https://id.twitch.tv/oauth2/token"
88+
const DeviceCodeFlowGrantType = "urn:ietf:params:oauth:grant-type:device_code"
89+
7890
// Sends `https://id.twitch.tv/oauth2/token?grant_type=client_credentials`.
7991
// Generates a new App Access Token. Stores new token information in the CLI's config.
8092
func ClientCredentialsLogin(p LoginParameters) (LoginResponse, error) {
@@ -104,9 +116,10 @@ func ClientCredentialsLogin(p LoginParameters) (LoginResponse, error) {
104116
return r, nil
105117
}
106118

119+
// Uses Authorization Code Flow: https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow
107120
// Sends `https://id.twitch.tv/oauth2/token?grant_type=authorization_code`.
108-
// Generates a new App Access Token, requiring the use of a web browser. Stores new token information in the CLI's config.
109-
func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort string) (LoginResponse, error) {
121+
// Generates a new User Access Token, requiring the use of a web browser. Stores new token information in the CLI's config.
122+
func UserCredentialsLogin_AuthorizationCodeFlow(p LoginParameters, webserverIP string, webserverPort string) (LoginResponse, error) {
110123
u, err := url.Parse(p.AuthorizeURL)
111124
if err != nil {
112125
return LoginResponse{}, fmt.Errorf("Internal error (parsing AuthorizeURL): %v", err.Error())
@@ -161,6 +174,14 @@ func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort s
161174
return LoginResponse{}, fmt.Errorf("Error reading body: %v", err.Error())
162175
}
163176

177+
if resp.StatusCode == 400 {
178+
// 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
179+
return LoginResponse{}, fmt.Errorf(
180+
"This Client Type of this Client ID is set to \"Public\", which doesn't allow the use of Authorization Code Grant Flow.\n" +
181+
"Please call the token command with the --dcf flag to use Device Code Flow. For example: twitch token -u --dcf",
182+
)
183+
}
184+
164185
r, err := handleLoginResponse(resp.Body, true)
165186
if err != nil {
166187
return LoginResponse{}, fmt.Errorf("Error handling login: %v", err.Error())
@@ -169,6 +190,55 @@ func UserCredentialsLogin(p LoginParameters, webserverIP string, webserverPort s
169190
return r, nil
170191
}
171192

193+
// Uses Device Code Flow: https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow
194+
// 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.
195+
func UserCredentialsLogin_DeviceCodeFlow(p LoginParameters) (LoginResponse, error) {
196+
// Initiate DCF flow
197+
deviceResp, err := dcfInitiateRequest(DeviceCodeFlowUrl, p.ClientID, p.Scopes)
198+
if err != nil {
199+
return LoginResponse{}, fmt.Errorf("Error initiating Device Code Flow: %v", err.Error())
200+
}
201+
202+
var deviceObj DeviceCodeFlowInitResponse
203+
if err := json.Unmarshal(deviceResp.Body, &deviceObj); err != nil {
204+
return LoginResponse{}, fmt.Errorf("Error reading body: %v", err.Error())
205+
}
206+
expirationTime := time.Now().Add(time.Second * time.Duration(deviceObj.ExpiresIn))
207+
208+
fmt.Printf("Started Device Code Flow login.\n")
209+
fmt.Printf("Use this URL to log in: %v\n", deviceObj.VerificationUri)
210+
fmt.Printf("Use this code when prompted at the above URL: %v\n\n", deviceObj.UserCode)
211+
fmt.Printf("This system will check every %v seconds, and will expire after %v minutes.\n", deviceObj.Interval, (deviceObj.ExpiresIn / 60))
212+
213+
// Loop and check for user login. Respects given interval, and times out after expiration
214+
tokenResp := loginRequestResponse{StatusCode: 999}
215+
for tokenResp.StatusCode != 0 {
216+
// Check for expiration
217+
if time.Now().After(expirationTime) {
218+
return LoginResponse{}, fmt.Errorf("The Device Code used for getting access token has expired. Run token command again to generate a new user.")
219+
}
220+
221+
// Wait interval
222+
time.Sleep(time.Second * time.Duration(deviceObj.Interval))
223+
224+
// Check for token
225+
tokenResp, err = dcfTokenRequest(DeviceCodeFlowTokenURL, p.ClientID, p.Scopes, deviceObj.DeviceCode, DeviceCodeFlowGrantType)
226+
if err != nil {
227+
return LoginResponse{}, fmt.Errorf("Error getting token via Device Code Flow: %v", err)
228+
}
229+
230+
if tokenResp.StatusCode == 200 {
231+
r, err := handleLoginResponse(tokenResp.Body, true)
232+
if err != nil {
233+
return LoginResponse{}, fmt.Errorf("Error handling login: %v", err.Error())
234+
}
235+
return r, nil
236+
}
237+
}
238+
239+
return LoginResponse{}, nil
240+
}
241+
172242
// Sends `https://id.twitch.tv/oauth2/revoke`.
173243
// Revokes the provided token. Does not change the CLI's config at all.
174244
func CredentialsLogout(p LoginParameters) (LoginResponse, error) {

internal/login/login_request.go

+60
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
package login
44

55
import (
6+
"bytes"
67
"io"
8+
"mime/multipart"
79
"net/http"
810
"time"
911

@@ -56,3 +58,61 @@ func loginRequestWithHeaders(method string, url string, payload io.Reader, heade
5658
Body: body,
5759
}, nil
5860
}
61+
62+
func dcfInitiateRequest(url string, clientId string, scopes string) (loginRequestResponse, error) {
63+
formData := map[string]string{
64+
"client_id": clientId,
65+
"scopes": scopes,
66+
}
67+
68+
return sendMultipartPostRequest(url, formData)
69+
}
70+
71+
func dcfTokenRequest(url string, clientId string, scopes string, deviceCode string, grantType string) (loginRequestResponse, error) {
72+
formData := map[string]string{
73+
"client_id": clientId,
74+
"scopes": scopes,
75+
"device_code": deviceCode,
76+
"grant_type": grantType,
77+
}
78+
79+
return sendMultipartPostRequest(url, formData)
80+
}
81+
82+
// Creates and sends a request with the content type multipart/form-data
83+
func sendMultipartPostRequest(url string, formData map[string]string) (loginRequestResponse, error) {
84+
// Create form's body using the provided data
85+
formBody := new(bytes.Buffer)
86+
mp := multipart.NewWriter(formBody)
87+
for k, v := range formData {
88+
mp.WriteField(k, v)
89+
}
90+
mp.Close() // If you do defer on this instead, it gets an "unexpected EOF" error from Twitch's servers
91+
92+
req, err := request.NewRequest("POST", url, formBody)
93+
if err != nil {
94+
return loginRequestResponse{}, err
95+
}
96+
97+
// Add Content-Type header, generated with the boundary associated with the form
98+
req.Header.Add("Content-Type", mp.FormDataContentType())
99+
100+
client := &http.Client{
101+
Timeout: time.Second * 10,
102+
}
103+
resp, err := client.Do(req)
104+
if err != nil {
105+
return loginRequestResponse{}, err
106+
}
107+
108+
responseBody, err := io.ReadAll(resp.Body)
109+
defer resp.Body.Close()
110+
if err != nil {
111+
return loginRequestResponse{}, err
112+
}
113+
114+
return loginRequestResponse{
115+
StatusCode: resp.StatusCode,
116+
Body: responseBody,
117+
}, nil
118+
}

0 commit comments

Comments
 (0)