Skip to content

Commit 16f15c6

Browse files
committed
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.
1 parent 169ad67 commit 16f15c6

6 files changed

Lines changed: 353 additions & 3 deletions

File tree

internal/api/helpers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ type RequestParams interface {
5151
CreateSSOProviderParams |
5252
EnrollFactorParams |
5353
GenerateLinkParams |
54+
AccessTokenGrantParams |
5455
IdTokenGrantParams |
5556
InviteParams |
5657
OtpParams |

internal/api/provider/facebook.go

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import (
55
"crypto/hmac"
66
"crypto/sha256"
77
"encoding/hex"
8+
"errors"
9+
"fmt"
10+
"net/url"
811
"strings"
912

1013
"github.com/supabase/auth/internal/conf"
@@ -21,7 +24,21 @@ const (
2124

2225
type facebookProvider struct {
2326
*oauth2.Config
24-
ProfileURL string
27+
ProfileURL string
28+
DebugTokenURL string
29+
}
30+
31+
type facebookDebugToken struct {
32+
Data struct {
33+
AppID string `json:"app_id"`
34+
Type string `json:"type"`
35+
IsValid bool `json:"is_valid"`
36+
UserID string `json:"user_id"`
37+
Error struct {
38+
Code int `json:"code"`
39+
Message string `json:"message"`
40+
} `json:"error"`
41+
} `json:"data"`
2542
}
2643

2744
type facebookUser struct {
@@ -45,7 +62,9 @@ func NewFacebookProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA
4562

4663
authHost := chooseHost(ext.URL, defaultFacebookAuthBase)
4764
tokenHost := chooseHost(ext.URL, defaultFacebookTokenBase)
48-
profileURL := chooseHost(ext.URL, defaultFacebookAPIBase) + "/me?fields=email,first_name,last_name,name,picture"
65+
apiHost := chooseHost(ext.URL, defaultFacebookAPIBase)
66+
profileURL := apiHost + "/me?fields=email,first_name,last_name,name,picture"
67+
debugTokenURL := apiHost + "/debug_token"
4968

5069
oauthScopes := []string{
5170
"email",
@@ -66,10 +85,52 @@ func NewFacebookProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA
6685
},
6786
Scopes: oauthScopes,
6887
},
69-
ProfileURL: profileURL,
88+
ProfileURL: profileURL,
89+
DebugTokenURL: debugTokenURL,
7090
}, nil
7191
}
7292

93+
// VerifyAccessToken confirms that the given access token was issued for this
94+
// Facebook app and is still valid. This is required when signing in with an
95+
// access token obtained on the client (for example a native Android login),
96+
// to mitigate access token substitution where a token minted for another app
97+
// could otherwise be replayed against this one.
98+
func (p facebookProvider) VerifyAccessToken(ctx context.Context, accessToken string) error {
99+
// The app access token authenticates the /debug_token call. It is sent as a
100+
// bearer token rather than a query parameter so the client secret is not
101+
// captured by URL loggers.
102+
appAccessToken := p.Config.ClientID + "|" + p.Config.ClientSecret
103+
104+
query := url.Values{}
105+
query.Set("input_token", accessToken)
106+
requestURL := p.DebugTokenURL + "?" + query.Encode()
107+
108+
var debugToken facebookDebugToken
109+
if err := makeRequest(ctx, &oauth2.Token{AccessToken: appAccessToken}, p.Config, requestURL, &debugToken); err != nil {
110+
// /debug_token requires input_token in the query string, so strip the URL
111+
// from transport errors to avoid leaking the access token into logs.
112+
var urlErr *url.Error
113+
if errors.As(err, &urlErr) {
114+
return fmt.Errorf("facebook: could not reach the token debug endpoint: %w", urlErr.Err)
115+
}
116+
return err
117+
}
118+
119+
if !debugToken.Data.IsValid {
120+
return fmt.Errorf("facebook: access token is not valid: %s", debugToken.Data.Error.Message)
121+
}
122+
123+
if debugToken.Data.AppID != p.Config.ClientID {
124+
return fmt.Errorf("facebook: access token was not issued for this app")
125+
}
126+
127+
if debugToken.Data.Type != "USER" {
128+
return fmt.Errorf("facebook: access token is not a user token (type=%q)", debugToken.Data.Type)
129+
}
130+
131+
return nil
132+
}
133+
73134
func (p facebookProvider) GetOAuthToken(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
74135
return p.Exchange(ctx, code, opts...)
75136
}

internal/api/provider/provider.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,13 @@ type OAuthProvider interface {
148148
RequiresPKCE() bool
149149
}
150150

151+
// AccessTokenVerifier is implemented by OAuth providers that can verify a
152+
// client-provided access token was issued for this app before it is exchanged
153+
// for a session through the access_token grant.
154+
type AccessTokenVerifier interface {
155+
VerifyAccessToken(ctx context.Context, accessToken string) error
156+
}
157+
151158
func chooseHost(base, defaultHost string) string {
152159
if base == "" {
153160
return "https://" + defaultHost

internal/api/token.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ func (a *API) Token(w http.ResponseWriter, r *http.Request) error {
5151
handler = a.RefreshTokenGrant
5252
case "id_token":
5353
handler = a.IdTokenGrant
54+
case "access_token":
55+
handler = a.AccessTokenGrant
5456
case "pkce":
5557
handler = a.PKCE
5658
case "web3":

internal/api/token_access_token.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"golang.org/x/oauth2"
8+
9+
"github.com/supabase/auth/internal/api/apierrors"
10+
"github.com/supabase/auth/internal/api/provider"
11+
"github.com/supabase/auth/internal/metering"
12+
"github.com/supabase/auth/internal/models"
13+
"github.com/supabase/auth/internal/storage"
14+
)
15+
16+
// AccessTokenGrantParams are the parameters the AccessTokenGrant method accepts
17+
type AccessTokenGrantParams struct {
18+
Provider string `json:"provider"`
19+
AccessToken string `json:"access_token"`
20+
}
21+
22+
// AccessTokenGrant implements the access_token grant type flow, which allows
23+
// signing in with a provider issued OAuth access token instead of an OIDC id
24+
// token.
25+
//
26+
// It exists mainly for native Facebook logins on Android: the Facebook SDK
27+
// reliably returns a classic Graph access token on every login, but only mints
28+
// an OIDC id token (AuthenticationToken) on the first authorization, which
29+
// makes the id_token grant unusable for repeat logins without falling back to
30+
// the browser flow.
31+
func (a *API) AccessTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
32+
db := a.db.WithContext(ctx)
33+
34+
params := &AccessTokenGrantParams{}
35+
if err := retrieveRequestParams(r, params); err != nil {
36+
return err
37+
}
38+
39+
if params.AccessToken == "" {
40+
return apierrors.NewOAuthError("invalid request", "access_token required")
41+
}
42+
43+
if params.Provider == "" {
44+
return apierrors.NewOAuthError("invalid request", "provider required")
45+
}
46+
47+
oauthProvider, pConfig, err := a.OAuthProvider(ctx, params.Provider)
48+
if err != nil {
49+
return apierrors.NewBadRequestError(apierrors.ErrorCodeOAuthProviderNotSupported, "Unsupported provider: %q", params.Provider).WithInternalError(err)
50+
}
51+
52+
if !pConfig.Enabled {
53+
return apierrors.NewBadRequestError(apierrors.ErrorCodeProviderDisabled, "Provider (%q) is not enabled", params.Provider)
54+
}
55+
56+
// Verifying that the access token was issued for this app is provider
57+
// specific, so the grant is only available to providers that opt in.
58+
verifier, ok := oauthProvider.(provider.AccessTokenVerifier)
59+
if !ok {
60+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "access_token grant is not supported for the %q provider", params.Provider)
61+
}
62+
63+
if err := verifier.VerifyAccessToken(ctx, params.AccessToken); err != nil {
64+
return apierrors.NewOAuthError("invalid request", "Invalid access token").WithInternalError(err)
65+
}
66+
67+
userData, err := oauthProvider.GetUserData(ctx, &oauth2.Token{AccessToken: params.AccessToken})
68+
if err != nil {
69+
return apierrors.NewOAuthError("invalid request", "Unable to fetch user data with the provided access token").WithInternalError(err)
70+
}
71+
72+
userData.Metadata.EmailVerified = false
73+
for _, email := range userData.Emails {
74+
if email.Primary {
75+
userData.Metadata.Email = email.Email
76+
userData.Metadata.EmailVerified = email.Verified
77+
break
78+
} else {
79+
userData.Metadata.Email = email.Email
80+
userData.Metadata.EmailVerified = email.Verified
81+
}
82+
}
83+
84+
var grantParams models.GrantParams
85+
grantParams.FillGrantParams(r)
86+
87+
if err := a.triggerBeforeUserCreatedExternal(r, db, userData, params.Provider); err != nil {
88+
return err
89+
}
90+
91+
var createdUser bool
92+
var token *AccessTokenResponse
93+
var user *models.User
94+
if err := db.Transaction(func(tx *storage.Connection) error {
95+
var terr error
96+
97+
var decision models.AccountLinkingDecision
98+
decision, user, terr = a.createAccountFromExternalIdentity(tx, r, userData, params.Provider, pConfig.EmailOptional)
99+
if terr != nil {
100+
return terr
101+
}
102+
createdUser = decision == models.CreateAccount
103+
104+
token, terr = a.issueRefreshToken(r, w.Header(), tx, user, models.OAuth, grantParams)
105+
if terr != nil {
106+
return terr
107+
}
108+
109+
return nil
110+
}); err != nil {
111+
switch err.(type) {
112+
case *storage.CommitWithError:
113+
return err
114+
case *HTTPError:
115+
return err
116+
default:
117+
return apierrors.NewOAuthError("server_error", "Internal Server Error").WithInternalError(err)
118+
}
119+
}
120+
if createdUser {
121+
if err := a.triggerAfterUserCreated(r, db, user); err != nil {
122+
return err
123+
}
124+
}
125+
126+
metering.RecordLogin(metering.LoginTypeOAuth, token.User.ID, &metering.LoginData{
127+
Provider: params.Provider,
128+
})
129+
130+
return sendJSON(w, http.StatusOK, token)
131+
}

0 commit comments

Comments
 (0)