Skip to content

Commit 3b94224

Browse files
feat: Implementation of OAuth Device Code Flow (#245)
* feat: implementation of device code flow * applied suggestions from copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed typo in accessToken error messages --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 9e9d881 commit 3b94224

File tree

7 files changed

+504
-66
lines changed

7 files changed

+504
-66
lines changed

SUPPORTED_ENDPOINTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- [x] Generate Authorization URL ("code" or "token" authorization)
66
- [x] Get App Access Tokens (OAuth Client Credentials Flow)
77
- [x] Get User Access Tokens (OAuth Authorization Code Flow)
8+
- [x] Get Device Access Tokens (OAuth Device Code Flow)
89
- [x] Refresh User Access Tokens
910
- [x] Revoke User Access Tokens
1011
- [x] Validate Access Token

authentication.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
)
77

88
var authPaths = map[string]string{
9+
"device": "/device",
910
"token": "/token",
1011
"revoke": "/revoke",
1112
"validate": "/validate",
@@ -115,6 +116,83 @@ func (c *Client) RequestUserAccessToken(code string) (*UserAccessTokenResponse,
115116
return token, nil
116117
}
117118

119+
type DeviceVerificationURIResponse struct {
120+
ResponseCommon
121+
Data DeviceVerificationCredentials
122+
}
123+
124+
type DeviceVerificationCredentials struct {
125+
DeviceCode string `json:"device_code"`
126+
ExpiresIn int `json:"expires_in"`
127+
Interval int `json:"interval"`
128+
UserCode string `json:"user_code"`
129+
VerificationURI string `json:"verification_uri"`
130+
}
131+
132+
type DeviceVerificationRequestData struct {
133+
ClientID string `query:"client_id"`
134+
Scopes string `query:"scope"`
135+
}
136+
137+
func (c *Client) RequestDeviceVerificationURI(scopes []string) (*DeviceVerificationURIResponse, error) {
138+
opts := c.opts
139+
data := &DeviceVerificationRequestData{
140+
ClientID: opts.ClientID,
141+
Scopes: strings.Join(scopes, " "),
142+
}
143+
144+
resp, err := c.post(authPaths["device"], &DeviceVerificationCredentials{}, data)
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
uri := &DeviceVerificationURIResponse{}
150+
resp.HydrateResponseCommon(&uri.ResponseCommon)
151+
uri.Data.DeviceCode = resp.Data.(*DeviceVerificationCredentials).DeviceCode
152+
uri.Data.ExpiresIn = resp.Data.(*DeviceVerificationCredentials).ExpiresIn
153+
uri.Data.Interval = resp.Data.(*DeviceVerificationCredentials).Interval
154+
uri.Data.UserCode = resp.Data.(*DeviceVerificationCredentials).UserCode
155+
uri.Data.VerificationURI = resp.Data.(*DeviceVerificationCredentials).VerificationURI
156+
157+
return uri, nil
158+
}
159+
160+
type DeviceAccessTokenResponse struct {
161+
ResponseCommon
162+
Data AccessCredentials
163+
}
164+
165+
type DeviceAccessTokenRequestData struct {
166+
ClientID string `query:"client_id"`
167+
DeviceCode string `query:"device_code"`
168+
GrantType string `query:"grant_type"`
169+
Scopes string `query:"scope"`
170+
}
171+
172+
func (c *Client) RequestDeviceAccessToken(deviceCode string, scopes []string) (*DeviceAccessTokenResponse, error) {
173+
opts := c.opts
174+
data := &DeviceAccessTokenRequestData{
175+
ClientID: opts.ClientID,
176+
DeviceCode: deviceCode,
177+
GrantType: "urn:ietf:params:oauth:grant-type:device_code",
178+
Scopes: strings.Join(scopes, " "),
179+
}
180+
181+
resp, err := c.post(authPaths["token"], &AccessCredentials{}, data)
182+
if err != nil {
183+
return nil, err
184+
}
185+
186+
token := &DeviceAccessTokenResponse{}
187+
resp.HydrateResponseCommon(&token.ResponseCommon)
188+
token.Data.AccessToken = resp.Data.(*AccessCredentials).AccessToken
189+
token.Data.RefreshToken = resp.Data.(*AccessCredentials).RefreshToken
190+
token.Data.ExpiresIn = resp.Data.(*AccessCredentials).ExpiresIn
191+
token.Data.Scopes = resp.Data.(*AccessCredentials).Scopes
192+
193+
return token, nil
194+
}
195+
118196
type RefreshTokenResponse struct {
119197
ResponseCommon
120198
Data AccessCredentials

authentication_test.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,232 @@ func TestRequestAppAccessToken(t *testing.T) {
152152
}
153153
}
154154

155+
func TestRequestDeviceVerificationURI(t *testing.T) {
156+
t.Parallel()
157+
158+
testCases := []struct {
159+
statusCode int
160+
scopes []string
161+
options *Options
162+
respBody string
163+
expectedErrMsg string
164+
}{
165+
{
166+
http.StatusBadRequest,
167+
[]string{"user:read:email"},
168+
&Options{
169+
ClientID: "invalid-client-id", // invalid client id
170+
},
171+
`{"status":400,"message":"invalid client"}`,
172+
"invalid client",
173+
},
174+
{
175+
http.StatusOK,
176+
[]string{}, // no scopes
177+
&Options{
178+
ClientID: "valid-client-id",
179+
},
180+
`{"device_code":"2mdjwJNygVNDpNiLZnygtCJTQjedpevQsoST7fi1","expires_in":1800,"interval":5,"user_code":"RGVPJWCX","verification_uri":"https://www.twitch.tv/activate?device-code=RGVPJWCX"}`,
181+
"",
182+
},
183+
{
184+
http.StatusOK,
185+
[]string{"user:read:email", "user:read:subscriptions", "channel:read:subscriptions"},
186+
&Options{
187+
ClientID: "valid-client-id",
188+
},
189+
`{"device_code":"uZqmEQqkOZO1NWsh0gMHWvsiEevIDLyYV3Y3Beku","expires_in":1800,"interval":5,"user_code":"RFFJTTBK","verification_uri":"https://www.twitch.tv/activate?device-code=RFFJTTBK"}`,
190+
"",
191+
},
192+
}
193+
194+
for _, testCase := range testCases {
195+
c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
196+
197+
resp, err := c.RequestDeviceVerificationURI(testCase.scopes)
198+
if err != nil {
199+
t.Error(err)
200+
}
201+
202+
if resp.StatusCode != testCase.statusCode {
203+
t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
204+
}
205+
206+
// Test error cases
207+
if resp.StatusCode != http.StatusOK {
208+
if resp.ErrorStatus != testCase.statusCode {
209+
t.Errorf("expected error status to be \"%d\", got \"%d\"", testCase.statusCode, resp.ErrorStatus)
210+
}
211+
212+
if resp.ErrorMessage != testCase.expectedErrMsg {
213+
t.Errorf("expected error message to be \"%s\", got \"%s\"", testCase.expectedErrMsg, resp.ErrorMessage)
214+
}
215+
216+
continue
217+
}
218+
219+
// Test success cases
220+
if resp.Data.DeviceCode == "" {
221+
t.Errorf("expected a device code but got an empty string")
222+
}
223+
224+
if resp.Data.ExpiresIn == 0 {
225+
t.Errorf("expected ExpiresIn to not be \"0\"")
226+
}
227+
228+
if resp.Data.Interval == 0 {
229+
t.Errorf("expected Interval to not be \"0\"")
230+
}
231+
232+
if resp.Data.UserCode == "" {
233+
t.Errorf("expected an user code but got an empty string")
234+
}
235+
236+
if resp.Data.VerificationURI == "" {
237+
t.Errorf("expected a verification uri but got an empty string")
238+
}
239+
}
240+
241+
// Test with HTTP Failure
242+
options := &Options{
243+
ClientID: "my-client-id",
244+
HTTPClient: &badMockHTTPClient{
245+
newMockHandler(0, "", nil),
246+
},
247+
}
248+
c := &Client{
249+
opts: options,
250+
ctx: context.Background(),
251+
}
252+
253+
_, err := c.RequestDeviceVerificationURI([]string{})
254+
if err == nil {
255+
t.Error("expected error but got nil")
256+
}
257+
258+
if err.Error() != "Failed to execute API request: Oops, that's bad :(" {
259+
t.Error("expected error does match return error")
260+
}
261+
}
262+
263+
func TestRequestDeviceAccessToken(t *testing.T) {
264+
t.Parallel()
265+
266+
testCases := []struct {
267+
statusCode int
268+
deviceCode string
269+
scopes []string
270+
options *Options
271+
respBody string
272+
expectedErrMsg string
273+
}{
274+
{
275+
http.StatusBadRequest,
276+
"invalid-device-code", // invalid auth code
277+
[]string{"user:read:email"},
278+
&Options{
279+
ClientID: "valid-client-id",
280+
},
281+
`{"status":400,"message":"invalid device code"}`,
282+
"invalid device code",
283+
},
284+
{
285+
http.StatusBadRequest,
286+
"valid-device-code",
287+
[]string{"user:read:email"},
288+
&Options{
289+
ClientID: "invalid-client-id", // invalid client id
290+
},
291+
`{"status":400,"message":"invalid client"}`,
292+
"invalid client",
293+
},
294+
{
295+
http.StatusOK,
296+
"valid-auth-code",
297+
[]string{}, // no scopes
298+
&Options{
299+
ClientID: "valid-client-id",
300+
},
301+
`{"access_token":"kagsfkgiuowegfkjsbdcuiwebf","expires_in":14146,"refresh_token":"fiuhgaofohofhohdflhoiwephvlhowiehfoi"}`,
302+
"",
303+
},
304+
{
305+
http.StatusOK,
306+
"valid-auth-code",
307+
[]string{"analytics:read:games", "bits:read", "clips:edit", "user:edit", "user:read:email"},
308+
&Options{
309+
ClientID: "valid-client-id",
310+
},
311+
`{"access_token":"kagsfkgiuowegfkjsbdcuiwebf","expires_in":14154,"refresh_token":"fiuhgaofohofhohdflhoiwephvlhowiehfoi","scope":["analytics:read:games","bits:read","clips:edit","user:edit","user:read:email"]}`,
312+
"",
313+
},
314+
}
315+
316+
for _, testCase := range testCases {
317+
c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
318+
319+
resp, err := c.RequestDeviceAccessToken(testCase.deviceCode, testCase.scopes)
320+
if err != nil {
321+
t.Error(err)
322+
}
323+
324+
if resp.StatusCode != testCase.statusCode {
325+
t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
326+
}
327+
328+
// Test error cases
329+
if resp.StatusCode != http.StatusOK {
330+
if resp.ErrorStatus != testCase.statusCode {
331+
t.Errorf("expected error status to be \"%d\", got \"%d\"", testCase.statusCode, resp.ErrorStatus)
332+
}
333+
334+
if resp.ErrorMessage != testCase.expectedErrMsg {
335+
t.Errorf("expected error message to be \"%s\", got \"%s\"", testCase.expectedErrMsg, resp.ErrorMessage)
336+
}
337+
338+
continue
339+
}
340+
341+
// Test success cases
342+
if resp.Data.AccessToken == "" {
343+
t.Errorf("expected an access token but got an empty string")
344+
}
345+
346+
if resp.Data.RefreshToken == "" {
347+
t.Errorf("expected a refresh token but got an empty string")
348+
}
349+
350+
if resp.Data.ExpiresIn == 0 {
351+
t.Errorf("expected ExpiresIn to not be \"0\"")
352+
}
353+
354+
if len(resp.Data.Scopes) != len(testCase.scopes) {
355+
t.Errorf("expected number of scope to be \"%d\", got \"%d\"", len(testCase.scopes), len(resp.Data.Scopes))
356+
}
357+
}
358+
359+
// Test with HTTP Failure
360+
options := &Options{
361+
ClientID: "my-client-id",
362+
HTTPClient: &badMockHTTPClient{
363+
newMockHandler(0, "", nil),
364+
},
365+
}
366+
c := &Client{
367+
opts: options,
368+
ctx: context.Background(),
369+
}
370+
371+
_, err := c.RequestDeviceAccessToken("valid-device-code", []string{})
372+
if err == nil {
373+
t.Error("expected error but got nil")
374+
}
375+
376+
if err.Error() != "Failed to execute API request: Oops, that's bad :(" {
377+
t.Error("expected error does match return error")
378+
}
379+
}
380+
155381
func TestRequestUserAccessToken(t *testing.T) {
156382
t.Parallel()
157383

0 commit comments

Comments
 (0)