Skip to content

Commit de5ddfa

Browse files
committed
Added DCF support for twitch token; Added error message when trying to use non-dcf flow on a public client
1 parent e5c22fe commit de5ddfa

File tree

3 files changed

+143
-4
lines changed

3 files changed

+143
-4
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

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)