Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type RequestParams interface {
CreateSSOProviderParams |
EnrollFactorParams |
GenerateLinkParams |
AccessTokenGrantParams |
IdTokenGrantParams |
InviteParams |
OtpParams |
Expand Down
67 changes: 64 additions & 3 deletions internal/api/provider/facebook.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"

"github.com/supabase/auth/internal/conf"
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -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
Comment thread
spydon marked this conversation as resolved.

query := url.Values{}
query.Set("input_token", accessToken)
Comment thread
spydon marked this conversation as resolved.
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...)
}
Expand Down
7 changes: 7 additions & 0 deletions internal/api/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/api/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
131 changes: 131 additions & 0 deletions internal/api/token_access_token.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading