From 8c7bec4dc3a447952ecf1158531beaffd9d600bb Mon Sep 17 00:00:00 2001 From: ealexandrohin Date: Thu, 7 Aug 2025 17:56:59 +0300 Subject: [PATCH 1/3] feat: implementation of device code flow --- SUPPORTED_ENDPOINTS.md | 1 + authentication.go | 78 +++++++++++++ authentication_test.go | 226 ++++++++++++++++++++++++++++++++++++ docs/README.md | 50 ++++---- docs/authentication_docs.md | 59 ++++++++++ helix.go | 44 ++++--- helix_test.go | 109 ++++++++++++----- 7 files changed, 502 insertions(+), 65 deletions(-) diff --git a/SUPPORTED_ENDPOINTS.md b/SUPPORTED_ENDPOINTS.md index 71b90dc..5f8dd50 100644 --- a/SUPPORTED_ENDPOINTS.md +++ b/SUPPORTED_ENDPOINTS.md @@ -5,6 +5,7 @@ - [x] Generate Authorization URL ("code" or "token" authorization) - [x] Get App Access Tokens (OAuth Client Credentials Flow) - [x] Get User Access Tokens (OAuth Authorization Code Flow) +- [x] Get Device Access Tokens (OAuth Device Code Flow) - [x] Refresh User Access Tokens - [x] Revoke User Access Tokens - [x] Validate Access Token diff --git a/authentication.go b/authentication.go index e9865a1..06472be 100644 --- a/authentication.go +++ b/authentication.go @@ -6,6 +6,7 @@ import ( ) var authPaths = map[string]string{ + "device": "/device", "token": "/token", "revoke": "/revoke", "validate": "/validate", @@ -115,6 +116,83 @@ func (c *Client) RequestUserAccessToken(code string) (*UserAccessTokenResponse, return token, nil } +type DeviceVerificationURIResponse struct { + ResponseCommon + Data DeviceVerificationCredentials +} + +type DeviceVerificationCredentials 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"` +} + +type DeviceVerificationRequestData struct { + ClientID string `query:"client_id"` + Scopes string `query:"scope"` +} + +func (c *Client) RequestDeviceVerificationURI(scopes []string) (*DeviceVerificationURIResponse, error) { + opts := c.opts + data := &DeviceVerificationRequestData{ + ClientID: opts.ClientID, + Scopes: strings.Join(scopes, " "), + } + + resp, err := c.post(authPaths["device"], &DeviceVerificationCredentials{}, data) + if err != nil { + return nil, err + } + + uri := &DeviceVerificationURIResponse{} + resp.HydrateResponseCommon(&uri.ResponseCommon) + uri.Data.DeviceCode = resp.Data.(*DeviceVerificationCredentials).DeviceCode + uri.Data.ExpiresIn = resp.Data.(*DeviceVerificationCredentials).ExpiresIn + uri.Data.Interval = resp.Data.(*DeviceVerificationCredentials).Interval + uri.Data.UserCode = resp.Data.(*DeviceVerificationCredentials).UserCode + uri.Data.VerificationURI = resp.Data.(*DeviceVerificationCredentials).VerificationURI + + return uri, nil +} + +type DeviceAccessTokenResponse struct { + ResponseCommon + Data AccessCredentials +} + +type DeviceAccessTokenRequestData struct { + ClientID string `query:"client_id"` + DeviceCode string `query:"device_code"` + GrantType string `query:"grant_type"` + Scopes string `query:"scope"` +} + +func (c *Client) RequestDeviceAccessToken(deviceCode string, scopes []string) (*DeviceAccessTokenResponse, error) { + opts := c.opts + data := &DeviceAccessTokenRequestData{ + ClientID: opts.ClientID, + DeviceCode: deviceCode, + GrantType: "urn:ietf:params:oauth:grant-type:device_code", + Scopes: strings.Join(scopes, " "), + } + + resp, err := c.post(authPaths["token"], &AccessCredentials{}, data) + if err != nil { + return nil, err + } + + token := &DeviceAccessTokenResponse{} + resp.HydrateResponseCommon(&token.ResponseCommon) + token.Data.AccessToken = resp.Data.(*AccessCredentials).AccessToken + token.Data.RefreshToken = resp.Data.(*AccessCredentials).RefreshToken + token.Data.ExpiresIn = resp.Data.(*AccessCredentials).ExpiresIn + token.Data.Scopes = resp.Data.(*AccessCredentials).Scopes + + return token, nil +} + type RefreshTokenResponse struct { ResponseCommon Data AccessCredentials diff --git a/authentication_test.go b/authentication_test.go index 30b1a22..3cd0aa0 100644 --- a/authentication_test.go +++ b/authentication_test.go @@ -152,6 +152,232 @@ func TestRequestAppAccessToken(t *testing.T) { } } +func TestRequestDeviceVerificationURI(t *testing.T) { + t.Parallel() + + testCases := []struct { + statusCode int + scopes []string + options *Options + respBody string + expectedErrMsg string + }{ + { + http.StatusBadRequest, + []string{"user:read:email"}, + &Options{ + ClientID: "invalid-client-id", // invalid client id + }, + `{"status":400,"message":"invalid client"}`, + "invalid client", + }, + { + http.StatusOK, + []string{}, // no scopes + &Options{ + ClientID: "valid-client-id", + }, + `{"device_code":"2mdjwJNygVNDpNiLZnygtCJTQjedpevQsoST7fi1","expires_in":1800,"interval":5,"user_code":"RGVPJWCX","verification_uri":"https://www.twitch.tv/activate?device-code=RGVPJWCX"}`, + "", + }, + { + http.StatusOK, + []string{"user:read:email", "user:read:subscriptions", "channel:read:subscriptions"}, + &Options{ + ClientID: "valid-client-id", + }, + `{"device_code":"uZqmEQqkOZO1NWsh0gMHWvsiEevIDLyYV3Y3Beku","expires_in":1800,"interval":5,"user_code":"RFFJTTBK","verification_uri":"https://www.twitch.tv/activate?device-code=RFFJTTBK"}`, + "", + }, + } + + for _, testCase := range testCases { + c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil)) + + resp, err := c.RequestDeviceVerificationURI(testCase.scopes) + if err != nil { + t.Error(err) + } + + if resp.StatusCode != testCase.statusCode { + t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode) + } + + // Test error cases + if resp.StatusCode != http.StatusOK { + if resp.ErrorStatus != testCase.statusCode { + t.Errorf("expected error status to be \"%d\", got \"%d\"", testCase.statusCode, resp.ErrorStatus) + } + + if resp.ErrorMessage != testCase.expectedErrMsg { + t.Errorf("expected error message to be \"%s\", got \"%s\"", testCase.expectedErrMsg, resp.ErrorMessage) + } + + continue + } + + // Test success cases + if resp.Data.DeviceCode == "" { + t.Errorf("expected a device code but got an empty string") + } + + if resp.Data.ExpiresIn == 0 { + t.Errorf("expected ExpiresIn to not be \"0\"") + } + + if resp.Data.Interval == 0 { + t.Errorf("expected Interval to not be \"0\"") + } + + if resp.Data.UserCode == "" { + t.Errorf("expected an user code but got an empty string") + } + + if resp.Data.VerificationURI == "" { + t.Errorf("expected a verificaion uri but got an empty string") + } + } + + // Test with HTTP Failure + options := &Options{ + ClientID: "my-client-id", + HTTPClient: &badMockHTTPClient{ + newMockHandler(0, "", nil), + }, + } + c := &Client{ + opts: options, + ctx: context.Background(), + } + + _, err := c.RequestDeviceVerificationURI([]string{}) + if err == nil { + t.Error("expected error but got nil") + } + + if err.Error() != "Failed to execute API request: Oops, that's bad :(" { + t.Error("expected error does match return error") + } +} + +func TestRequestDeviceAccessToken(t *testing.T) { + t.Parallel() + + testCases := []struct { + statusCode int + deviceCode string + scopes []string + options *Options + respBody string + expectedErrMsg string + }{ + { + http.StatusBadRequest, + "invalid-device-code", // invalid auth code + []string{"user:read:email"}, + &Options{ + ClientID: "valid-client-id", + }, + `{"status":400,"message":"invalid device code"}`, + "invalid device code", + }, + { + http.StatusBadRequest, + "valid-device-code", + []string{"user:read:email"}, + &Options{ + ClientID: "invalid-client-id", // invalid client id + }, + `{"status":400,"message":"invalid client"}`, + "invalid client", + }, + { + http.StatusOK, + "valid-auth-code", + []string{}, // no scopes + &Options{ + ClientID: "valid-client-id", + }, + `{"access_token":"kagsfkgiuowegfkjsbdcuiwebf","expires_in":14146,"refresh_token":"fiuhgaofohofhohdflhoiwephvlhowiehfoi"}`, + "", + }, + { + http.StatusOK, + "valid-auth-code", + []string{"analytics:read:games", "bits:read", "clips:edit", "user:edit", "user:read:email"}, + &Options{ + ClientID: "valid-client-id", + }, + `{"access_token":"kagsfkgiuowegfkjsbdcuiwebf","expires_in":14154,"refresh_token":"fiuhgaofohofhohdflhoiwephvlhowiehfoi","scope":["analytics:read:games","bits:read","clips:edit","user:edit","user:read:email"]}`, + "", + }, + } + + for _, testCase := range testCases { + c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil)) + + resp, err := c.RequestDeviceAccessToken(testCase.deviceCode, testCase.scopes) + if err != nil { + t.Error(err) + } + + if resp.StatusCode != testCase.statusCode { + t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode) + } + + // Test error cases + if resp.StatusCode != http.StatusOK { + if resp.ErrorStatus != testCase.statusCode { + t.Errorf("expected error status to be \"%d\", got \"%d\"", testCase.statusCode, resp.ErrorStatus) + } + + if resp.ErrorMessage != testCase.expectedErrMsg { + t.Errorf("expected error message to be \"%s\", got \"%s\"", testCase.expectedErrMsg, resp.ErrorMessage) + } + + continue + } + + // Test success cases + if resp.Data.AccessToken == "" { + t.Errorf("expected an access token but got an empty string") + } + + if resp.Data.RefreshToken == "" { + t.Errorf("expected a refresh token but got an empty string") + } + + if resp.Data.ExpiresIn == 0 { + t.Errorf("expected ExpiresIn to not be \"0\"") + } + + if len(resp.Data.Scopes) != len(testCase.scopes) { + t.Errorf("expected number of scope to be \"%d\", got \"%d\"", len(testCase.scopes), len(resp.Data.Scopes)) + } + } + + // Test with HTTP Failure + options := &Options{ + ClientID: "my-client-id", + HTTPClient: &badMockHTTPClient{ + newMockHandler(0, "", nil), + }, + } + c := &Client{ + opts: options, + ctx: context.Background(), + } + + _, err := c.RequestDeviceAccessToken("valid-device-code", []string{}) + if err == nil { + t.Error("expected error but got nil") + } + + if err.Error() != "Failed to execute API request: Oops, that's bad :(" { + t.Error("expected error does match return error") + } +} + func TestRequestUserAccessToken(t *testing.T) { t.Parallel() diff --git a/docs/README.md b/docs/README.md index 096f4a4..2f96591 100644 --- a/docs/README.md +++ b/docs/README.md @@ -128,17 +128,19 @@ if err != nil { Below is a list of all available options that can be passed in when creating a new client: ```go + type Options struct { - ClientID string // Required - ClientSecret string // Default: empty string - AppAccessToken string // Default: empty string - UserAccessToken string // Default: empty string - RefreshToken string // Default: empty string - UserAgent string // Default: empty string - RedirectURI string // Default: empty string - HTTPClient HTTPClient // Default: http.DefaultClient - RateLimitFunc RateLimitFunc // Default: nil - APIBaseURL string // Default: https://api.twitch.tv/helix + ClientID string // Required + ClientSecret string // Default: empty string + AppAccessToken string // Default: empty string + DeviceAccessToken string // Default: empty string + UserAccessToken string // Default: empty string + RefreshToken string // Default: empty string + UserAgent string // Default: empty string + RedirectURI string // Default: empty string + HTTPClient HTTPClient // Default: http.DefaultClient + RateLimitFunc RateLimitFunc // Default: nil + APIBaseURL string // Default: https://api.twitch.tv/helix } ``` @@ -232,8 +234,8 @@ a 429 (Too Many Requests) response. Before retrying the request, the `RateLimitF ## Access Tokens -Some API endpoints require that you have a valid access token in order to fulfill the request. There are two types -of access tokens: app access tokens and user access tokens. +Some API endpoints require that you have a valid access token in order to fulfill the request. There are three types +of access tokens: app access tokens, user access tokens and device access tokens. App access tokens allow game developers to integrate their game into Twitch's viewing experience. [Drops](https://dev.twitch.tv/drops) are an example of this. @@ -242,18 +244,20 @@ User access tokens, on the other hand, are used to interact with the Twitch API If you're only looking to consume the standard API, such as getting access to a user's registered email address, user access tokens are what you will need. -It is worth noting that both app and user access tokens have the ability to extend the request rate limit enforced by -Twitch. However, if you provide both an app and a user token - as is the case in the below example - the app access -token will be ignored as user access tokens are prioritized when setting the request _Authorization_ header. +Device access tokens are used to authenticate stand alone devices, such as videogame consoles, CLIs, etc. + +It is worth noting that both app and user access tokens have the ability to extend the request rate limit enforced by Twitch. + +However, if you provide all three tokens: an app token, a user token and a device token - as is the case in the below example - both the app access token and the device access token will be ignored as user access tokens are prioritized when setting the request _Authorization_ header. -In order to set the access token for a request, you can either supply it as an option or use the `SetUserAccessToken` -or `SetAppAccessToken` methods. For example: +In order to set the access token for a request, you can either supply it as an option or use the `SetUserAccessToken`, `SetAppAccessToken` or `SetDeviceAccessToken` methods. For example: ```go client, err := helix.NewClient(&helix.Options{ - ClientID: "your-client-id", - UserAccessToken: "your-user-access-token", - AppAccessToken: "your-app-access-token" + ClientID: "your-client-id", + UserAccessToken: "your-user-access-token", + AppAccessToken: "your-app-access-token", + DeviceAccessToken: "your-device-access-token" }) if err != nil { // handle error @@ -274,13 +278,13 @@ if err != nil { client.SetUserAccessToken("your-user-access-token") client.SetAppAccessToken("your-app-access-token") +client.SetDeviceAccessToken("your-device-access-token") // send API request... ``` Note that any subsequent API requests will utilize this same access token. So it is necessary to unset the access -token when you are finished with it. To do so, simply pass an empty string to the `SetUserAccessToken` or -`SetAppAccessToken` methods. +token when you are finished with it. To do so, simply pass an empty string to the `SetUserAccessToken`, `SetAppAccessToken` or `SetDeviceAccessToken` methods. ### Automatically refresh user access tokens @@ -318,8 +322,6 @@ client.OnUserAccessTokenRefreshed(func(newAccessToken, newRefreshToken string) { It's entirely possible that you may want to set or change the *User-Agent* header value that is sent with each request. You can do so by passing it through as an option when creating a new client, like so: -with the `SetUserAgent()` method before sending a request. For example: - ```go client, err := helix.NewClient(&helix.Options{ ClientID: "your-client-id", diff --git a/docs/authentication_docs.md b/docs/authentication_docs.md index 275606a..5469ad3 100644 --- a/docs/authentication_docs.md +++ b/docs/authentication_docs.md @@ -150,3 +150,62 @@ fmt.Printf("%+v\n", resp) // Set the access token on the client client.SetAppAccessToken(resp.Data.AccessToken) ``` + +## Get Device Access Token + +Here's an example of how to create a device access token. First, you need to request a device verification URI, which the user will visit to verify their device. After the user verifies the device, you can request an access token using the device code. + +If user hasn't verified the device yet, Twitch API will return `{"status":400,"message":"authorization_pending"}`. + +```go +client, err := helix.NewClient(&helix.Options{ + ClientID: "your-client-id", +}) +if err != nil { + // handle error +} + +respURI, err := client.RequestDeviceVerificationURI([]string{"user:read:follows"}) +if err != nil { + // handle error +} + +// Link to redirect the user to for device verification +fmt.Printf("%+v\n", respURI.Data.VerificationURI) + +// After user verified, set the access token on the client +respToken, err := client.RequestDeviceAccessToken(respURI.Data.DeviceCode, []string{"user:read:follows"}) +if err != nil { + // handle error +} + +fmt.Printf("%+v\n", respToken.Data) + +client.SetDeviceAccessToken(respToken.Data.AccessToken) +client.SetRefreshToken(respToken.Data.RefreshToken) +``` + +## Refresh Device Access Token + +Here's an example of how to refresh a device access token. + +```go +client, err := helix.NewClient(&helix.Options{ + ClientID: "your-client-id", +}) +if err != nil { + // handle error +} + +// Get the refresh token from the client +refreshToken := client.GetRefreshToken() + +if canRefresh := client.canRefreshToken(); canRefresh { + resp, err := client.RefreshToken(refreshToken) + if err != nil { + // handle error + } + + fmt.Printf("%+v\n", resp) +} +``` diff --git a/helix.go b/helix.go index e36dad5..26e8dd1 100644 --- a/helix.go +++ b/helix.go @@ -39,17 +39,18 @@ type Client struct { } type Options struct { - ClientID string - ClientSecret string - AppAccessToken string - UserAccessToken string - RefreshToken string - UserAgent string - RedirectURI string - HTTPClient HTTPClient - RateLimitFunc RateLimitFunc - APIBaseURL string - ExtensionOpts ExtensionOptions + ClientID string + ClientSecret string + AppAccessToken string + DeviceAccessToken string + UserAccessToken string + RefreshToken string + UserAgent string + RedirectURI string + HTTPClient HTTPClient + RateLimitFunc RateLimitFunc + APIBaseURL string + ExtensionOpts ExtensionOptions } type ExtensionOptions struct { @@ -418,10 +419,9 @@ func (c *Client) doRequest(req *http.Request, resp *Response) error { } func (c *Client) canRefreshToken() bool { - return c.opts.ClientID != "" && - c.opts.ClientSecret != "" && - c.opts.UserAccessToken != "" && - c.opts.RefreshToken != "" + return ((c.opts.UserAccessToken != "" && c.opts.ClientSecret != "") || + c.opts.DeviceAccessToken != "") && + (c.opts.ClientID != "" && c.opts.RefreshToken != "") } func (c *Client) refreshToken() error { @@ -461,6 +461,9 @@ func (c *Client) setRequestHeaders(req *http.Request) { if opts.AppAccessToken != "" { bearerToken = opts.AppAccessToken } + if opts.DeviceAccessToken != "" { + bearerToken = opts.DeviceAccessToken + } if opts.UserAccessToken != "" { bearerToken = opts.UserAccessToken } @@ -496,6 +499,17 @@ func (c *Client) SetAppAccessToken(accessToken string) { c.opts.AppAccessToken = accessToken } +// GetDeviceAccessToken returns the current device access token. +func (c *Client) GetDeviceAccessToken() string { + return c.opts.DeviceAccessToken +} + +func (c *Client) SetDeviceAccessToken(accessToken string) { + c.mu.Lock() + defer c.mu.Unlock() + c.opts.DeviceAccessToken = accessToken +} + // GetUserAccessToken returns the current user access token. func (c *Client) GetUserAccessToken() string { return c.opts.UserAccessToken diff --git a/helix_test.go b/helix_test.go index 914a6cc..20b31f5 100644 --- a/helix_test.go +++ b/helix_test.go @@ -60,16 +60,17 @@ func TestNewClient(t *testing.T) { { false, &Options{ - ClientID: "my-client-id", - ClientSecret: "my-client-secret", - HTTPClient: &http.Client{}, - AppAccessToken: "my-app-access-token", - UserAccessToken: "my-user-access-token", - RefreshToken: "my-refresh-token", - UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36", - RateLimitFunc: func(*Response) error { return nil }, - RedirectURI: "http://localhost/auth/callback", - APIBaseURL: "http://localhost/proxy", + ClientID: "my-client-id", + ClientSecret: "my-client-secret", + HTTPClient: &http.Client{}, + AppAccessToken: "my-app-access-token", + DeviceAccessToken: "my-device-access-token", + UserAccessToken: "my-user-access-token", + RefreshToken: "my-refresh-token", + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36", + RateLimitFunc: func(*Response) error { return nil }, + RedirectURI: "http://localhost/auth/callback", + APIBaseURL: "http://localhost/proxy", }, }, } @@ -110,6 +111,10 @@ func TestNewClient(t *testing.T) { t.Errorf("expected accessToken to be \"%s\", got \"%s\"", testCase.options.AppAccessToken, opts.AppAccessToken) } + if opts.DeviceAccessToken != testCase.options.DeviceAccessToken { + t.Errorf("expected accessToken to be \"%s\", got \"%s\"", testCase.options.DeviceAccessToken, opts.DeviceAccessToken) + } + if opts.UserAccessToken != testCase.options.UserAccessToken { t.Errorf("expected accessToken to be \"%s\", got \"%s\"", testCase.options.UserAccessToken, opts.UserAccessToken) } @@ -152,6 +157,14 @@ func TestNewClientDefault(t *testing.T) { t.Errorf("expected userAgent to be \"%s\", got \"%s\"", "", opts.UserAgent) } + if opts.AppAccessToken != "" { + t.Errorf("expected accesstoken to be \"\", got \"%s\"", opts.AppAccessToken) + } + + if opts.DeviceAccessToken != "" { + t.Errorf("expected accesstoken to be \"\", got \"%s\"", opts.DeviceAccessToken) + } + if opts.UserAccessToken != "" { t.Errorf("expected accesstoken to be \"\", got \"%s\"", opts.UserAccessToken) } @@ -187,7 +200,6 @@ func TestNewClientHasContext(t *testing.T) { } _, err = c.GetStreams(nil) - if err != nil { t.Errorf("Did not expect error, got \"%s\"", err) } @@ -220,7 +232,6 @@ func TestNewClientWithContext(t *testing.T) { } _, err = c.GetStreams(nil) - if err != nil { t.Errorf("Did not expect error, got \"%s\"", err) } @@ -253,7 +264,6 @@ func TestNewClientWithContextCancel(t *testing.T) { cancel() _, err = c.GetStreams(nil) - if err != nil { t.Errorf("Did not expect error, got \"%s\"", err) } @@ -275,7 +285,6 @@ func TestNoContext(t *testing.T) { c := newMockClient(&Options{}, handlerFunc) _, err := c.GetStreams(nil) - if err != nil { t.Errorf("Did not expect error, got \"%s\"", err) } @@ -384,22 +393,25 @@ func TestSetRequestHeaders(t *testing.T) { t.Parallel() testCases := []struct { - endpoint string - method string - userBearerToken string - appBearerToken string + endpoint string + method string + userBearerToken string + deviceBearerToken string + appBearerToken string }{ - {"/users", "GET", "my-user-access-token", "my-app-access-token"}, - {"/entitlements/upload", "POST", "", "my-app-access-token"}, - {"/streams", "GET", "", ""}, + {"/users", "GET", "my-user-access-token", "my-device-access-token", "my-app-access-token"}, + {"/entitlements/upload", "POST", "", "", "my-app-access-token"}, + {"/streams", "GET", "", "", ""}, + {"/channels", "GET", "", "my-device-access-token", ""}, } for _, testCase := range testCases { client, err := NewClient(&Options{ - ClientID: "my-client-id", - UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36", - AppAccessToken: testCase.appBearerToken, - UserAccessToken: testCase.userBearerToken, + ClientID: "my-client-id", + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36", + AppAccessToken: testCase.appBearerToken, + DeviceAccessToken: testCase.deviceBearerToken, + UserAccessToken: testCase.userBearerToken, }) if err != nil { t.Errorf("Did not expect an error, got \"%s\"", err.Error()) @@ -422,7 +434,14 @@ func TestSetRequestHeaders(t *testing.T) { } } - if testCase.userBearerToken == "" && testCase.appBearerToken == "" { + if testCase.userBearerToken == "" && testCase.deviceBearerToken != "" { + expectedAuthHeader := "Bearer " + testCase.deviceBearerToken + if req.Header.Get("Authorization") != expectedAuthHeader { + t.Errorf("expected Authorization header to be \"%s\", got \"%s\"", expectedAuthHeader, req.Header.Get("Authorization")) + } + } + + if testCase.userBearerToken == "" && testCase.deviceBearerToken == "" && testCase.appBearerToken == "" { if req.Header.Get("Authorization") != "" { t.Error("did not expect Authorization header to be set") } @@ -592,6 +611,44 @@ func TestSetAppAccessToken(t *testing.T) { } } +func TestGetDeviceAccessToken(t *testing.T) { + t.Parallel() + + accessToken := "my-device-access-token" + + client, err := NewClient(&Options{ + ClientID: "cid", + }) + if err != nil { + t.Errorf("Did not expect an error, got \"%s\"", err.Error()) + } + + client.SetDeviceAccessToken(accessToken) + + if client.GetDeviceAccessToken() != accessToken { + t.Errorf("expected GetDeviceAccessToken to return \"%s\", got \"%s\"", accessToken, client.GetDeviceAccessToken()) + } +} + +func TestSetDeviceAccessToken(t *testing.T) { + t.Parallel() + + accessToken := "my-device-access-token" + + client, err := NewClient(&Options{ + ClientID: "cid", + }) + if err != nil { + t.Errorf("Did not expect an error, got \"%s\"", err.Error()) + } + + client.SetDeviceAccessToken(accessToken) + + if client.opts.DeviceAccessToken != accessToken { + t.Errorf("expected accessToken to be \"%s\", got \"%s\"", accessToken, client.opts.DeviceAccessToken) + } +} + func TestGetUserAccessToken(t *testing.T) { t.Parallel() From 1e398c3cc32c2b03a483f81d53ca83a36e20a73e Mon Sep 17 00:00:00 2001 From: Alexander <46440248+ealexandrohin@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:25:40 +0300 Subject: [PATCH 2/3] applied suggestions from copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- authentication_test.go | 2 +- docs/authentication_docs.md | 2 +- helix.go | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/authentication_test.go b/authentication_test.go index 3cd0aa0..a2d0c37 100644 --- a/authentication_test.go +++ b/authentication_test.go @@ -234,7 +234,7 @@ func TestRequestDeviceVerificationURI(t *testing.T) { } if resp.Data.VerificationURI == "" { - t.Errorf("expected a verificaion uri but got an empty string") + t.Errorf("expected a verification uri but got an empty string") } } diff --git a/docs/authentication_docs.md b/docs/authentication_docs.md index 5469ad3..0deccdf 100644 --- a/docs/authentication_docs.md +++ b/docs/authentication_docs.md @@ -200,7 +200,7 @@ if err != nil { // Get the refresh token from the client refreshToken := client.GetRefreshToken() -if canRefresh := client.canRefreshToken(); canRefresh { +if refreshToken != "" { resp, err := client.RefreshToken(refreshToken) if err != nil { // handle error diff --git a/helix.go b/helix.go index 26e8dd1..78de4ac 100644 --- a/helix.go +++ b/helix.go @@ -504,6 +504,7 @@ func (c *Client) GetDeviceAccessToken() string { return c.opts.DeviceAccessToken } +// SetDeviceAccessToken sets the current device access token. func (c *Client) SetDeviceAccessToken(accessToken string) { c.mu.Lock() defer c.mu.Unlock() From 49ead21b955fcf08601fdc9ee7f6ad5102e4a3cb Mon Sep 17 00:00:00 2001 From: Alexander <46440248+ealexandrohin@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:32:43 +0300 Subject: [PATCH 3/3] fixed typo in accessToken error messages --- helix_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/helix_test.go b/helix_test.go index 20b31f5..31c2da3 100644 --- a/helix_test.go +++ b/helix_test.go @@ -158,15 +158,15 @@ func TestNewClientDefault(t *testing.T) { } if opts.AppAccessToken != "" { - t.Errorf("expected accesstoken to be \"\", got \"%s\"", opts.AppAccessToken) + t.Errorf("expected accessToken to be \"\", got \"%s\"", opts.AppAccessToken) } if opts.DeviceAccessToken != "" { - t.Errorf("expected accesstoken to be \"\", got \"%s\"", opts.DeviceAccessToken) + t.Errorf("expected accessToken to be \"\", got \"%s\"", opts.DeviceAccessToken) } if opts.UserAccessToken != "" { - t.Errorf("expected accesstoken to be \"\", got \"%s\"", opts.UserAccessToken) + t.Errorf("expected accessToken to be \"\", got \"%s\"", opts.UserAccessToken) } if opts.HTTPClient != http.DefaultClient {