From 830f22842e68e0acd2a6ec42ff3b4b2aabc8a1c1 Mon Sep 17 00:00:00 2001 From: Cemal Kilic Date: Wed, 29 Oct 2025 11:50:05 +0300 Subject: [PATCH 1/4] feat(oauth): add __Supabase__ OAuth provider --- internal/api/external.go | 44 +++++- internal/api/external_oauth.go | 16 +- internal/api/magic_link.go | 2 +- internal/api/pkce.go | 4 +- internal/api/provider/apple.go | 11 +- internal/api/provider/azure.go | 8 +- internal/api/provider/bitbucket.go | 8 +- internal/api/provider/discord.go | 8 +- internal/api/provider/facebook.go | 8 +- internal/api/provider/figma.go | 8 +- internal/api/provider/fly.go | 8 +- internal/api/provider/github.go | 8 +- internal/api/provider/gitlab.go | 8 +- internal/api/provider/google.go | 8 +- internal/api/provider/kakao.go | 8 +- internal/api/provider/keycloak.go | 8 +- internal/api/provider/linkedin.go | 8 +- internal/api/provider/linkedin_oidc.go | 8 +- internal/api/provider/notion.go | 8 +- internal/api/provider/provider.go | 13 +- internal/api/provider/slack.go | 8 +- internal/api/provider/slack_oidc.go | 8 +- internal/api/provider/snapchat.go | 8 +- internal/api/provider/spotify.go | 8 +- internal/api/provider/supabase.go | 145 ++++++++++++++++++ internal/api/provider/twitch.go | 8 +- internal/api/provider/twitter.go | 6 +- internal/api/provider/vercel_marketplace.go | 8 +- internal/api/provider/workos.go | 8 +- internal/api/provider/zoom.go | 8 +- internal/api/recover.go | 2 +- internal/api/signup.go | 2 +- internal/api/sso.go | 2 +- internal/api/user.go | 2 +- internal/conf/configuration.go | 1 + internal/models/flow_state.go | 11 +- internal/security/pkce.go | 2 + ...rovider_code_verifier_to_flow_state.up.sql | 7 + 38 files changed, 381 insertions(+), 65 deletions(-) create mode 100644 internal/api/provider/supabase.go create mode 100644 migrations/20251028000000_add_provider_code_verifier_to_flow_state.up.sql diff --git a/internal/api/external.go b/internal/api/external.go index 9611c24c2..51df231ab 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -81,9 +81,25 @@ func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Requ } flowType := getFlowFromChallenge(codeChallenge) + // Check if this provider requires PKCE for its own authorization flow + // Generate a separate PKCE pair for communication with the external provider + // This is separate from the user's PKCE and is used for provider-to-provider auth + providerRequiresPKCE := false + providerCodeVerifier := "" + if oauthProvider, ok := p.(provider.OAuthProvider); ok { + providerRequiresPKCE = oauthProvider.RequiresPKCE() + if providerRequiresPKCE { + // Uses oauth2 library's built-in PKCE support + providerCodeVerifier = oauth2.GenerateVerifier() + } + } + flowStateID := "" - if isPKCEFlow(flowType) { - flowState, err := generateFlowState(db, providerType, models.OAuth, codeChallengeMethod, codeChallenge, nil) + var flowState *models.FlowState + // Create FlowState if user is using PKCE OR provider requires PKCE + // This ensures we can store provider's code_verifier even for implicit flows + if isPKCEFlow(flowType) || providerRequiresPKCE { + flowState, err = generateFlowState(db, providerType, models.OAuth, codeChallengeMethod, codeChallenge, nil, providerCodeVerifier) if err != nil { return "", err } @@ -129,6 +145,13 @@ func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Requ } } + // Pass the GENERATED PKCE parameters (not user's PKCE) to the external provider's + // authorization URL using oauth2 library's built-in PKCE support + // This works for any OAuth provider that supports/requires PKCE + if flowState != nil && flowState.ProviderCodeVerifier != "" { + authUrlParams = append(authUrlParams, oauth2.S256ChallengeOption(flowState.ProviderCodeVerifier)) + } + authURL := p.AuthCodeURL(tokenString, authUrlParams...) return authURL, nil @@ -237,8 +260,10 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re return terr } } - if flowState != nil { - // This means that the callback is using PKCE + // Check if USER requested PKCE (not just if FlowState exists) + // FlowState might exist only because provider requires PKCE + if flowState.IsUserPKCEFlow() { + // User wants PKCE flow - store tokens and return auth code flowState.ProviderAccessToken = providerAccessToken flowState.ProviderRefreshToken = providerRefreshToken flowState.UserID = &(user.ID) @@ -247,6 +272,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re terr = tx.Update(flowState) } else { + // User wants implicit flow - issue token directly token, terr = a.issueRefreshToken(r, tx, user, models.OAuth, grantParams) } @@ -272,14 +298,15 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re } rurl := a.getExternalRedirectURL(r) - if flowState != nil { - // This means that the callback is using PKCE - // Set the flowState.AuthCode to the query param here + // Check if USER requested PKCE (not just if FlowState exists) + if flowState.IsUserPKCEFlow() { + // User wants PKCE - return auth code rurl, err = a.prepPKCERedirectURL(rurl, flowState.AuthCode) if err != nil { return err } } else if token != nil { + // User wants implicit flow - return token directly q := url.Values{} q.Set("provider_token", providerAccessToken) // Because not all providers give out a refresh token @@ -644,6 +671,9 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide case "spotify": pConfig = config.External.Spotify p, err = provider.NewSpotifyProvider(pConfig, scopes) + case "supabase": + pConfig = config.External.Supabase + p, err = provider.NewSupabaseProvider(ctx, pConfig, scopes) case "slack": pConfig = config.External.Slack p, err = provider.NewSlackProvider(pConfig, scopes) diff --git a/internal/api/external_oauth.go b/internal/api/external_oauth.go index a02623a38..6afeed9f6 100644 --- a/internal/api/external_oauth.go +++ b/internal/api/external_oauth.go @@ -11,8 +11,10 @@ import ( "github.com/supabase/auth/internal/api/apierrors" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/utilities" + "golang.org/x/oauth2" ) // OAuthProviderData contains the userData and token returned by the oauth provider @@ -83,7 +85,19 @@ func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType s "code": oauthCode, }).Debug("Exchanging oauth code") - token, err := oAuthProvider.GetOAuthToken(oauthCode) + // Build token exchange options, including PKCE code verifier if available + var tokenOpts []oauth2.AuthCodeOption + flowStateID := getFlowStateID(ctx) + if flowStateID != "" { + db := a.db.WithContext(ctx) + flowState, fsErr := models.FindFlowStateByID(db, flowStateID) + if fsErr == nil && flowState.ProviderCodeVerifier != "" { + // Pass PKCE code verifier for token exchange + tokenOpts = append(tokenOpts, oauth2.VerifierOption(flowState.ProviderCodeVerifier)) + } + } + + token, err := oAuthProvider.GetOAuthToken(oauthCode, tokenOpts...) if err != nil { return nil, apierrors.NewInternalServerError("Unable to exchange external code: %s", oauthCode).WithInternalError(err) } diff --git a/internal/api/magic_link.go b/internal/api/magic_link.go index 2393059de..7b63a1811 100644 --- a/internal/api/magic_link.go +++ b/internal/api/magic_link.go @@ -130,7 +130,7 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error { } if isPKCEFlow(flowType) { - if _, err = generateFlowState(db, models.MagicLink.String(), models.MagicLink, params.CodeChallengeMethod, params.CodeChallenge, &user.ID); err != nil { + if _, err = generateFlowState(db, models.MagicLink.String(), models.MagicLink, params.CodeChallengeMethod, params.CodeChallenge, &user.ID, ""); err != nil { return err } } diff --git a/internal/api/pkce.go b/internal/api/pkce.go index f62c00625..b2ff5f533 100644 --- a/internal/api/pkce.go +++ b/internal/api/pkce.go @@ -85,12 +85,12 @@ func getFlowFromChallenge(codeChallenge string) models.FlowType { } // Should only be used with Auth Code of PKCE Flows -func generateFlowState(tx *storage.Connection, providerType string, authenticationMethod models.AuthenticationMethod, codeChallengeMethodParam string, codeChallenge string, userID *uuid.UUID) (*models.FlowState, error) { +func generateFlowState(tx *storage.Connection, providerType string, authenticationMethod models.AuthenticationMethod, codeChallengeMethodParam string, codeChallenge string, userID *uuid.UUID, providerCodeVerifier string) (*models.FlowState, error) { codeChallengeMethod, err := models.ParseCodeChallengeMethod(codeChallengeMethodParam) if err != nil { return nil, err } - flowState := models.NewFlowState(providerType, codeChallenge, codeChallengeMethod, authenticationMethod, userID) + flowState := models.NewFlowState(providerType, codeChallenge, codeChallengeMethod, authenticationMethod, userID, providerCodeVerifier) if err := tx.Create(flowState); err != nil { return nil, err } diff --git a/internal/api/provider/apple.go b/internal/api/provider/apple.go index 260a3605c..33c94434f 100644 --- a/internal/api/provider/apple.go +++ b/internal/api/provider/apple.go @@ -118,12 +118,13 @@ func NewAppleProvider(ctx context.Context, ext conf.OAuthProviderConfiguration) } // GetOAuthToken returns the apple provider access token -func (p AppleProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - opts := []oauth2.AuthCodeOption{ +func (p AppleProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + appleOpts := []oauth2.AuthCodeOption{ oauth2.SetAuthURLParam("client_id", p.ClientID), oauth2.SetAuthURLParam("secret", p.ClientSecret), } - return p.Exchange(context.Background(), code, opts...) + appleOpts = append(appleOpts, opts...) + return p.Exchange(context.Background(), code, appleOpts...) } func (p AppleProvider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string { @@ -172,3 +173,7 @@ func (p AppleProvider) ParseUser(data string, userData *UserProvidedData) error userData.Metadata.FullName = strings.TrimSpace(u.Name.FirstName + " " + u.Name.LastName) return nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *AppleProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/azure.go b/internal/api/provider/azure.go index 4a341f4d6..dbc0b42ce 100644 --- a/internal/api/provider/azure.go +++ b/internal/api/provider/azure.go @@ -91,8 +91,8 @@ func NewAzureProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuth }, nil } -func (g azureProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g azureProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func DetectAzureIDTokenIssuer(ctx context.Context, idToken string) (string, error) { @@ -162,3 +162,7 @@ func (g azureProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Use return nil, fmt.Errorf("azure: no OIDC ID token present in response") } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *azureProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/bitbucket.go b/internal/api/provider/bitbucket.go index e5fae5c91..b6b7a3510 100644 --- a/internal/api/provider/bitbucket.go +++ b/internal/api/provider/bitbucket.go @@ -59,8 +59,8 @@ func NewBitbucketProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, e }, nil } -func (g bitbucketProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g bitbucketProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g bitbucketProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -102,3 +102,7 @@ func (g bitbucketProvider) GetUserData(ctx context.Context, tok *oauth2.Token) ( return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *bitbucketProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/discord.go b/internal/api/provider/discord.go index 50d413b7c..6e2fcff2b 100644 --- a/internal/api/provider/discord.go +++ b/internal/api/provider/discord.go @@ -61,8 +61,8 @@ func NewDiscordProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAu }, nil } -func (g discordProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g discordProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g discordProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -118,3 +118,7 @@ func (g discordProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*U return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *discordProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/facebook.go b/internal/api/provider/facebook.go index e73c419da..620c9bf47 100644 --- a/internal/api/provider/facebook.go +++ b/internal/api/provider/facebook.go @@ -70,8 +70,8 @@ func NewFacebookProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA }, nil } -func (p facebookProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return p.Exchange(context.Background(), code) +func (p facebookProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.Exchange(context.Background(), code, opts...) } func (p facebookProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -110,3 +110,7 @@ func (p facebookProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (* return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *facebookProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/figma.go b/internal/api/provider/figma.go index aae74939b..1311e3125 100644 --- a/internal/api/provider/figma.go +++ b/internal/api/provider/figma.go @@ -60,8 +60,8 @@ func NewFigmaProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuth }, nil } -func (p figmaProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return p.Exchange(context.Background(), code) +func (p figmaProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.Exchange(context.Background(), code, opts...) } func (p figmaProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -93,3 +93,7 @@ func (p figmaProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Use } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *figmaProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/fly.go b/internal/api/provider/fly.go index d9337524f..6cf5e9c07 100644 --- a/internal/api/provider/fly.go +++ b/internal/api/provider/fly.go @@ -65,8 +65,8 @@ func NewFlyProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthPr }, nil } -func (p flyProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return p.Exchange(context.Background(), code) +func (p flyProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.Exchange(context.Background(), code, opts...) } func (p flyProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -101,3 +101,7 @@ func (p flyProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserP } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *flyProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/github.go b/internal/api/provider/github.go index 0da3e8842..7a9f4551f 100644 --- a/internal/api/provider/github.go +++ b/internal/api/provider/github.go @@ -70,8 +70,8 @@ func NewGithubProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAut }, nil } -func (g githubProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g githubProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g githubProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -108,3 +108,7 @@ func (g githubProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *githubProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/gitlab.go b/internal/api/provider/gitlab.go index 4b5d70cb8..9a0cc8ec2 100644 --- a/internal/api/provider/gitlab.go +++ b/internal/api/provider/gitlab.go @@ -61,8 +61,8 @@ func NewGitlabProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAut }, nil } -func (g gitlabProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g gitlabProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g gitlabProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -105,3 +105,7 @@ func (g gitlabProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *gitlabProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/google.go b/internal/api/provider/google.go index 03b76aebe..4763a734c 100644 --- a/internal/api/provider/google.go +++ b/internal/api/provider/google.go @@ -72,8 +72,8 @@ func NewGoogleProvider(ctx context.Context, ext conf.OAuthProviderConfiguration, }, nil } -func (g googleProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g googleProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } const UserInfoEndpointGoogle = "https://www.googleapis.com/userinfo/v2/me" @@ -142,3 +142,7 @@ func OverrideGoogleProvider(issuer, userInfo string) { internalIssuerGoogle = issuer internalUserInfoEndpointGoogle = userInfo } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *googleProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/kakao.go b/internal/api/provider/kakao.go index 2482b97a8..bc842da42 100644 --- a/internal/api/provider/kakao.go +++ b/internal/api/provider/kakao.go @@ -33,8 +33,8 @@ type kakaoUser struct { } `json:"kakao_account"` } -func (p kakaoProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return p.Exchange(context.Background(), code) +func (p kakaoProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.Exchange(context.Background(), code, opts...) } func (p kakaoProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -105,3 +105,7 @@ func NewKakaoProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuth APIHost: apiHost, }, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *kakaoProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/keycloak.go b/internal/api/provider/keycloak.go index 480a46724..3f123216a 100644 --- a/internal/api/provider/keycloak.go +++ b/internal/api/provider/keycloak.go @@ -85,8 +85,8 @@ func NewKeycloakProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA }, nil } -func (g keycloakProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g keycloakProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g keycloakProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -132,3 +132,7 @@ func (g keycloakProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (* return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *keycloakProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/linkedin.go b/internal/api/provider/linkedin.go index bc33515e7..9e6af2230 100644 --- a/internal/api/provider/linkedin.go +++ b/internal/api/provider/linkedin.go @@ -97,8 +97,8 @@ func NewLinkedinProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA }, nil } -func (g linkedinProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g linkedinProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func GetName(name linkedinName) string { @@ -147,3 +147,7 @@ func (g linkedinProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (* } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *linkedinProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/linkedin_oidc.go b/internal/api/provider/linkedin_oidc.go index a5d94fa09..eeacbed03 100644 --- a/internal/api/provider/linkedin_oidc.go +++ b/internal/api/provider/linkedin_oidc.go @@ -59,8 +59,8 @@ func NewLinkedinOIDCProvider(ext conf.OAuthProviderConfiguration, scopes string) }, nil } -func (g linkedinOIDCProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g linkedinOIDCProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g linkedinOIDCProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -79,3 +79,7 @@ func (g linkedinOIDCProvider) GetUserData(ctx context.Context, tok *oauth2.Token } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *linkedinOIDCProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/notion.go b/internal/api/provider/notion.go index f8d0ee706..7faed28ea 100644 --- a/internal/api/provider/notion.go +++ b/internal/api/provider/notion.go @@ -59,8 +59,8 @@ func NewNotionProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, erro }, nil } -func (g notionProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g notionProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g notionProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -119,3 +119,7 @@ func (g notionProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *notionProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/provider.go b/internal/api/provider/provider.go index 5471e1cee..e2d187c2a 100644 --- a/internal/api/provider/provider.go +++ b/internal/api/provider/provider.go @@ -102,9 +102,20 @@ type Provider interface { // OAuthProvider specifies additional methods needed for providers using OAuth type OAuthProvider interface { + // AuthCodeURL returns the URL for the authorization code flow AuthCodeURL(string, ...oauth2.AuthCodeOption) string + + // GetUserData retrieves user information from the provider using the access token GetUserData(context.Context, *oauth2.Token) (*UserProvidedData, error) - GetOAuthToken(string) (*oauth2.Token, error) + + // GetOAuthToken exchanges the authorization code for an access token + // The opts parameter can include PKCE verifier for providers that require it + GetOAuthToken(string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) + + // RequiresPKCE indicates whether this provider requires PKCE for its OAuth authorization code flow + // When true, the auth instance will automatically generate and manage PKCE parameters + // for communication with this provider, regardless of whether the end user requested PKCE + RequiresPKCE() bool } func chooseHost(base, defaultHost string) string { diff --git a/internal/api/provider/slack.go b/internal/api/provider/slack.go index 40377b0aa..173fc17b6 100644 --- a/internal/api/provider/slack.go +++ b/internal/api/provider/slack.go @@ -57,8 +57,8 @@ func NewSlackProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuth }, nil } -func (g slackProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g slackProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g slackProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -92,3 +92,7 @@ func (g slackProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Use } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *slackProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/slack_oidc.go b/internal/api/provider/slack_oidc.go index 3c7a5eb62..c2eb504f0 100644 --- a/internal/api/provider/slack_oidc.go +++ b/internal/api/provider/slack_oidc.go @@ -60,8 +60,8 @@ func NewSlackOIDCProvider(ext conf.OAuthProviderConfiguration, scopes string) (O }, nil } -func (g slackOIDCProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g slackOIDCProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g slackOIDCProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -97,3 +97,7 @@ func (g slackOIDCProvider) GetUserData(ctx context.Context, tok *oauth2.Token) ( } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *slackOIDCProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/snapchat.go b/internal/api/provider/snapchat.go index 6c64b86e2..037144888 100644 --- a/internal/api/provider/snapchat.go +++ b/internal/api/provider/snapchat.go @@ -71,8 +71,8 @@ func NewSnapchatProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA }, nil } -func (p snapchatProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return p.Exchange(context.Background(), code) +func (p snapchatProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.Exchange(context.Background(), code, opts...) } func (p snapchatProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -138,3 +138,7 @@ func parseSnapchatIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData return token, &data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *snapchatProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/spotify.go b/internal/api/provider/spotify.go index e6d2f383c..883244f93 100644 --- a/internal/api/provider/spotify.go +++ b/internal/api/provider/spotify.go @@ -63,8 +63,8 @@ func NewSpotifyProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAu }, nil } -func (g spotifyProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g spotifyProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g spotifyProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -112,3 +112,7 @@ func (g spotifyProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*U } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *spotifyProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/supabase.go b/internal/api/provider/supabase.go new file mode 100644 index 000000000..4cb607108 --- /dev/null +++ b/internal/api/provider/supabase.go @@ -0,0 +1,145 @@ +package provider + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "strings" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +// SupabaseProvider represents a Supabase OAuth provider +type SupabaseProvider struct { + *oauth2.Config +} + +// NewSupabaseProvider creates a Supabase OAuth2 provider. +func NewSupabaseProvider(ctx context.Context, ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + if ext.URL == "" { + return nil, errors.New("unable to find URL for the Supabase provider, make sure config is set") + } + baseURL := strings.TrimSuffix(ext.URL, "/") + + // TODO(cemal) :: currently not being supported by supabase auth oauth2.1 + oauthScopes := []string{} + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + // Supabase Auth OAuth endpoints + // TODO(cemal) :: update to use oidc.NewProvider when supabase auth supports oidc + authURL := baseURL + "/oauth/authorize" + tokenURL := baseURL + "/oauth/token" + + return &SupabaseProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, + }, + }, nil +} + +func (p SupabaseProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.Exchange(context.Background(), code, opts...) +} + +func (p SupabaseProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + // TEMP(cemal): Supabase Auth doesn't expose a userinfo endpoint yet. + // Until then, decode claims from the freshly issued OAuth token without signature verification. + + data := &UserProvidedData{} + data.Metadata = &Claims{} + claimsMap, err := decodeJWTClaims(tok.AccessToken) + if err == nil { + // Extract email and email_verified from claims + email, _ := claimsMap["email"].(string) + + // email_verified may live under user_metadata.email_verified + emailVerified := false + if uv, ok := claimsMap["email_verified"].(bool); ok { + emailVerified = uv + } else if um, ok := claimsMap["user_metadata"].(map[string]interface{}); ok { + if uv2, ok2 := um["email_verified"].(bool); ok2 { + emailVerified = uv2 + } + } + + if email != "" { + data.Emails = []Email{{ + Email: email, + Verified: emailVerified, + Primary: true, + }} + } + + if iss, ok := claimsMap["iss"].(string); ok { + data.Metadata.Issuer = iss + } + if sub, ok := claimsMap["sub"].(string); ok { + data.Metadata.Subject = sub + // To be deprecated + data.Metadata.ProviderId = sub + } + if aud, ok := claimsMap["aud"].(string); ok { + data.Metadata.Aud = audience{aud} + } + + data.Metadata.Email = email + data.Metadata.EmailVerified = emailVerified + + // Carry through app_metadata and user_metadata as custom claims + customClaims := make(map[string]interface{}) + if am, ok := claimsMap["app_metadata"].(map[string]interface{}); ok && len(am) > 0 { + customClaims["app_metadata"] = am + } + if um, ok := claimsMap["user_metadata"].(map[string]interface{}); ok && len(um) > 0 { + customClaims["user_metadata"] = um + } + if clientId, ok := claimsMap["client_id"].(string); ok { + customClaims["client_id"] = clientId + } + + if len(customClaims) > 0 { + data.Metadata.CustomClaims = customClaims + } + } + + return data, nil +} + +// RequiresPKCE returns true as Supabase requires PKCE for OAuth +func (p *SupabaseProvider) RequiresPKCE() bool { + return true +} + +// decodeJWTClaims decodes the claims section of a JWT without verifying the signature. +// This is safe here because the token was just issued by authorization code flow. +func decodeJWTClaims(token string) (map[string]interface{}, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil, errors.New("invalid jwt format") + } + payloadSegment := parts[1] + decoded, err := base64.RawURLEncoding.DecodeString(payloadSegment) + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := json.Unmarshal(decoded, &m); err != nil { + return nil, err + } + return m, nil +} diff --git a/internal/api/provider/twitch.go b/internal/api/provider/twitch.go index defb1983a..50fb09f5a 100644 --- a/internal/api/provider/twitch.go +++ b/internal/api/provider/twitch.go @@ -75,8 +75,8 @@ func NewTwitchProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAut }, nil } -func (t twitchProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return t.Exchange(context.Background(), code) +func (t twitchProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return t.Exchange(context.Background(), code, opts...) } func (t twitchProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -152,3 +152,7 @@ func (t twitchProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *twitchProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/twitter.go b/internal/api/provider/twitter.go index 8dc5a4c64..c9b59df16 100644 --- a/internal/api/provider/twitter.go +++ b/internal/api/provider/twitter.go @@ -60,7 +60,7 @@ func NewTwitterProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAu } // GetOAuthToken is a stub method for OAuthProvider interface, unused in OAuth1.0 protocol -func (t TwitterProvider) GetOAuthToken(_ string) (*oauth2.Token, error) { +func (t TwitterProvider) GetOAuthToken(_ string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { return &oauth2.Token{}, nil } @@ -153,3 +153,7 @@ func (t TwitterProvider) Unmarshal(data string) (*oauth.RequestToken, error) { err := json.NewDecoder(strings.NewReader(data)).Decode(requestToken) return requestToken, err } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *TwitterProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/vercel_marketplace.go b/internal/api/provider/vercel_marketplace.go index ba76a7412..0503eaffc 100644 --- a/internal/api/provider/vercel_marketplace.go +++ b/internal/api/provider/vercel_marketplace.go @@ -56,8 +56,8 @@ func NewVercelMarketplaceProvider(ext conf.OAuthProviderConfiguration, scopes st }, nil } -func (g vercelMarketplaceProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g vercelMarketplaceProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g vercelMarketplaceProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -76,3 +76,7 @@ func (g vercelMarketplaceProvider) GetUserData(ctx context.Context, tok *oauth2. } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *vercelMarketplaceProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/workos.go b/internal/api/provider/workos.go index 75cafa27d..4fdcbbc4e 100644 --- a/internal/api/provider/workos.go +++ b/internal/api/provider/workos.go @@ -53,8 +53,8 @@ func NewWorkOSProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, erro }, nil } -func (g workosProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g workosProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g workosProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -96,3 +96,7 @@ func (g workosProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *workosProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/provider/zoom.go b/internal/api/provider/zoom.go index 8e2e9fa4d..62904ba5f 100644 --- a/internal/api/provider/zoom.go +++ b/internal/api/provider/zoom.go @@ -51,8 +51,8 @@ func NewZoomProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, error) }, nil } -func (g zoomProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return g.Exchange(context.Background(), code) +func (g zoomProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code, opts...) } func (g zoomProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { @@ -89,3 +89,7 @@ func (g zoomProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*User } return data, nil } +// RequiresPKCE returns false as this provider does not require PKCE +func (p *zoomProvider) RequiresPKCE() bool { + return false +} diff --git a/internal/api/recover.go b/internal/api/recover.go index 7c967aaf0..98132f001 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -57,7 +57,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { return apierrors.NewInternalServerError("Unable to process request").WithInternalError(err) } if isPKCEFlow(flowType) { - if _, err := generateFlowState(db, models.Recovery.String(), models.Recovery, params.CodeChallengeMethod, params.CodeChallenge, &(user.ID)); err != nil { + if _, err := generateFlowState(db, models.Recovery.String(), models.Recovery, params.CodeChallengeMethod, params.CodeChallenge, &(user.ID), ""); err != nil { return err } } diff --git a/internal/api/signup.go b/internal/api/signup.go index 89c79f889..0a3b3d2ff 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -242,7 +242,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { return terr } if isPKCEFlow(flowType) { - _, terr := generateFlowState(tx, params.Provider, models.EmailSignup, params.CodeChallengeMethod, params.CodeChallenge, &user.ID) + _, terr := generateFlowState(tx, params.Provider, models.EmailSignup, params.CodeChallengeMethod, params.CodeChallenge, &user.ID, "") if terr != nil { return terr } diff --git a/internal/api/sso.go b/internal/api/sso.go index 9b803f4ce..94804ea42 100644 --- a/internal/api/sso.go +++ b/internal/api/sso.go @@ -86,7 +86,7 @@ func (a *API) SingleSignOn(w http.ResponseWriter, r *http.Request) error { var flowStateID *uuid.UUID flowStateID = nil if isPKCEFlow(flowType) { - flowState, err := generateFlowState(db, models.SSOSAML.String(), models.SSOSAML, codeChallengeMethod, codeChallenge, nil) + flowState, err := generateFlowState(db, models.SSOSAML.String(), models.SSOSAML, codeChallengeMethod, codeChallenge, nil, "") if err != nil { return err } diff --git a/internal/api/user.go b/internal/api/user.go index 723bce144..74781f70f 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -235,7 +235,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } else { flowType := getFlowFromChallenge(params.CodeChallenge) if isPKCEFlow(flowType) { - _, terr := generateFlowState(tx, models.EmailChange.String(), models.EmailChange, params.CodeChallengeMethod, params.CodeChallenge, &user.ID) + _, terr := generateFlowState(tx, models.EmailChange.String(), models.EmailChange, params.CodeChallengeMethod, params.CodeChallenge, &user.ID, "") if terr != nil { return terr } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 84ec2ee16..091a1a9de 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -430,6 +430,7 @@ type ProviderConfiguration struct { Linkedin OAuthProviderConfiguration `json:"linkedin"` LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` Spotify OAuthProviderConfiguration `json:"spotify"` + Supabase OAuthProviderConfiguration `json:"supabase"` Slack OAuthProviderConfiguration `json:"slack"` SlackOIDC OAuthProviderConfiguration `json:"slack_oidc" envconfig:"SLACK_OIDC"` Twitter OAuthProviderConfiguration `json:"twitter"` diff --git a/internal/models/flow_state.go b/internal/models/flow_state.go index 7ce4c940a..33f30bb6b 100644 --- a/internal/models/flow_state.go +++ b/internal/models/flow_state.go @@ -23,6 +23,7 @@ type FlowState struct { ProviderType string `json:"provider_type" db:"provider_type"` ProviderAccessToken string `json:"provider_access_token" db:"provider_access_token"` ProviderRefreshToken string `json:"provider_refresh_token" db:"provider_refresh_token"` + ProviderCodeVerifier string `json:"provider_code_verifier" db:"provider_code_verifier"` // PKCE code_verifier for external provider communication AuthCodeIssuedAt *time.Time `json:"auth_code_issued_at" db:"auth_code_issued_at"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` @@ -77,7 +78,7 @@ func (FlowState) TableName() string { return tableName } -func NewFlowState(providerType, codeChallenge string, codeChallengeMethod CodeChallengeMethod, authenticationMethod AuthenticationMethod, userID *uuid.UUID) *FlowState { +func NewFlowState(providerType, codeChallenge string, codeChallengeMethod CodeChallengeMethod, authenticationMethod AuthenticationMethod, userID *uuid.UUID, providerCodeVerifier string) *FlowState { id := uuid.Must(uuid.NewV4()) authCode := uuid.Must(uuid.NewV4()) flowState := &FlowState{ @@ -88,6 +89,7 @@ func NewFlowState(providerType, codeChallenge string, codeChallengeMethod CodeCh AuthCode: authCode.String(), AuthenticationMethod: authenticationMethod.String(), UserID: userID, + ProviderCodeVerifier: providerCodeVerifier, } return flowState } @@ -132,6 +134,13 @@ func (f *FlowState) VerifyPKCE(codeVerifier string) error { return security.VerifyPKCEChallenge(f.CodeChallenge, f.CodeChallengeMethod, codeVerifier) } +// IsUserPKCEFlow returns true if the user explicitly requested PKCE flow +// by providing a code_challenge. This is different from FlowState simply +// existing, which might be due to the provider requiring PKCE internally. +func (f *FlowState) IsUserPKCEFlow() bool { + return f != nil && f.CodeChallenge != "" +} + func (f *FlowState) IsExpired(expiryDuration time.Duration) bool { if f.AuthCodeIssuedAt != nil && f.AuthenticationMethod == MagicLink.String() { return time.Now().After(f.AuthCodeIssuedAt.Add(expiryDuration)) diff --git a/internal/security/pkce.go b/internal/security/pkce.go index 7e4ccf41d..21334a733 100644 --- a/internal/security/pkce.go +++ b/internal/security/pkce.go @@ -13,6 +13,8 @@ const PKCEInvalidCodeMethodError = "code challenge method not supported" // VerifyPKCEChallenge performs PKCE verification using the provided challenge, method, and verifier // This is a shared utility function used by both FlowState and OAuthServerAuthorization +// Note: For generating PKCE verifiers and challenges, use golang.org/x/oauth2.GenerateVerifier() +// and oauth2.S256ChallengeFromVerifier() instead of custom implementations func VerifyPKCEChallenge(codeChallenge, codeChallengeMethod, codeVerifier string) error { switch strings.ToLower(codeChallengeMethod) { case "s256": diff --git a/migrations/20251028000000_add_provider_code_verifier_to_flow_state.up.sql b/migrations/20251028000000_add_provider_code_verifier_to_flow_state.up.sql new file mode 100644 index 000000000..e248a31ec --- /dev/null +++ b/migrations/20251028000000_add_provider_code_verifier_to_flow_state.up.sql @@ -0,0 +1,7 @@ +-- Add provider_code_verifier column to flow_state table +-- This stores the code_verifier used for PKCE flow when this auth instance +-- acts as an OAuth client to another Supabase instance +alter table {{ index .Options "Namespace" }}.flow_state +add column if not exists provider_code_verifier text null; + +comment on column {{ index .Options "Namespace" }}.flow_state.provider_code_verifier is 'stores the code verifier for PKCE when communicating with external OAuth providers'; From e69593515d69af25ed33582231c01f7d2e209186 Mon Sep 17 00:00:00 2001 From: Cemal Kilic Date: Wed, 29 Oct 2025 12:05:20 +0300 Subject: [PATCH 2/4] fix: add missing param in tests --- internal/api/token_test.go | 2 +- internal/api/verify_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/token_test.go b/internal/api/token_test.go index 166bfc900..628ecdfa9 100644 --- a/internal/api/token_test.go +++ b/internal/api/token_test.go @@ -338,7 +338,7 @@ func (ts *TokenTestSuite) TestTokenPKCEGrantFailure() { invalidVerifier := codeVerifier + "123" codeChallenge := sha256.Sum256([]byte(codeVerifier)) challenge := base64.RawURLEncoding.EncodeToString(codeChallenge[:]) - flowState := models.NewFlowState("github", challenge, models.SHA256, models.OAuth, nil) + flowState := models.NewFlowState("github", challenge, models.SHA256, models.OAuth, nil, "") flowState.AuthCode = authCode require.NoError(ts.T(), ts.API.db.Create(flowState)) cases := []struct { diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go index 82d16c88c..87f9b5fc9 100644 --- a/internal/api/verify_test.go +++ b/internal/api/verify_test.go @@ -752,7 +752,7 @@ func (ts *VerifyTestSuite) TestVerifyPKCEOTP() { require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload)) codeChallenge := "codechallengecodechallengcodechallengcodechallengcodechallenge" - flowState := models.NewFlowState(c.authenticationMethod.String(), codeChallenge, models.SHA256, c.authenticationMethod, &u.ID) + flowState := models.NewFlowState(c.authenticationMethod.String(), codeChallenge, models.SHA256, c.authenticationMethod, &u.ID, "") require.NoError(ts.T(), ts.API.db.Create(flowState)) requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v", c.payload.Type, c.payload.Token) From 0b3bc165f19bde4d28b8452128b60e7e71bfa8ec Mon Sep 17 00:00:00 2001 From: Cemal Kilic Date: Wed, 29 Oct 2025 12:12:35 +0300 Subject: [PATCH 3/4] fix: fallback for code challenge method --- internal/api/external.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/api/external.go b/internal/api/external.go index 51df231ab..66a3d847b 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -99,6 +99,12 @@ func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Requ // Create FlowState if user is using PKCE OR provider requires PKCE // This ensures we can store provider's code_verifier even for implicit flows if isPKCEFlow(flowType) || providerRequiresPKCE { + // a bit hacky but we have a db constraint on code challenge method, + // so we default to s256 if the provider requires PKCE and the code challenge method is not provided + if providerRequiresPKCE && codeChallengeMethod == "" { + codeChallengeMethod = "s256" + } + flowState, err = generateFlowState(db, providerType, models.OAuth, codeChallengeMethod, codeChallenge, nil, providerCodeVerifier) if err != nil { return "", err From 57eb141a5c23e6524af577ca1839463ecf2eee2a Mon Sep 17 00:00:00 2001 From: Cemal Kilic Date: Fri, 7 Nov 2025 16:38:55 +0300 Subject: [PATCH 4/4] feat(oidc): update as supabase auth supports for oidc --- internal/api/provider/oidc.go | 9 +-- internal/api/provider/provider.go | 39 +++++++++-- internal/api/provider/supabase.go | 112 ++++++------------------------ 3 files changed, 60 insertions(+), 100 deletions(-) diff --git a/internal/api/provider/oidc.go b/internal/api/provider/oidc.go index 7dd976780..ce2526536 100644 --- a/internal/api/provider/oidc.go +++ b/internal/api/provider/oidc.go @@ -2,6 +2,7 @@ package provider import ( "context" + "fmt" "strconv" "strings" "time" @@ -46,7 +47,7 @@ func ParseIDToken(ctx context.Context, provider *oidc.Provider, config *oidc.Con token, err := verifier.Verify(ctx, idToken) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("error verifying id token: %w", err) } var data *UserProvidedData @@ -76,12 +77,12 @@ func ParseIDToken(ctx context.Context, provider *oidc.Provider, config *oidc.Con } if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("error parsing id token: %w", err) } if !options.SkipAccessTokenCheck && token.AccessTokenHash != "" { if err := token.VerifyAccessToken(options.AccessToken); err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("error verifying access token: %w", err) } } @@ -446,7 +447,7 @@ func parseGenericIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, var data UserProvidedData if err := token.Claims(&data.Metadata); err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("error parsing generic id token: %w", err) } if data.Metadata.Email != "" { diff --git a/internal/api/provider/provider.go b/internal/api/provider/provider.go index e2d187c2a..3048e1867 100644 --- a/internal/api/provider/provider.go +++ b/internal/api/provider/provider.go @@ -43,6 +43,31 @@ func (a *audience) UnmarshalJSON(b []byte) error { return nil } +// UpdatedAt is a flexible type that can unmarshal both string and numeric timestamps +type UpdatedAt string + +func (u *UpdatedAt) UnmarshalJSON(b []byte) error { + // Try to unmarshal as string first + var strVal string + if err := json.Unmarshal(b, &strVal); err == nil { + *u = UpdatedAt(strVal) + return nil + } + + // Try to unmarshal as number (Unix timestamp) + var numVal float64 + if err := json.Unmarshal(b, &numVal); err == nil { + // Convert Unix timestamp to string + t := time.Unix(int64(numVal), 0).UTC() + *u = UpdatedAt(t.Format(time.RFC3339)) + return nil + } + + // If neither works, return empty + *u = "" + return nil +} + type Claims struct { // Reserved claims Issuer string `json:"iss,omitempty" structs:"iss,omitempty"` @@ -60,13 +85,13 @@ type Claims struct { PreferredUsername string `json:"preferred_username,omitempty" structs:"preferred_username,omitempty"` Profile string `json:"profile,omitempty" structs:"profile,omitempty"` Picture string `json:"picture,omitempty" structs:"picture,omitempty"` - Website string `json:"website,omitempty" structs:"website,omitempty"` - Gender string `json:"gender,omitempty" structs:"gender,omitempty"` - Birthdate string `json:"birthdate,omitempty" structs:"birthdate,omitempty"` - ZoneInfo string `json:"zoneinfo,omitempty" structs:"zoneinfo,omitempty"` - Locale string `json:"locale,omitempty" structs:"locale,omitempty"` - UpdatedAt string `json:"updated_at,omitempty" structs:"updated_at,omitempty"` - Email string `json:"email,omitempty" structs:"email,omitempty"` + Website string `json:"website,omitempty" structs:"website,omitempty"` + Gender string `json:"gender,omitempty" structs:"gender,omitempty"` + Birthdate string `json:"birthdate,omitempty" structs:"birthdate,omitempty"` + ZoneInfo string `json:"zoneinfo,omitempty" structs:"zoneinfo,omitempty"` + Locale string `json:"locale,omitempty" structs:"locale,omitempty"` + UpdatedAt UpdatedAt `json:"updated_at,omitempty" structs:"updated_at,omitempty"` + Email string `json:"email,omitempty" structs:"email,omitempty"` EmailVerified bool `json:"email_verified,omitempty" structs:"email_verified"` Phone string `json:"phone,omitempty" structs:"phone,omitempty"` PhoneVerified bool `json:"phone_verified,omitempty" structs:"phone_verified"` diff --git a/internal/api/provider/supabase.go b/internal/api/provider/supabase.go index 4cb607108..607b7f2b0 100644 --- a/internal/api/provider/supabase.go +++ b/internal/api/provider/supabase.go @@ -2,11 +2,10 @@ package provider import ( "context" - "encoding/base64" - "encoding/json" "errors" "strings" + "github.com/coreos/go-oidc/v3/oidc" "github.com/supabase/auth/internal/conf" "golang.org/x/oauth2" ) @@ -14,9 +13,10 @@ import ( // SupabaseProvider represents a Supabase OAuth provider type SupabaseProvider struct { *oauth2.Config + oidc *oidc.Provider } -// NewSupabaseProvider creates a Supabase OAuth2 provider. +// NewSupabaseProvider creates a Supabase OAuth2 provider with OIDC discovery. func NewSupabaseProvider(ctx context.Context, ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { if err := ext.ValidateOAuth(); err != nil { return nil, err @@ -33,22 +33,21 @@ func NewSupabaseProvider(ctx context.Context, ext conf.OAuthProviderConfiguratio oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) } - // Supabase Auth OAuth endpoints - // TODO(cemal) :: update to use oidc.NewProvider when supabase auth supports oidc - authURL := baseURL + "/oauth/authorize" - tokenURL := baseURL + "/oauth/token" + // Use OIDC discovery to automatically find the authorization and token endpoints + oidcProvider, err := oidc.NewProvider(ctx, baseURL) + if err != nil { + return nil, err + } return &SupabaseProvider{ Config: &oauth2.Config{ ClientID: ext.ClientID[0], ClientSecret: ext.Secret, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: oauthScopes, - RedirectURL: ext.RedirectURI, + Endpoint: oidcProvider.Endpoint(), + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, }, + oidc: oidcProvider, }, nil } @@ -57,64 +56,18 @@ func (p SupabaseProvider) GetOAuthToken(code string, opts ...oauth2.AuthCodeOpti } func (p SupabaseProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { - // TEMP(cemal): Supabase Auth doesn't expose a userinfo endpoint yet. - // Until then, decode claims from the freshly issued OAuth token without signature verification. - - data := &UserProvidedData{} - data.Metadata = &Claims{} - claimsMap, err := decodeJWTClaims(tok.AccessToken) - if err == nil { - // Extract email and email_verified from claims - email, _ := claimsMap["email"].(string) - - // email_verified may live under user_metadata.email_verified - emailVerified := false - if uv, ok := claimsMap["email_verified"].(bool); ok { - emailVerified = uv - } else if um, ok := claimsMap["user_metadata"].(map[string]interface{}); ok { - if uv2, ok2 := um["email_verified"].(bool); ok2 { - emailVerified = uv2 - } - } - - if email != "" { - data.Emails = []Email{{ - Email: email, - Verified: emailVerified, - Primary: true, - }} - } - - if iss, ok := claimsMap["iss"].(string); ok { - data.Metadata.Issuer = iss - } - if sub, ok := claimsMap["sub"].(string); ok { - data.Metadata.Subject = sub - // To be deprecated - data.Metadata.ProviderId = sub - } - if aud, ok := claimsMap["aud"].(string); ok { - data.Metadata.Aud = audience{aud} - } - - data.Metadata.Email = email - data.Metadata.EmailVerified = emailVerified - - // Carry through app_metadata and user_metadata as custom claims - customClaims := make(map[string]interface{}) - if am, ok := claimsMap["app_metadata"].(map[string]interface{}); ok && len(am) > 0 { - customClaims["app_metadata"] = am - } - if um, ok := claimsMap["user_metadata"].(map[string]interface{}); ok && len(um) > 0 { - customClaims["user_metadata"] = um - } - if clientId, ok := claimsMap["client_id"].(string); ok { - customClaims["client_id"] = clientId - } + idToken := tok.Extra("id_token") + if tok.AccessToken == "" || idToken == nil { + return &UserProvidedData{}, nil + } - if len(customClaims) > 0 { - data.Metadata.CustomClaims = customClaims - } + _, data, err := ParseIDToken(ctx, p.oidc, &oidc.Config{ + ClientID: p.ClientID, + }, idToken.(string), ParseIDTokenOptions{ + AccessToken: tok.AccessToken, + }) + if err != nil { + return nil, err } return data, nil @@ -124,22 +77,3 @@ func (p SupabaseProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (* func (p *SupabaseProvider) RequiresPKCE() bool { return true } - -// decodeJWTClaims decodes the claims section of a JWT without verifying the signature. -// This is safe here because the token was just issued by authorization code flow. -func decodeJWTClaims(token string) (map[string]interface{}, error) { - parts := strings.Split(token, ".") - if len(parts) < 2 { - return nil, errors.New("invalid jwt format") - } - payloadSegment := parts[1] - decoded, err := base64.RawURLEncoding.DecodeString(payloadSegment) - if err != nil { - return nil, err - } - var m map[string]interface{} - if err := json.Unmarshal(decoded, &m); err != nil { - return nil, err - } - return m, nil -}