Skip to content

Commit dfa3146

Browse files
kangmingtayph1p
andauthored
Feat: Twitch Provider (#135)
* fix(test): replace Fragment with RawQuery to make it work as intended * feat(twitch): add twitch provider * refactor(twitch): order twitch alphabetically and add aliasKey * refactor: rename struct literal Co-authored-by: ph1p <me@ph1p.dev>
1 parent f51a7a8 commit dfa3146

8 files changed

Lines changed: 323 additions & 14 deletions

File tree

README.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ The default group to assign all new users to.
172172

173173
### External Authentication Providers
174174

175-
We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google` and `twitter` for external authentication.
175+
We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch` and `twitter` for external authentication.
176176
Use the names as the keys underneath `external` to configure each separately.
177177

178178
```properties
@@ -203,7 +203,7 @@ The URI a OAuth2 provider will redirect to with the `code` and `state` values.
203203

204204
The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` only. Defaults to `https://gitlab.com`.
205205

206-
#### Apple OAuth
206+
#### Apple OAuth
207207

208208
To try out external authentication with Apple locally, you will need to do the following:
209209
1. Remap localhost to \<my_custom_dns \> in your `/etc/hosts` config.
@@ -427,6 +427,7 @@ GoTrue exposes the following endpoints:
427427
"github": true,
428428
"gitlab": true,
429429
"google": true,
430+
"twitch": true,
430431
"twitter": true
431432
},
432433
"disable_signup": false,
@@ -544,7 +545,7 @@ GoTrue exposes the following endpoints:
544545

545546
Magic Link. Will deliver a link (e.g. `/verify?type=magiclink&token=fgtyuf68ddqdaDd`) to the user based on
546547
email address which they can use to redeem an access_token.
547-
548+
548549
By default Magic Links can only be sent once every 60 seconds
549550

550551
```json
@@ -558,14 +559,14 @@ GoTrue exposes the following endpoints:
558559
```json
559560
{}
560561
```
561-
562+
562563
when clicked the magic link will redirect the user to `<SITE_URL>#access_token=x&refresh_token=y&expires_in=z&token_type=bearer&type=magiclink` (see `/verify` above)
563564

564565
### **POST /recover**
565566

566567
Password recovery. Will deliver a password recovery mail to the user based on
567568
email address.
568-
569+
569570
By default recovery links can only be sent once every 60 seconds
570571

571572
```json
@@ -685,18 +686,17 @@ GoTrue exposes the following endpoints:
685686

686687
query params:
687688
```
688-
provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | twitter
689+
provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | twitch | twitter
689690
scopes=<optional additional scopes depending on the provider (email and name are requested by default)>
690691
```
691-
692+
692693
Redirects to provider and then to `/callback`
693-
694+
694695
For apple specific setup see: https://github.com/supabase/gotrue#apple-oauth
695-
696+
696697
### **GET /callback**
697698

698699
External provider should redirect to here
699-
700-
Redirects to `<GOTRUE_SITE_URL>#access_token=<access_token>&refresh_token=<refresh_token>&provider_token=<provider_oauth_token>&expires_in=3600&provider=<provider_name>`
701-
If additional scopes were requested then `provider_token` will be populated, you can use this to fetch additional data from the provider or interact with their services
702700

701+
Redirects to `<GOTRUE_SITE_URL>#access_token=<access_token>&refresh_token=<refresh_token>&provider_token=<provider_oauth_token>&expires_in=3600&provider=<provider_name>`
702+
If additional scopes were requested then `provider_token` will be populated, you can use this to fetch additional data from the provider or interact with their services

api/external.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
352352
return provider.NewGoogleProvider(config.External.Google, scopes)
353353
case "facebook":
354354
return provider.NewFacebookProvider(config.External.Facebook, scopes)
355+
case "twitch":
356+
return provider.NewTwitchProvider(config.External.Twitch, scopes)
355357
case "twitter":
356358
return provider.NewTwitterProvider(config.External.Twitter, scopes)
357359
case "saml":

api/external_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func performAuthorization(ts *ExternalTestSuite, provider string, code string, i
100100

101101
func assertAuthorizationSuccess(ts *ExternalTestSuite, u *url.URL, tokenCount int, userCount int, email string, name string, avatar string) {
102102
// ensure redirect has #access_token=...
103-
v, err := url.ParseQuery(u.Fragment)
103+
v, err := url.ParseQuery(u.RawQuery)
104104
ts.Require().NoError(err)
105105
ts.Require().Empty(v.Get("error_description"))
106106
ts.Require().Empty(v.Get("error"))
@@ -122,7 +122,7 @@ func assertAuthorizationSuccess(ts *ExternalTestSuite, u *url.URL, tokenCount in
122122

123123
func assertAuthorizationFailure(ts *ExternalTestSuite, u *url.URL, errorDescription string, errorType string, email string) {
124124
// ensure new sign ups error
125-
v, err := url.ParseQuery(u.Fragment)
125+
v, err := url.ParseQuery(u.RawQuery)
126126
ts.Require().NoError(err)
127127
ts.Require().Equal(errorDescription, v.Get("error_description"))
128128
ts.Require().Equal(errorType, v.Get("error"))

api/external_twitch_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
9+
jwt "github.com/dgrijalva/jwt-go"
10+
)
11+
12+
func (ts *ExternalTestSuite) TestSignupExternalTwitch() {
13+
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=twitch", nil)
14+
w := httptest.NewRecorder()
15+
ts.API.handler.ServeHTTP(w, req)
16+
ts.Require().Equal(http.StatusFound, w.Code)
17+
u, err := url.Parse(w.Header().Get("Location"))
18+
ts.Require().NoError(err, "redirect url parse failed")
19+
q := u.Query()
20+
ts.Equal(ts.Config.External.Twitch.RedirectURI, q.Get("redirect_uri"))
21+
ts.Equal(ts.Config.External.Twitch.ClientID, q.Get("client_id"))
22+
ts.Equal("code", q.Get("response_type"))
23+
ts.Equal("user:read:email", q.Get("scope"))
24+
25+
claims := ExternalProviderClaims{}
26+
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
27+
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
28+
return []byte(ts.API.config.OperatorToken), nil
29+
})
30+
ts.Require().NoError(err)
31+
32+
ts.Equal("twitch", claims.Provider)
33+
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
34+
}
35+
36+
func TwitchTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
37+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
switch r.URL.Path {
39+
case "/oauth2/token":
40+
*tokenCount++
41+
ts.Equal(code, r.FormValue("code"))
42+
ts.Equal("authorization_code", r.FormValue("grant_type"))
43+
ts.Equal(ts.Config.External.Twitch.RedirectURI, r.FormValue("redirect_uri"))
44+
45+
w.Header().Add("Content-Type", "application/json")
46+
fmt.Fprint(w, `{"access_token":"Twitch_token","expires_in":100000}`)
47+
case "/helix/users":
48+
*userCount++
49+
w.Header().Add("Content-Type", "application/json")
50+
fmt.Fprint(w, user)
51+
default:
52+
w.WriteHeader(500)
53+
ts.Fail("unknown Twitch oauth call %s", r.URL.Path)
54+
}
55+
}))
56+
57+
ts.Config.External.Twitch.URL = server.URL
58+
59+
return server
60+
}
61+
62+
func (ts *ExternalTestSuite) TestSignupExternalTwitch_AuthorizationCode() {
63+
ts.Config.DisableSignup = false
64+
tokenCount, userCount := 0, 0
65+
code := "authcode"
66+
TwitchUser := `{"data":[{"id":"1","login":"Twitch Test","display_name":"Twitch Test","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
67+
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
68+
defer server.Close()
69+
70+
u := performAuthorization(ts, "twitch", code, "")
71+
72+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch Test", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
73+
}
74+
75+
func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupErrorWhenNoUser() {
76+
ts.Config.DisableSignup = true
77+
78+
tokenCount, userCount := 0, 0
79+
code := "authcode"
80+
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
81+
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
82+
defer server.Close()
83+
84+
u := performAuthorization(ts, "twitch", code, "")
85+
86+
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "twitch@example.com")
87+
}
88+
89+
func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupErrorWhenEmptyEmail() {
90+
ts.Config.DisableSignup = true
91+
92+
tokenCount, userCount := 0, 0
93+
code := "authcode"
94+
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":""}]}`
95+
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
96+
defer server.Close()
97+
98+
99+
u := performAuthorization(ts, "twitch", code, "")
100+
101+
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "twitch@example.com")
102+
}
103+
104+
func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupSuccessWithPrimaryEmail() {
105+
ts.Config.DisableSignup = true
106+
107+
ts.createUser("twitch@example.com", "Twitch Test", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8", "")
108+
109+
tokenCount, userCount := 0, 0
110+
code := "authcode"
111+
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
112+
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
113+
defer server.Close()
114+
115+
u := performAuthorization(ts, "twitch", code, "")
116+
117+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch Test", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
118+
}
119+
120+
func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchSuccessWhenMatchingToken() {
121+
// name and avatar should be populated from Twitch API
122+
ts.createUser("twitch@example.com", "", "", "invite_token")
123+
124+
tokenCount, userCount := 0, 0
125+
code := "authcode"
126+
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
127+
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
128+
defer server.Close()
129+
130+
u := performAuthorization(ts, "twitch", code, "invite_token")
131+
132+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch Test", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
133+
}
134+
135+
func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenNoMatchingToken() {
136+
tokenCount, userCount := 0, 0
137+
code := "authcode"
138+
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
139+
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
140+
defer server.Close()
141+
142+
w := performAuthorizationRequest(ts, "twitch", "invite_token")
143+
ts.Require().Equal(http.StatusNotFound, w.Code)
144+
}
145+
146+
func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenWrongToken() {
147+
ts.createUser("twitch@example.com", "", "", "invite_token")
148+
149+
tokenCount, userCount := 0, 0
150+
code := "authcode"
151+
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
152+
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
153+
defer server.Close()
154+
155+
w := performAuthorizationRequest(ts, "twitch", "wrong_token")
156+
ts.Require().Equal(http.StatusNotFound, w.Code)
157+
}
158+
159+
func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenEmailDoesntMatch() {
160+
ts.createUser("twitch@example.com", "", "", "invite_token")
161+
162+
tokenCount, userCount := 0, 0
163+
code := "authcode"
164+
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"other@example.com"}]}`
165+
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
166+
defer server.Close()
167+
168+
u := performAuthorization(ts, "twitch", code, "invite_token")
169+
170+
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
171+
}

0 commit comments

Comments
 (0)