From 39ea58a142776aeb1ff7a69e6f4cc7acea8d4baf Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 3 Jul 2026 10:07:20 +0200 Subject: [PATCH] feat(token): add access_token grant for signing in with a provider access token Facebook's native Android login reliably returns a classic Graph access token on every login, but only mints an OIDC id token (AuthenticationToken) on the first authorization, which makes the id_token grant unusable for repeat native logins without falling back to the browser flow. Add an access_token grant that accepts a provider-issued access token, verifies via Facebook's /debug_token that it was issued for this app, is valid, and is a user token (mitigating access token substitution), fetches the profile and issues a session. The grant is only available to providers that implement the new AccessTokenVerifier interface, which for now is Facebook only. --- internal/api/helpers.go | 1 + internal/api/provider/facebook.go | 67 ++++++++++- internal/api/provider/provider.go | 7 ++ internal/api/token.go | 2 + internal/api/token_access_token.go | 131 +++++++++++++++++++++ internal/api/token_access_token_test.go | 148 ++++++++++++++++++++++++ 6 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 internal/api/token_access_token.go create mode 100644 internal/api/token_access_token_test.go diff --git a/internal/api/helpers.go b/internal/api/helpers.go index a4a8458402..4d9696cf7f 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -51,6 +51,7 @@ type RequestParams interface { CreateSSOProviderParams | EnrollFactorParams | GenerateLinkParams | + AccessTokenGrantParams | IdTokenGrantParams | InviteParams | OtpParams | diff --git a/internal/api/provider/facebook.go b/internal/api/provider/facebook.go index 5940cf57cf..1a7ac996e4 100644 --- a/internal/api/provider/facebook.go +++ b/internal/api/provider/facebook.go @@ -5,6 +5,9 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "errors" + "fmt" + "net/url" "strings" "github.com/supabase/auth/internal/conf" @@ -21,7 +24,21 @@ const ( type facebookProvider struct { *oauth2.Config - ProfileURL string + ProfileURL string + DebugTokenURL string +} + +type facebookDebugToken struct { + Data struct { + AppID string `json:"app_id"` + Type string `json:"type"` + IsValid bool `json:"is_valid"` + UserID string `json:"user_id"` + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } `json:"data"` } type facebookUser struct { @@ -45,7 +62,9 @@ func NewFacebookProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA authHost := chooseHost(ext.URL, defaultFacebookAuthBase) tokenHost := chooseHost(ext.URL, defaultFacebookTokenBase) - profileURL := chooseHost(ext.URL, defaultFacebookAPIBase) + "/me?fields=email,first_name,last_name,name,picture" + apiHost := chooseHost(ext.URL, defaultFacebookAPIBase) + profileURL := apiHost + "/me?fields=email,first_name,last_name,name,picture" + debugTokenURL := apiHost + "/debug_token" oauthScopes := []string{ "email", @@ -66,10 +85,52 @@ func NewFacebookProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA }, Scopes: oauthScopes, }, - ProfileURL: profileURL, + ProfileURL: profileURL, + DebugTokenURL: debugTokenURL, }, nil } +// VerifyAccessToken confirms that the given access token was issued for this +// Facebook app and is still valid. This is required when signing in with an +// access token obtained on the client (for example a native Android login), +// to mitigate access token substitution where a token minted for another app +// could otherwise be replayed against this one. +func (p facebookProvider) VerifyAccessToken(ctx context.Context, accessToken string) error { + // The app access token authenticates the /debug_token call. It is sent as a + // bearer token rather than a query parameter so the client secret is not + // captured by URL loggers. + appAccessToken := p.Config.ClientID + "|" + p.Config.ClientSecret + + query := url.Values{} + query.Set("input_token", accessToken) + requestURL := p.DebugTokenURL + "?" + query.Encode() + + var debugToken facebookDebugToken + if err := makeRequest(ctx, &oauth2.Token{AccessToken: appAccessToken}, p.Config, requestURL, &debugToken); err != nil { + // /debug_token requires input_token in the query string, so strip the URL + // from transport errors to avoid leaking the access token into logs. + var urlErr *url.Error + if errors.As(err, &urlErr) { + return fmt.Errorf("facebook: could not reach the token debug endpoint: %w", urlErr.Err) + } + return err + } + + if !debugToken.Data.IsValid { + return fmt.Errorf("facebook: access token is not valid: %s", debugToken.Data.Error.Message) + } + + if debugToken.Data.AppID != p.Config.ClientID { + return fmt.Errorf("facebook: access token was not issued for this app") + } + + if debugToken.Data.Type != "USER" { + return fmt.Errorf("facebook: access token is not a user token (type=%q)", debugToken.Data.Type) + } + + return nil +} + func (p facebookProvider) GetOAuthToken(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { return p.Exchange(ctx, code, opts...) } diff --git a/internal/api/provider/provider.go b/internal/api/provider/provider.go index c3ab0781f2..5634daab44 100644 --- a/internal/api/provider/provider.go +++ b/internal/api/provider/provider.go @@ -148,6 +148,13 @@ type OAuthProvider interface { RequiresPKCE() bool } +// AccessTokenVerifier is implemented by OAuth providers that can verify a +// client-provided access token was issued for this app before it is exchanged +// for a session through the access_token grant. +type AccessTokenVerifier interface { + VerifyAccessToken(ctx context.Context, accessToken string) error +} + func chooseHost(base, defaultHost string) string { if base == "" { return "https://" + defaultHost diff --git a/internal/api/token.go b/internal/api/token.go index 9ef0ae727a..10a6df996b 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -51,6 +51,8 @@ func (a *API) Token(w http.ResponseWriter, r *http.Request) error { handler = a.RefreshTokenGrant case "id_token": handler = a.IdTokenGrant + case "access_token": + handler = a.AccessTokenGrant case "pkce": handler = a.PKCE case "web3": diff --git a/internal/api/token_access_token.go b/internal/api/token_access_token.go new file mode 100644 index 0000000000..0f693dd530 --- /dev/null +++ b/internal/api/token_access_token.go @@ -0,0 +1,131 @@ +package api + +import ( + "context" + "net/http" + + "golang.org/x/oauth2" + + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/metering" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" +) + +// AccessTokenGrantParams are the parameters the AccessTokenGrant method accepts +type AccessTokenGrantParams struct { + Provider string `json:"provider"` + AccessToken string `json:"access_token"` +} + +// AccessTokenGrant implements the access_token grant type flow, which allows +// signing in with a provider issued OAuth access token instead of an OIDC id +// token. +// +// It exists mainly for native Facebook logins on Android: the Facebook SDK +// reliably returns a classic Graph access token on every login, but only mints +// an OIDC id token (AuthenticationToken) on the first authorization, which +// makes the id_token grant unusable for repeat logins without falling back to +// the browser flow. +func (a *API) AccessTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + db := a.db.WithContext(ctx) + + params := &AccessTokenGrantParams{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + + if params.AccessToken == "" { + return apierrors.NewOAuthError("invalid request", "access_token required") + } + + if params.Provider == "" { + return apierrors.NewOAuthError("invalid request", "provider required") + } + + oauthProvider, pConfig, err := a.OAuthProvider(ctx, params.Provider) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeOAuthProviderNotSupported, "Unsupported provider: %q", params.Provider).WithInternalError(err) + } + + if !pConfig.Enabled { + return apierrors.NewBadRequestError(apierrors.ErrorCodeProviderDisabled, "Provider (%q) is not enabled", params.Provider) + } + + // Verifying that the access token was issued for this app is provider + // specific, so the grant is only available to providers that opt in. + verifier, ok := oauthProvider.(provider.AccessTokenVerifier) + if !ok { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "access_token grant is not supported for the %q provider", params.Provider) + } + + if err := verifier.VerifyAccessToken(ctx, params.AccessToken); err != nil { + return apierrors.NewOAuthError("invalid request", "Invalid access token").WithInternalError(err) + } + + userData, err := oauthProvider.GetUserData(ctx, &oauth2.Token{AccessToken: params.AccessToken}) + if err != nil { + return apierrors.NewOAuthError("invalid request", "Unable to fetch user data with the provided access token").WithInternalError(err) + } + + userData.Metadata.EmailVerified = false + for _, email := range userData.Emails { + if email.Primary { + userData.Metadata.Email = email.Email + userData.Metadata.EmailVerified = email.Verified + break + } else { + userData.Metadata.Email = email.Email + userData.Metadata.EmailVerified = email.Verified + } + } + + var grantParams models.GrantParams + grantParams.FillGrantParams(r) + + if err := a.triggerBeforeUserCreatedExternal(r, db, userData, params.Provider); err != nil { + return err + } + + var createdUser bool + var token *AccessTokenResponse + var user *models.User + if err := db.Transaction(func(tx *storage.Connection) error { + var terr error + + var decision models.AccountLinkingDecision + decision, user, terr = a.createAccountFromExternalIdentity(tx, r, userData, params.Provider, pConfig.EmailOptional) + if terr != nil { + return terr + } + createdUser = decision == models.CreateAccount + + token, terr = a.issueRefreshToken(r, w.Header(), tx, user, models.OAuth, grantParams) + if terr != nil { + return terr + } + + return nil + }); err != nil { + switch err.(type) { + case *storage.CommitWithError: + return err + case *HTTPError: + return err + default: + return apierrors.NewOAuthError("server_error", "Internal Server Error").WithInternalError(err) + } + } + if createdUser { + if err := a.triggerAfterUserCreated(r, db, user); err != nil { + return err + } + } + + metering.RecordLogin(metering.LoginTypeOAuth, token.User.ID, &metering.LoginData{ + Provider: params.Provider, + }) + + return sendJSON(w, http.StatusOK, token) +} diff --git a/internal/api/token_access_token_test.go b/internal/api/token_access_token_test.go new file mode 100644 index 0000000000..dd20c0f1f9 --- /dev/null +++ b/internal/api/token_access_token_test.go @@ -0,0 +1,148 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" +) + +// FacebookAccessTokenSetup spins up a mock Graph API that answers the +// /debug_token and /me calls used by the access_token grant. appID controls the +// app_id returned by /debug_token, isValid controls whether the token is +// reported as valid, and tokenType controls the token type (USER, PAGE, APP). +func FacebookAccessTokenSetup(ts *ExternalTestSuite, appID string, isValid bool, tokenType string, user string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/debug_token": + expectedAppToken := ts.Config.External.Facebook.ClientID[0] + "|" + ts.Config.External.Facebook.Secret + ts.Equal("Bearer "+expectedAppToken, r.Header.Get("Authorization")) + ts.NotEmpty(r.URL.Query().Get("input_token")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprintf(w, `{"data":{"app_id":"%s","type":"%s","is_valid":%t,"user_id":"facebookTestId"}}`, appID, tokenType, isValid) + case "/me": + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, user) + default: + w.WriteHeader(http.StatusInternalServerError) + ts.Fail("unknown facebook graph call %s", r.URL.Path) + } + })) + + ts.Config.External.Facebook.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) accessTokenGrant(provider, accessToken string) *httptest.ResponseRecorder { + var buffer bytes.Buffer + ts.Require().NoError(json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "provider": provider, + "access_token": accessToken, + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=access_token", &buffer) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + return w +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantFacebookSuccess() { + server := FacebookAccessTokenSetup(ts, ts.Config.External.Facebook.ClientID[0], true, "USER", facebookUser) + defer server.Close() + + w := ts.accessTokenGrant("facebook", "valid_access_token") + ts.Require().Equal(http.StatusOK, w.Code, w.Body.String()) + + var response AccessTokenResponse + ts.Require().NoError(json.NewDecoder(w.Body).Decode(&response)) + ts.Require().NotEmpty(response.Token) + ts.Require().NotEmpty(response.RefreshToken) + ts.Require().NotNil(response.User) + ts.Equal("facebook@example.com", response.User.GetEmail()) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantFacebookExistingUserSignsIn() { + // Pre-create the user so this is a sign-in, not a signup. + existing, err := ts.createUser("facebookTestId", "facebook@example.com", "Facebook Test", "http://example.com/avatar", "") + ts.Require().NoError(err) + + server := FacebookAccessTokenSetup(ts, ts.Config.External.Facebook.ClientID[0], true, "USER", facebookUser) + defer server.Close() + + w := ts.accessTokenGrant("facebook", "valid_access_token") + ts.Require().Equal(http.StatusOK, w.Code, w.Body.String()) + + var response AccessTokenResponse + ts.Require().NoError(json.NewDecoder(w.Body).Decode(&response)) + ts.Require().NotNil(response.User) + // Signing in with an already-existing account returns that same user. + ts.Equal(existing.ID, response.User.ID) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantFacebookRejectsTokenFromAnotherApp() { + server := FacebookAccessTokenSetup(ts, "some_other_app_id", true, "USER", facebookUser) + defer server.Close() + + w := ts.accessTokenGrant("facebook", "token_for_another_app") + ts.Require().Equal(http.StatusBadRequest, w.Code) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantFacebookRejectsInvalidToken() { + server := FacebookAccessTokenSetup(ts, ts.Config.External.Facebook.ClientID[0], false, "USER", facebookUser) + defer server.Close() + + w := ts.accessTokenGrant("facebook", "invalid_token") + ts.Require().Equal(http.StatusBadRequest, w.Code) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantFacebookRejectsNonUserToken() { + // An app or page token of this same app passes the app_id check, so the + // token type must also be verified. + server := FacebookAccessTokenSetup(ts, ts.Config.External.Facebook.ClientID[0], true, "PAGE", facebookUser) + defer server.Close() + + w := ts.accessTokenGrant("facebook", "page_token") + ts.Require().Equal(http.StatusBadRequest, w.Code) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantMissingAccessToken() { + w := ts.accessTokenGrant("facebook", "") + ts.Require().Equal(http.StatusBadRequest, w.Code) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantMissingProvider() { + w := ts.accessTokenGrant("", "some_token") + ts.Require().Equal(http.StatusBadRequest, w.Code) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantUnsupportedProvider() { + // google is enabled but does not implement AccessTokenVerifier + ts.Config.External.Google.Enabled = true + ts.Config.External.Google.ClientID = []string{"googleclientid"} + ts.Config.External.Google.Secret = "googlesecret" + + w := ts.accessTokenGrant("google", "some_google_token") + ts.Require().Equal(http.StatusBadRequest, w.Code) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantProviderDisabled() { + ts.Config.External.Facebook.Enabled = false + defer func() { ts.Config.External.Facebook.Enabled = true }() + + w := ts.accessTokenGrant("facebook", "some_token") + ts.Require().Equal(http.StatusBadRequest, w.Code) +} + +func (ts *ExternalTestSuite) TestAccessTokenGrantSignupDisabled() { + ts.Config.DisableSignup = true + + server := FacebookAccessTokenSetup(ts, ts.Config.External.Facebook.ClientID[0], true, "USER", facebookUser) + defer server.Close() + + w := ts.accessTokenGrant("facebook", "valid_access_token") + ts.Require().Equal(http.StatusUnprocessableEntity, w.Code) +}