Skip to content

Commit fca8ea4

Browse files
hfymiedviediev
andauthored
feat: add snapchat provider (#2071)
Adds support for the Snapchat OAuth provider. This is not going to be configurable for generic Supabase projects at this time. Like #1998 but with some fixes. --------- Co-authored-by: Yurii <[email protected]>
1 parent b244598 commit fca8ea4

File tree

10 files changed

+278
-2
lines changed

10 files changed

+278
-2
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ To see the current settings, make a request to `http://localhost:9999/settings`
227227
"gitlab": false,
228228
"google": false,
229229
"facebook": false,
230+
"snapchat": false,
230231
"spotify": false,
231232
"slack": false,
232233
"slack_oidc": false,

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ The default group to assign all new users to.
430430

431431
### External Authentication Providers
432432

433-
We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication.
433+
We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `snapchat`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication.
434434

435435
Use the names as the keys underneath `external` to configure each separately.
436436

@@ -746,6 +746,7 @@ Returns the publicly available settings for this auth instance.
746746
"linkedin": true,
747747
"notion": true,
748748
"slack": true,
749+
"snapchat": true,
749750
"spotify": true,
750751
"twitch": true,
751752
"twitter": true,
@@ -1212,7 +1213,7 @@ Get access_token from external oauth provider
12121213
query params:
12131214

12141215
```
1215-
provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos
1216+
provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | notion | slack | snapchat | spotify | twitch | twitter | workos
12161217
12171218
scopes=<optional additional scopes depending on the provider (email and name are requested by default)>
12181219
```

hack/test.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ GOTRUE_EXTERNAL_NOTION_ENABLED=true
7676
GOTRUE_EXTERNAL_NOTION_CLIENT_ID=testclientid
7777
GOTRUE_EXTERNAL_NOTION_SECRET=testsecret
7878
GOTRUE_EXTERNAL_NOTION_REDIRECT_URI=https://identity.services.netlify.com/callback
79+
GOTRUE_EXTERNAL_SNAPCHAT_ENABLED=true
80+
GOTRUE_EXTERNAL_SNAPCHAT_CLIENT_ID=testclientid
81+
GOTRUE_EXTERNAL_SNAPCHAT_SECRET=testsecret
82+
GOTRUE_EXTERNAL_SNAPCHAT_REDIRECT_URI=https://identity.services.netlify.com/callback
7983
GOTRUE_EXTERNAL_SPOTIFY_ENABLED=true
8084
GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID=testclientid
8185
GOTRUE_EXTERNAL_SPOTIFY_SECRET=testsecret

internal/api/external.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
584584
return provider.NewLinkedinOIDCProvider(config.External.LinkedinOIDC, scopes)
585585
case "notion":
586586
return provider.NewNotionProvider(config.External.Notion)
587+
case "snapchat":
588+
return provider.NewSnapchatProvider(config.External.Snapchat, scopes)
587589
case "spotify":
588590
return provider.NewSpotifyProvider(config.External.Spotify, scopes)
589591
case "slack":
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
9+
jwt "github.com/golang-jwt/jwt/v5"
10+
)
11+
12+
const (
13+
snapchatUser = `{"data":{"me":{"externalId":"snapchatTestId","displayName":"Snapchat Test","bitmoji":{"avatar":"http://example.com/bitmoji"}}}}`
14+
)
15+
16+
func (ts *ExternalTestSuite) TestSignupExternalSnapchat() {
17+
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=snapchat", nil)
18+
w := httptest.NewRecorder()
19+
ts.API.handler.ServeHTTP(w, req)
20+
ts.Require().Equal(http.StatusFound, w.Code)
21+
u, err := url.Parse(w.Header().Get("Location"))
22+
ts.Require().NoError(err, "redirect url parse failed")
23+
q := u.Query()
24+
ts.Equal(ts.Config.External.Snapchat.RedirectURI, q.Get("redirect_uri"))
25+
ts.Equal(ts.Config.External.Snapchat.ClientID, []string{q.Get("client_id")})
26+
ts.Equal("code", q.Get("response_type"))
27+
ts.Equal("https://auth.snapchat.com/oauth2/api/user.external_id https://auth.snapchat.com/oauth2/api/user.display_name https://auth.snapchat.com/oauth2/api/user.bitmoji.avatar", q.Get("scope"))
28+
29+
claims := ExternalProviderClaims{}
30+
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
31+
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
32+
return []byte(ts.Config.JWT.Secret), nil
33+
})
34+
ts.Require().NoError(err)
35+
36+
ts.Equal("snapchat", claims.Provider)
37+
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
38+
}
39+
40+
func SnapchatTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
41+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+
switch r.URL.Path {
43+
case "/accounts/oauth2/token":
44+
*tokenCount++
45+
ts.Equal(code, r.FormValue("code"))
46+
ts.Equal("authorization_code", r.FormValue("grant_type"))
47+
ts.Equal(ts.Config.External.Snapchat.RedirectURI, r.FormValue("redirect_uri"))
48+
49+
w.Header().Add("Content-Type", "application/json")
50+
fmt.Fprint(w, `{"access_token":"snapchat_token","expires_in":3600}`)
51+
case "/v1/me":
52+
*userCount++
53+
w.Header().Add("Content-Type", "application/json")
54+
fmt.Fprint(w, user)
55+
default:
56+
w.WriteHeader(500)
57+
ts.Fail("unknown snapchat oauth call %s", r.URL.Path)
58+
}
59+
}))
60+
61+
ts.Config.External.Snapchat.URL = server.URL
62+
63+
return server
64+
}
65+
66+
func (ts *ExternalTestSuite) TestSignupExternalSnapchat_AuthorizationCode() {
67+
ts.Config.DisableSignup = false
68+
tokenCount, userCount := 0, 0
69+
code := "authcode"
70+
server := SnapchatTestSignupSetup(ts, &tokenCount, &userCount, code, snapchatUser)
71+
defer server.Close()
72+
73+
u := performAuthorization(ts, "snapchat", code, "")
74+
75+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Snapchat Test", "snapchatTestId", "http://example.com/bitmoji")
76+
}
77+
78+
func (ts *ExternalTestSuite) TestSignupExternalSnapchatDisableSignupErrorWhenNoUser() {
79+
ts.Config.DisableSignup = true
80+
81+
tokenCount, userCount := 0, 0
82+
code := "authcode"
83+
server := SnapchatTestSignupSetup(ts, &tokenCount, &userCount, code, snapchatUser)
84+
defer server.Close()
85+
86+
u := performAuthorization(ts, "snapchat", code, "")
87+
88+
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "")
89+
}
90+
91+
func (ts *ExternalTestSuite) TestSignupExternalSnapchatDisableSignupSuccessWithExistingUser() {
92+
ts.Config.DisableSignup = true
93+
94+
ts.createUser("snapchatTestId", "[email protected]", "Snapchat Test", "http://example.com/bitmoji", "")
95+
96+
tokenCount, userCount := 0, 0
97+
code := "authcode"
98+
server := SnapchatTestSignupSetup(ts, &tokenCount, &userCount, code, snapchatUser)
99+
defer server.Close()
100+
101+
u := performAuthorization(ts, "snapchat", code, "")
102+
103+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Snapchat Test", "snapchatTestId", "http://example.com/bitmoji")
104+
}
105+
106+
func (ts *ExternalTestSuite) TestInviteTokenExternalSnapchatSuccessWhenMatchingToken() {
107+
// name and avatar should be populated from Snapchat API
108+
// Use the same email that the provider will generate - converted to lowercase
109+
ts.createUser("snapchatTestId", "[email protected]", "", "", "invite_token")
110+
111+
tokenCount, userCount := 0, 0
112+
code := "authcode"
113+
server := SnapchatTestSignupSetup(ts, &tokenCount, &userCount, code, snapchatUser)
114+
defer server.Close()
115+
116+
u := performAuthorization(ts, "snapchat", code, "invite_token")
117+
118+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Snapchat Test", "snapchatTestId", "http://example.com/bitmoji")
119+
}
120+
121+
func (ts *ExternalTestSuite) TestInviteTokenExternalSnapchatErrorWhenNoMatchingToken() {
122+
tokenCount, userCount := 0, 0
123+
code := "authcode"
124+
server := SnapchatTestSignupSetup(ts, &tokenCount, &userCount, code, snapchatUser)
125+
defer server.Close()
126+
127+
w := performAuthorizationRequest(ts, "snapchat", "invite_token")
128+
ts.Require().Equal(http.StatusNotFound, w.Code)
129+
}
130+
131+
func (ts *ExternalTestSuite) TestInviteTokenExternalSnapchatErrorWhenWrongToken() {
132+
ts.createUser("snapchatTestId", "", "", "", "invite_token")
133+
134+
tokenCount, userCount := 0, 0
135+
code := "authcode"
136+
server := SnapchatTestSignupSetup(ts, &tokenCount, &userCount, code, snapchatUser)
137+
defer server.Close()
138+
139+
w := performAuthorizationRequest(ts, "snapchat", "wrong_token")
140+
ts.Require().Equal(http.StatusNotFound, w.Code)
141+
}

internal/api/provider/snapchat.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"net/url"
6+
"strings"
7+
8+
"github.com/supabase/auth/internal/conf"
9+
"golang.org/x/oauth2"
10+
)
11+
12+
const IssuerSnapchat = "https://accounts.snapchat.com"
13+
14+
const (
15+
defaultSnapchatAuthBase = "accounts.snapchat.com"
16+
defaultSnapchatTokenBase = "accounts.snapchat.com"
17+
defaultSnapchatAPIBase = "kit.snapchat.com"
18+
)
19+
20+
type snapchatProvider struct {
21+
*oauth2.Config
22+
ProfileURL string
23+
}
24+
25+
type snapchatUser struct {
26+
Data struct {
27+
Me struct {
28+
ExternalID string `json:"externalId"`
29+
DisplayName string `json:"displayName"`
30+
Bitmoji struct {
31+
Avatar string `json:"avatar"`
32+
} `json:"bitmoji"`
33+
} `json:"me"`
34+
} `json:"data"`
35+
}
36+
37+
// NewSnapchatProvider creates a Snapchat account provider.
38+
func NewSnapchatProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
39+
if err := ext.ValidateOAuth(); err != nil {
40+
return nil, err
41+
}
42+
43+
authHost := chooseHost(ext.URL, defaultSnapchatAuthBase)
44+
tokenHost := chooseHost(ext.URL, defaultSnapchatTokenBase)
45+
profileURL := chooseHost(ext.URL, defaultSnapchatAPIBase) + "/v1/me"
46+
47+
oauthScopes := []string{
48+
"https://auth.snapchat.com/oauth2/api/user.external_id",
49+
"https://auth.snapchat.com/oauth2/api/user.display_name",
50+
"https://auth.snapchat.com/oauth2/api/user.bitmoji.avatar",
51+
}
52+
53+
if scopes != "" {
54+
oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
55+
}
56+
57+
return &snapchatProvider{
58+
Config: &oauth2.Config{
59+
ClientID: ext.ClientID[0],
60+
ClientSecret: ext.Secret,
61+
RedirectURL: ext.RedirectURI,
62+
Endpoint: oauth2.Endpoint{
63+
AuthURL: authHost + "/accounts/oauth2/auth",
64+
TokenURL: tokenHost + "/accounts/oauth2/token",
65+
},
66+
Scopes: oauthScopes,
67+
},
68+
ProfileURL: profileURL,
69+
}, nil
70+
}
71+
72+
func (p snapchatProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
73+
return p.Exchange(context.Background(), code)
74+
}
75+
76+
func (p snapchatProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
77+
var u snapchatUser
78+
79+
// Create a URL with the GraphQL query parameter
80+
baseURL, err := url.Parse(p.ProfileURL)
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
// Add the GraphQL query parameter
86+
query := url.Values{}
87+
query.Add("query", "{me { externalId displayName bitmoji { avatar id } } }")
88+
baseURL.RawQuery = query.Encode()
89+
90+
if err := makeRequest(ctx, tok, p.Config, baseURL.String(), &u); err != nil {
91+
return nil, err
92+
}
93+
94+
data := &UserProvidedData{}
95+
96+
// Snapchat doesn't provide email by default, additional scopes needed
97+
data.Emails = []Email{{
98+
Email: strings.ToLower(u.Data.Me.ExternalID) + "@snapchat.id", // TODO: Create a pseudo-email using the external ID
99+
Verified: true,
100+
Primary: true,
101+
}}
102+
103+
data.Metadata = &Claims{
104+
Issuer: IssuerSnapchat,
105+
Subject: u.Data.Me.ExternalID,
106+
Name: u.Data.Me.DisplayName,
107+
Picture: u.Data.Me.Bitmoji.Avatar,
108+
109+
// To be deprecated
110+
Slug: u.Data.Me.DisplayName,
111+
AvatarURL: u.Data.Me.Bitmoji.Avatar,
112+
FullName: u.Data.Me.DisplayName,
113+
ProviderId: u.Data.Me.ExternalID,
114+
}
115+
116+
return data, nil
117+
}

internal/api/settings.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type ProviderSettings struct {
99
Bitbucket bool `json:"bitbucket"`
1010
Discord bool `json:"discord"`
1111
Facebook bool `json:"facebook"`
12+
Snapchat bool `json:"snapchat"`
1213
Figma bool `json:"figma"`
1314
Fly bool `json:"fly"`
1415
GitHub bool `json:"github"`
@@ -50,6 +51,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
5051
Bitbucket: config.External.Bitbucket.Enabled,
5152
Discord: config.External.Discord.Enabled,
5253
Facebook: config.External.Facebook.Enabled,
54+
Snapchat: config.External.Snapchat.Enabled,
5355
Figma: config.External.Figma.Enabled,
5456
Fly: config.External.Fly.Enabled,
5557
GitHub: config.External.Github.Enabled,

internal/api/settings_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
3232
require.True(t, p.Bitbucket)
3333
require.True(t, p.Discord)
3434
require.True(t, p.Facebook)
35+
require.True(t, p.Snapchat)
3536
require.True(t, p.Notion)
3637
require.True(t, p.Spotify)
3738
require.True(t, p.Slack)

internal/conf/configuration.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ type ProviderConfiguration struct {
321321
Bitbucket OAuthProviderConfiguration `json:"bitbucket"`
322322
Discord OAuthProviderConfiguration `json:"discord"`
323323
Facebook OAuthProviderConfiguration `json:"facebook"`
324+
Snapchat OAuthProviderConfiguration `json:"snapchat"`
324325
Figma OAuthProviderConfiguration `json:"figma"`
325326
Fly OAuthProviderConfiguration `json:"fly"`
326327
Github OAuthProviderConfiguration `json:"github"`

internal/reloader/testdata/50_example.env

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID=""
8484
GOTRUE_EXTERNAL_FACEBOOK_SECRET=""
8585
GOTRUE_EXTERNAL_FACEBOOK_REDIRECT_URI="https://localhost:9999/callback"
8686

87+
# Snapchat OAuth config
88+
GOTRUE_EXTERNAL_SNAPCHAT_ENABLED="false"
89+
GOTRUE_EXTERNAL_SNAPCHAT_CLIENT_ID=""
90+
GOTRUE_EXTERNAL_SNAPCHAT_SECRET=""
91+
GOTRUE_EXTERNAL_SNAPCHAT_REDIRECT_URI="https://localhost:9999/callback"
92+
8793
# Figma OAuth config
8894
GOTRUE_EXTERNAL_FIGMA_ENABLED="false"
8995
GOTRUE_EXTERNAL_FIGMA_CLIENT_ID=""

0 commit comments

Comments
 (0)