Skip to content

Commit 9a61dae

Browse files
authored
feat: implements email-less accounts with oauth (#2105)
If you set `GOTRUE_EXTERNAL_<PROVIDER>_EMAIL_OPTIONAL=true` then OAuth, OIDC sign-in with that provider will no longer enforce that an `email` address is present. Adds some fixes to the Snapchat provider per internal discussions.
1 parent 8fae015 commit 9a61dae

File tree

14 files changed

+231
-152
lines changed

14 files changed

+231
-152
lines changed

hack/test.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ GOTRUE_EXTERNAL_SNAPCHAT_ENABLED=true
8080
GOTRUE_EXTERNAL_SNAPCHAT_CLIENT_ID=testclientid
8181
GOTRUE_EXTERNAL_SNAPCHAT_SECRET=testsecret
8282
GOTRUE_EXTERNAL_SNAPCHAT_REDIRECT_URI=https://identity.services.netlify.com/callback
83+
GOTRUE_EXTERNAL_SNAPCHAT_EMAIL_OPTIONAL=true
8384
GOTRUE_EXTERNAL_SPOTIFY_ENABLED=true
8485
GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID=testclientid
8586
GOTRUE_EXTERNAL_SPOTIFY_SECRET=testsecret

internal/api/apierrors/errorcode.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,11 @@ const (
9090
ErrorCodeMFAWebAuthnVerifyDisabled ErrorCode = "mfa_webauthn_verify_not_enabled"
9191
ErrorCodeMFAVerifiedFactorExists ErrorCode = "mfa_verified_factor_exists"
9292
//#nosec G101 -- Not a secret value.
93-
ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials"
94-
ErrorCodeEmailAddressNotAuthorized ErrorCode = "email_address_not_authorized"
95-
ErrorCodeEmailAddressInvalid ErrorCode = "email_address_invalid"
96-
ErrorCodeWeb3ProviderDisabled ErrorCode = "web3_provider_disabled"
97-
ErrorCodeWeb3UnsupportedChain ErrorCode = "web3_unsupported_chain"
98-
93+
ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials"
94+
ErrorCodeEmailAddressNotAuthorized ErrorCode = "email_address_not_authorized"
95+
ErrorCodeEmailAddressInvalid ErrorCode = "email_address_invalid"
96+
ErrorCodeWeb3ProviderDisabled ErrorCode = "web3_provider_disabled"
97+
ErrorCodeWeb3UnsupportedChain ErrorCode = "web3_unsupported_chain"
9998
ErrorCodeOAuthDynamicClientRegistrationDisabled ErrorCode = "oauth_dynamic_client_registration_disabled"
99+
ErrorCodeEmailAddressNotProvided ErrorCode = "email_address_not_provided"
100100
)

internal/api/context.go

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,24 @@ func (c contextKey) String() string {
1515
}
1616

1717
const (
18-
tokenKey = contextKey("jwt")
19-
inviteTokenKey = contextKey("invite_token")
20-
signatureKey = contextKey("signature")
21-
externalProviderTypeKey = contextKey("external_provider_type")
22-
userKey = contextKey("user")
23-
targetUserKey = contextKey("target_user")
24-
factorKey = contextKey("factor")
25-
sessionKey = contextKey("session")
26-
externalReferrerKey = contextKey("external_referrer")
27-
functionHooksKey = contextKey("function_hooks")
28-
adminUserKey = contextKey("admin_user")
29-
oauthTokenKey = contextKey("oauth_token") // for OAuth1.0, also known as request token
30-
oauthVerifierKey = contextKey("oauth_verifier")
31-
ssoProviderKey = contextKey("sso_provider")
32-
externalHostKey = contextKey("external_host")
33-
flowStateKey = contextKey("flow_state_id")
18+
externalProviderTypeKey = contextKey("external_provider_type")
19+
externalProviderEmailOptionalKey = contextKey("external_provider_allow_no_email")
20+
21+
tokenKey = contextKey("jwt")
22+
inviteTokenKey = contextKey("invite_token")
23+
signatureKey = contextKey("signature")
24+
userKey = contextKey("user")
25+
targetUserKey = contextKey("target_user")
26+
factorKey = contextKey("factor")
27+
sessionKey = contextKey("session")
28+
externalReferrerKey = contextKey("external_referrer")
29+
functionHooksKey = contextKey("function_hooks")
30+
adminUserKey = contextKey("admin_user")
31+
oauthTokenKey = contextKey("oauth_token") // for OAuth1.0, also known as request token
32+
oauthVerifierKey = contextKey("oauth_verifier")
33+
ssoProviderKey = contextKey("sso_provider")
34+
externalHostKey = contextKey("external_host")
35+
flowStateKey = contextKey("flow_state_id")
3436
)
3537

3638
// withToken adds the JWT token to the context.
@@ -152,18 +154,26 @@ func getInviteToken(ctx context.Context) string {
152154
}
153155

154156
// withExternalProviderType adds the provided request ID to the context.
155-
func withExternalProviderType(ctx context.Context, id string) context.Context {
156-
return context.WithValue(ctx, externalProviderTypeKey, id)
157+
func withExternalProviderType(ctx context.Context, id string, emailOptional bool) context.Context {
158+
return context.WithValue(context.WithValue(ctx, externalProviderTypeKey, id), externalProviderEmailOptionalKey, emailOptional)
157159
}
158160

159-
// getExternalProviderType reads the request ID from the context.
160-
func getExternalProviderType(ctx context.Context) string {
161-
obj := ctx.Value(externalProviderTypeKey)
162-
if obj == nil {
163-
return ""
161+
// getExternalProviderType returns the provider type and whether user data without email address should be allowed.
162+
func getExternalProviderType(ctx context.Context) (string, bool) {
163+
idValue := ctx.Value(externalProviderTypeKey)
164+
emailOptionalValue := ctx.Value(externalProviderEmailOptionalKey)
165+
166+
id, okID := idValue.(string)
167+
if !okID {
168+
return "", false
164169
}
165170

166-
return obj.(string)
171+
emailOptional, okEmailOptional := emailOptionalValue.(bool)
172+
if !okEmailOptional {
173+
return "", false
174+
}
175+
176+
return id, emailOptional
167177
}
168178

169179
func withExternalReferrer(ctx context.Context, token string) context.Context {

internal/api/external.go

Lines changed: 74 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type ExternalProviderClaims struct {
3131
Referrer string `json:"referrer,omitempty"`
3232
FlowStateID string `json:"flow_state_id"`
3333
LinkingTargetID string `json:"linking_target_id,omitempty"`
34+
EmailOptional bool `json:"email_optional,omitempty"`
3435
}
3536

3637
// ExternalProviderRedirect redirects the request to the oauth provider
@@ -55,7 +56,7 @@ func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Requ
5556
codeChallenge := query.Get("code_challenge")
5657
codeChallengeMethod := query.Get("code_challenge_method")
5758

58-
p, err := a.Provider(ctx, providerType, scopes)
59+
p, pConfig, err := a.Provider(ctx, providerType, scopes)
5960
if err != nil {
6061
return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Unsupported provider: %+v", err).WithInternalError(err)
6162
}
@@ -96,10 +97,11 @@ func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Requ
9697
SiteURL: config.SiteURL,
9798
InstanceID: uuid.Nil.String(),
9899
},
99-
Provider: providerType,
100-
InviteToken: inviteToken,
101-
Referrer: redirectURL,
102-
FlowStateID: flowStateID,
100+
Provider: providerType,
101+
InviteToken: inviteToken,
102+
Referrer: redirectURL,
103+
FlowStateID: flowStateID,
104+
EmailOptional: pConfig.EmailOptional,
103105
}
104106

105107
if linkingTargetUser != nil {
@@ -144,7 +146,7 @@ func (a *API) ExternalProviderCallback(w http.ResponseWriter, r *http.Request) e
144146

145147
func (a *API) handleOAuthCallback(r *http.Request) (*OAuthProviderData, error) {
146148
ctx := r.Context()
147-
providerType := getExternalProviderType(ctx)
149+
providerType, _ := getExternalProviderType(ctx)
148150

149151
var oAuthResponseData *OAuthProviderData
150152
var err error
@@ -168,16 +170,18 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
168170
var grantParams models.GrantParams
169171
grantParams.FillGrantParams(r)
170172

171-
providerType := getExternalProviderType(ctx)
173+
providerType, emailOptional := getExternalProviderType(ctx)
172174
data, err := a.handleOAuthCallback(r)
173175
if err != nil {
174176
return err
175177
}
176178

177179
userData := data.userData
178-
if len(userData.Emails) <= 0 {
180+
181+
if len(userData.Emails) == 0 && !emailOptional {
179182
return apierrors.NewInternalServerError("Error getting user email from external provider")
180183
}
184+
181185
userData.Metadata.EmailVerified = false
182186
for _, email := range userData.Emails {
183187
if email.Primary {
@@ -226,7 +230,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
226230
return terr
227231
}
228232
} else {
229-
if user, terr = a.createAccountFromExternalIdentity(tx, r, userData, providerType); terr != nil {
233+
if user, terr = a.createAccountFromExternalIdentity(tx, r, userData, providerType, emailOptional); terr != nil {
230234
return terr
231235
}
232236
}
@@ -285,7 +289,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
285289
return nil
286290
}
287291

288-
func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.Request, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
292+
func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.Request, userData *provider.UserProvidedData, providerType string, emailOptional bool) (*models.User, error) {
289293
ctx := r.Context()
290294
aud := a.requestAud(ctx, r)
291295
config := a.config
@@ -378,8 +382,7 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.
378382
return nil, apierrors.NewForbiddenError(apierrors.ErrorCodeUserBanned, "User is banned")
379383
}
380384

381-
// TODO(hf): Expand this boolean with all providers that may not have emails (like X/Twitter, Discord).
382-
hasEmails := providerType != "web3" // intentionally not using len(userData.Emails) != 0 for better backward compatibility control
385+
hasEmails := providerType != "web3" && !(emailOptional && decision.CandidateEmail.Email == "")
383386

384387
if hasEmails && !user.IsConfirmed() {
385388
// The user may have other unconfirmed email + password
@@ -400,21 +403,19 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.
400403
return nil, apierrors.NewInternalServerError("Error updating user").WithInternalError(terr)
401404
}
402405
} else {
403-
// Some providers, like web3 don't have email data.
404-
// Treat these as if a confirmation email has been
405-
// sent, although the user will be created without an
406-
// email address.
407406
emailConfirmationSent := false
408407
if decision.CandidateEmail.Email != "" {
409408
if terr = a.sendConfirmation(r, tx, user, models.ImplicitFlow); terr != nil {
410409
return nil, terr
411410
}
412411
emailConfirmationSent = true
413412
}
413+
414414
if !config.Mailer.AllowUnverifiedEmailSignIns {
415415
if emailConfirmationSent {
416416
return nil, storage.NewCommitWithError(apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeProviderEmailNeedsVerification, fmt.Sprintf("Unverified email with %v. A confirmation email has been sent to your %v email", providerType, providerType)))
417417
}
418+
418419
return nil, storage.NewCommitWithError(apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeProviderEmailNeedsVerification, fmt.Sprintf("Unverified email with %v. Verify the email with %v in order to sign in", providerType, providerType)))
419420
}
420421
}
@@ -564,67 +565,97 @@ func (a *API) loadExternalState(ctx context.Context, r *http.Request) (context.C
564565
}
565566
ctx = withTargetUser(ctx, u)
566567
}
567-
ctx = withExternalProviderType(ctx, claims.Provider)
568+
ctx = withExternalProviderType(ctx, claims.Provider, claims.EmailOptional)
568569
return withSignature(ctx, state), nil
569570
}
570571

571572
// Provider returns a Provider interface for the given name.
572-
func (a *API) Provider(ctx context.Context, name string, scopes string) (provider.Provider, error) {
573+
func (a *API) Provider(ctx context.Context, name string, scopes string) (provider.Provider, conf.OAuthProviderConfiguration, error) {
573574
config := a.config
574575
name = strings.ToLower(name)
575576

577+
var err error
578+
var p provider.Provider
579+
var pConfig conf.OAuthProviderConfiguration
580+
576581
switch name {
577582
case "apple":
578-
return provider.NewAppleProvider(ctx, config.External.Apple)
583+
pConfig = config.External.Apple
584+
p, err = provider.NewAppleProvider(ctx, pConfig)
579585
case "azure":
580-
return provider.NewAzureProvider(config.External.Azure, scopes)
586+
pConfig = config.External.Azure
587+
p, err = provider.NewAzureProvider(pConfig, scopes)
581588
case "bitbucket":
582-
return provider.NewBitbucketProvider(config.External.Bitbucket)
589+
pConfig = config.External.Bitbucket
590+
p, err = provider.NewBitbucketProvider(pConfig)
583591
case "discord":
584-
return provider.NewDiscordProvider(config.External.Discord, scopes)
592+
pConfig = config.External.Discord
593+
p, err = provider.NewDiscordProvider(pConfig, scopes)
585594
case "facebook":
586-
return provider.NewFacebookProvider(config.External.Facebook, scopes)
595+
pConfig = config.External.Facebook
596+
p, err = provider.NewFacebookProvider(pConfig, scopes)
587597
case "figma":
588-
return provider.NewFigmaProvider(config.External.Figma, scopes)
598+
pConfig = config.External.Figma
599+
p, err = provider.NewFigmaProvider(pConfig, scopes)
589600
case "fly":
590-
return provider.NewFlyProvider(config.External.Fly, scopes)
601+
pConfig = config.External.Fly
602+
p, err = provider.NewFlyProvider(pConfig, scopes)
591603
case "github":
592-
return provider.NewGithubProvider(config.External.Github, scopes)
604+
pConfig = config.External.Github
605+
p, err = provider.NewGithubProvider(pConfig, scopes)
593606
case "gitlab":
594-
return provider.NewGitlabProvider(config.External.Gitlab, scopes)
607+
pConfig = config.External.Gitlab
608+
p, err = provider.NewGitlabProvider(pConfig, scopes)
595609
case "google":
596-
return provider.NewGoogleProvider(ctx, config.External.Google, scopes)
610+
pConfig = config.External.Google
611+
p, err = provider.NewGoogleProvider(ctx, pConfig, scopes)
597612
case "kakao":
598-
return provider.NewKakaoProvider(config.External.Kakao, scopes)
613+
pConfig = config.External.Kakao
614+
p, err = provider.NewKakaoProvider(pConfig, scopes)
599615
case "keycloak":
600-
return provider.NewKeycloakProvider(config.External.Keycloak, scopes)
616+
pConfig = config.External.Keycloak
617+
p, err = provider.NewKeycloakProvider(pConfig, scopes)
601618
case "linkedin":
602-
return provider.NewLinkedinProvider(config.External.Linkedin, scopes)
619+
pConfig = config.External.Linkedin
620+
p, err = provider.NewLinkedinProvider(pConfig, scopes)
603621
case "linkedin_oidc":
604-
return provider.NewLinkedinOIDCProvider(config.External.LinkedinOIDC, scopes)
622+
pConfig = config.External.LinkedinOIDC
623+
p, err = provider.NewLinkedinOIDCProvider(pConfig, scopes)
605624
case "notion":
606-
return provider.NewNotionProvider(config.External.Notion)
625+
pConfig = config.External.Notion
626+
p, err = provider.NewNotionProvider(pConfig)
607627
case "snapchat":
608-
return provider.NewSnapchatProvider(config.External.Snapchat, scopes)
628+
pConfig = config.External.Snapchat
629+
p, err = provider.NewSnapchatProvider(pConfig, scopes)
609630
case "spotify":
610-
return provider.NewSpotifyProvider(config.External.Spotify, scopes)
631+
pConfig = config.External.Spotify
632+
p, err = provider.NewSpotifyProvider(pConfig, scopes)
611633
case "slack":
612-
return provider.NewSlackProvider(config.External.Slack, scopes)
634+
pConfig = config.External.Slack
635+
p, err = provider.NewSlackProvider(pConfig, scopes)
613636
case "slack_oidc":
614-
return provider.NewSlackOIDCProvider(config.External.SlackOIDC, scopes)
637+
pConfig = config.External.SlackOIDC
638+
p, err = provider.NewSlackOIDCProvider(pConfig, scopes)
615639
case "twitch":
616-
return provider.NewTwitchProvider(config.External.Twitch, scopes)
640+
pConfig = config.External.Twitch
641+
p, err = provider.NewTwitchProvider(pConfig, scopes)
617642
case "twitter":
618-
return provider.NewTwitterProvider(config.External.Twitter, scopes)
643+
pConfig = config.External.Twitter
644+
p, err = provider.NewTwitterProvider(pConfig, scopes)
619645
case "vercel_marketplace":
620-
return provider.NewVercelMarketplaceProvider(config.External.VercelMarketplace, scopes)
646+
pConfig = config.External.VercelMarketplace
647+
p, err = provider.NewVercelMarketplaceProvider(pConfig, scopes)
621648
case "workos":
622-
return provider.NewWorkOSProvider(config.External.WorkOS)
649+
pConfig = config.External.WorkOS
650+
p, err = provider.NewWorkOSProvider(pConfig)
623651
case "zoom":
624-
return provider.NewZoomProvider(config.External.Zoom)
652+
pConfig = config.External.Zoom
653+
p, err = provider.NewZoomProvider(pConfig)
625654
default:
626-
return nil, fmt.Errorf("Provider %s could not be found", name)
655+
return nil, pConfig, fmt.Errorf("Provider %s could not be found", name)
627656
}
657+
658+
return p, pConfig, err
628659
}
629660

630661
func redirectErrors(handler apiHandler, w http.ResponseWriter, r *http.Request, u *url.URL) {

internal/api/external_oauth.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/sirupsen/logrus"
1111
"github.com/supabase/auth/internal/api/apierrors"
1212
"github.com/supabase/auth/internal/api/provider"
13+
"github.com/supabase/auth/internal/conf"
1314
"github.com/supabase/auth/internal/observability"
1415
"github.com/supabase/auth/internal/utilities"
1516
)
@@ -69,7 +70,7 @@ func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType s
6970
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeBadOAuthCallback, "OAuth callback with missing authorization code missing")
7071
}
7172

72-
oAuthProvider, err := a.OAuthProvider(ctx, providerType)
73+
oAuthProvider, _, err := a.OAuthProvider(ctx, providerType)
7374
if err != nil {
7475
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeOAuthProviderNotSupported, "Unsupported provider: %+v", err).WithInternalError(err)
7576
}
@@ -111,7 +112,7 @@ func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType s
111112
}
112113

113114
func (a *API) oAuth1Callback(ctx context.Context, providerType string) (*OAuthProviderData, error) {
114-
oAuthProvider, err := a.OAuthProvider(ctx, providerType)
115+
oAuthProvider, _, err := a.OAuthProvider(ctx, providerType)
115116
if err != nil {
116117
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeOAuthProviderNotSupported, "Unsupported provider: %+v", err).WithInternalError(err)
117118
}
@@ -141,16 +142,16 @@ func (a *API) oAuth1Callback(ctx context.Context, providerType string) (*OAuthPr
141142
}
142143

143144
// OAuthProvider returns the corresponding oauth provider as an OAuthProvider interface
144-
func (a *API) OAuthProvider(ctx context.Context, name string) (provider.OAuthProvider, error) {
145-
providerCandidate, err := a.Provider(ctx, name, "")
145+
func (a *API) OAuthProvider(ctx context.Context, name string) (provider.OAuthProvider, conf.OAuthProviderConfiguration, error) {
146+
providerCandidate, pConfig, err := a.Provider(ctx, name, "")
146147
if err != nil {
147-
return nil, err
148+
return nil, pConfig, err
148149
}
149150

150151
switch p := providerCandidate.(type) {
151152
case provider.OAuthProvider:
152-
return p, nil
153+
return p, pConfig, nil
153154
default:
154-
return nil, fmt.Errorf("Provider %v cannot be used for OAuth", name)
155+
return nil, pConfig, fmt.Errorf("Provider %v cannot be used for OAuth", name)
155156
}
156157
}

0 commit comments

Comments
 (0)