Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SUPPORTED_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
)

var authPaths = map[string]string{
"device": "/device",
"token": "/token",
"revoke": "/revoke",
"validate": "/validate",
Expand Down Expand Up @@ -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
Expand Down
226 changes: 226 additions & 0 deletions authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 verification 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()

Expand Down
Loading
Loading