From 1a899db58281529aaad00fee008578f4dcb68fb8 Mon Sep 17 00:00:00 2001 From: RWayne93 Date: Sat, 4 Oct 2025 16:58:16 -0400 Subject: [PATCH] feat: add passkey support --- docs/passkey.go | 54 ++ example.env | 4 + internal/api/admin.go | 22 + internal/api/api.go | 16 + internal/api/helpers.go | 4 + internal/api/mfa.go | 6 +- internal/api/options.go | 12 + internal/api/passkey.go | 501 ++++++++++++++++++ internal/api/provider_constants.go | 5 +- internal/conf/configuration.go | 7 + internal/metering/record.go | 2 + internal/models/errors.go | 8 + internal/models/factor.go | 42 +- internal/models/passkey_challenge.go | 67 +++ internal/models/user.go | 40 +- .../20250216000000_add_passkey_support.up.sql | 19 + openapi.yaml | 312 +++++++++++ 17 files changed, 1116 insertions(+), 5 deletions(-) create mode 100644 docs/passkey.go create mode 100644 internal/api/passkey.go create mode 100644 internal/models/passkey_challenge.go create mode 100644 migrations/20250216000000_add_passkey_support.up.sql diff --git a/docs/passkey.go b/docs/passkey.go new file mode 100644 index 000000000..8182a93ac --- /dev/null +++ b/docs/passkey.go @@ -0,0 +1,54 @@ +//lint:file-ignore U1000 ignore go-swagger template +package docs + +import ( + "github.com/supabase/auth/internal/api" +) + +// swagger:route POST /passkeys/sign-in user passkeySignIn +// Begin a passkey sign-in challenge. +// responses: +// 200: PasskeySignInChallengeResponse +// 400: BadRequestResponse +// 429: RateLimitResponse + +type passkeySignInParamsWrapper struct { + // in:body + Body api.PasskeySignInRequest +} + +// swagger:route POST /passkeys/sign-in/verify user passkeySignInVerify +// Complete a passkey sign-in challenge. +// responses: +// 200: AccessTokenResponseSchema +// 400: BadRequestResponse +// 429: RateLimitResponse + +type passkeySignInVerifyParamsWrapper struct { + // in:body + Body api.PasskeySignInVerifyRequest +} + +// swagger:route POST /passkeys user passkeyRegister +// Begin passkey registration for the authenticated user. +// responses: +// 200: PasskeyRegistrationResponse +// 400: BadRequestResponse +// 429: RateLimitResponse + +type passkeyRegisterParamsWrapper struct { + // in:body + Body api.PasskeyRegistrationRequest +} + +// swagger:route POST /passkeys/{passkey_id}/verify user passkeyVerify +// Verify a passkey registration challenge. +// responses: +// 200: PasskeyVerifyResponse +// 400: BadRequestResponse +// 429: RateLimitResponse + +type passkeyVerifyParamsWrapper struct { + // in:body + Body api.PasskeyVerifyRequest +} diff --git a/example.env b/example.env index 190d45b1b..5cf4b9d6d 100644 --- a/example.env +++ b/example.env @@ -262,3 +262,7 @@ GOTRUE_SMS_TEST_OTP_VALID_UNTIL="" # (e.g. 2023-09-29T08:14:06Z) GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED="false" GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED="false" + +# Passkey configuration +GOTRUE_PASSKEY_ENABLED="false" +GOTRUE_PASSKEY_CHALLENGE_EXPIRY_DURATION="300" diff --git a/internal/api/admin.go b/internal/api/admin.go index 0d53406de..191f3d85e 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -92,6 +92,28 @@ func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Contex return withFactor(ctx, factor), nil } +func (a *API) loadPasskey(w http.ResponseWriter, r *http.Request) (context.Context, error) { + ctx := r.Context() + db := a.db.WithContext(ctx) + user := getUser(ctx) + passkeyID, err := uuid.FromString(chi.URLParam(r, "passkey_id")) + if err != nil { + return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "passkey_id must be an UUID") + } + + observability.LogEntrySetField(r, "passkey_id", passkeyID) + + factor, err := user.FindOwnedPasskeyByID(db, passkeyID) + if err != nil { + if models.IsNotFoundError(err) { + return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeMFAFactorNotFound, "Passkey not found") + } + return nil, apierrors.NewInternalServerError("Database error loading passkey").WithInternalError(err) + } + + return withFactor(ctx, factor), nil +} + func (a *API) getAdminParams(r *http.Request) (*AdminUserParams, error) { params := &AdminUserParams{} if err := retrieveRequestParams(r, params); err != nil { diff --git a/internal/api/api.go b/internal/api/api.go index ed77282d7..9df6d3b5f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -277,6 +277,22 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne }) }) + r.Route("/passkeys", func(r *router) { + r.With(api.limitHandler(api.limiterOpts.PasskeySignIn)).Post("/sign-in", api.PasskeySignIn) + r.With(api.limitHandler(api.limiterOpts.PasskeySignIn)).Post("/sign-in/verify", api.PasskeySignInVerify) + + r.With(api.requireAuthentication).With(api.requireNotAnonymous).Route("/", func(r *router) { + r.Get("/", api.ListPasskeys) + r.With(api.limitHandler(api.limiterOpts.PasskeyChallenge)).Post("/", api.CreatePasskey) + + r.Route("/{passkey_id}", func(r *router) { + r.Use(api.loadPasskey) + r.With(api.limitHandler(api.limiterOpts.PasskeyChallenge)).Post("/verify", api.VerifyPasskey) + r.Delete("/", api.DeletePasskey) + }) + }) + }) + r.Route("/sso", func(r *router) { r.Use(api.requireSAMLEnabled) r.With(api.limitHandler(api.limiterOpts.SSO)). diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 24643f736..1407aea34 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -70,6 +70,10 @@ type RequestParams interface { adminUserDeleteParams | security.GotrueRequest | ChallengeFactorParams | + PasskeyRegistrationRequest | + PasskeyVerifyRequest | + PasskeySignInRequest | + PasskeySignInVerifyRequest | struct { Email string `json:"email"` diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 81523363f..dafee9344 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -133,10 +133,14 @@ func validateFactors(db *storage.Connection, user *models.User, newFactorName st if err := db.Load(user, "Factors"); err != nil { return err } - factorCount := len(user.Factors) + factorCount := 0 numVerifiedFactors := 0 for _, factor := range user.Factors { + if factor.IsPasskeyFactor() { + continue + } + factorCount++ if factor.FriendlyName == newFactorName { return apierrors.NewUnprocessableEntityError( apierrors.ErrorCodeMFAFactorNameConflict, diff --git a/internal/api/options.go b/internal/api/options.go index d47aba276..00d01e831 100644 --- a/internal/api/options.go +++ b/internal/api/options.go @@ -46,6 +46,8 @@ type LimiterOptions struct { User *limiter.Limiter FactorVerify *limiter.Limiter FactorChallenge *limiter.Limiter + PasskeySignIn *limiter.Limiter + PasskeyChallenge *limiter.Limiter SSO *limiter.Limiter SAMLAssertion *limiter.Limiter Web3 *limiter.Limiter @@ -85,6 +87,16 @@ func NewLimiterOptions(gc *conf.GlobalConfiguration) *LimiterOptions { DefaultExpirationTTL: time.Minute, }).SetBurst(30) + o.PasskeySignIn = tollbooth.NewLimiter(gc.RateLimitVerify/(60*5), + &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }).SetBurst(30) + + o.PasskeyChallenge = tollbooth.NewLimiter(gc.RateLimitVerify/(60*5), + &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }).SetBurst(30) + o.SSO = tollbooth.NewLimiter(gc.RateLimitSso/(60*5), &limiter.ExpirableOptions{ DefaultExpirationTTL: time.Hour, diff --git a/internal/api/passkey.go b/internal/api/passkey.go new file mode 100644 index 000000000..05e0c4309 --- /dev/null +++ b/internal/api/passkey.go @@ -0,0 +1,501 @@ +package api + +import ( + "bytes" + "net/http" + "time" + + wbnprotocol "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/metering" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" + "github.com/supabase/auth/internal/tokens" + "github.com/supabase/auth/internal/utilities" +) + +type PasskeyRegistrationRequest struct { + FriendlyName string `json:"friendly_name"` + WebAuthn *WebAuthnParams `json:"webauthn"` +} + +type PasskeyRegistrationResponse struct { + PasskeyID uuid.UUID `json:"passkey_id"` + ChallengeID uuid.UUID `json:"challenge_id"` + FriendlyName string `json:"friendly_name,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` + WebAuthn *WebAuthnChallengeData `json:"webauthn,omitempty"` +} + +type PasskeyVerifyRequest struct { + ChallengeID uuid.UUID `json:"challenge_id"` + WebAuthn *WebAuthnParams `json:"webauthn"` +} + +type PasskeySummary struct { + ID uuid.UUID `json:"id"` + FriendlyName string `json:"friendly_name,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + +type PasskeySignInRequest struct { + WebAuthn *WebAuthnParams `json:"webauthn"` +} + +type PasskeySignInResponse struct { + ChallengeID uuid.UUID `json:"challenge_id"` + ExpiresAt int64 `json:"expires_at,omitempty"` + WebAuthn *WebAuthnChallengeData `json:"webauthn,omitempty"` +} + +type PasskeySignInVerifyRequest struct { + ChallengeID uuid.UUID `json:"challenge_id"` + WebAuthn *WebAuthnParams `json:"webauthn"` +} + +type passkeyWebAuthnUser struct { + user *models.User + credentials []webauthn.Credential +} + +func (p passkeyWebAuthnUser) WebAuthnID() []byte { + return p.user.WebAuthnID() +} + +func (p passkeyWebAuthnUser) WebAuthnName() string { + return p.user.WebAuthnName() +} + +func (p passkeyWebAuthnUser) WebAuthnDisplayName() string { + return p.user.WebAuthnDisplayName() +} + +func (p passkeyWebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + return p.credentials +} + +func (a *API) ensurePasskeysEnabled() error { + if !a.config.Passkey.Enabled { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Passkeys are disabled") + } + return nil +} + +func (a *API) listPasskeys(w http.ResponseWriter, r *http.Request, user *models.User) error { + summaries := []PasskeySummary{} + for _, factor := range user.Factors { + if !factor.IsPasskeyFactor() { + continue + } + summaries = append(summaries, PasskeySummary{ + ID: factor.ID, + FriendlyName: factor.FriendlyName, + CreatedAt: factor.CreatedAt, + UpdatedAt: factor.UpdatedAt, + LastUsedAt: factor.LastChallengedAt, + }) + } + return sendJSON(w, http.StatusOK, map[string]interface{}{"passkeys": summaries}) +} + +func (a *API) ListPasskeys(w http.ResponseWriter, r *http.Request) error { + if err := a.ensurePasskeysEnabled(); err != nil { + return err + } + user := getUser(r.Context()) + if user == nil { + return apierrors.NewInternalServerError("No user in context") + } + if err := a.db.WithContext(r.Context()).Load(user, "Factors"); err != nil { + return apierrors.NewInternalServerError("Database error loading factors").WithInternalError(err) + } + return a.listPasskeys(w, r, user) +} + +func (a *API) CreatePasskey(w http.ResponseWriter, r *http.Request) error { + if err := a.ensurePasskeysEnabled(); err != nil { + return err + } + ctx := r.Context() + user := getUser(ctx) + session := getSession(ctx) + if user == nil || session == nil { + return apierrors.NewInternalServerError("A valid session and user are required to register passkeys") + } + + params := &PasskeyRegistrationRequest{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + if params.WebAuthn == nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "web_authn config required") + } + webAuthn, err := params.WebAuthn.ToConfig() + if err != nil { + return err + } + if params.FriendlyName == "" { + params.FriendlyName = "Passkey" + } + + db := a.db.WithContext(ctx) + if err := db.Load(user, "Factors"); err != nil { + return apierrors.NewInternalServerError("Database error loading user factors").WithInternalError(err) + } + + if err := models.DeleteUnverifiedPasskeyFactors(db, user); err != nil { + return apierrors.NewInternalServerError("Database error cleaning up passkeys").WithInternalError(err) + } + + factor := models.NewPasskeyFactor(user, params.FriendlyName) + ipAddress := utilities.GetIPAddress(r) + + err = db.Transaction(func(tx *storage.Connection) error { + if terr := tx.Create(factor); terr != nil { + return terr + } + if terr := models.NewAuditLogEntry(a.config.AuditLog, r, tx, user, models.EnrollFactorAction, ipAddress, map[string]interface{}{ + "factor_id": factor.ID, + "factor_type": factor.FactorType, + "is_passkey": true, + }); terr != nil { + return terr + } + return nil + }) + if err != nil { + return err + } + + // Reload user factors to include the new passkey for exclusion list + if err := db.Load(user, "Factors"); err != nil { + return apierrors.NewInternalServerError("Database error loading user factors").WithInternalError(err) + } + + excludeList := []wbnprotocol.CredentialDescriptor{} + for _, cred := range user.PasskeyCredentials() { + excludeList = append(excludeList, wbnprotocol.CredentialDescriptor{ + Type: wbnprotocol.PublicKeyCredentialType, + CredentialID: cred.ID, + Transport: []wbnprotocol.AuthenticatorTransport{"usb", "nfc", "ble", "internal"}, + }) + } + + options, sessionData, err := webAuthn.BeginRegistration(user, webauthn.WithExclusions(excludeList)) + if err != nil { + return apierrors.NewInternalServerError("Failed to generate WebAuthn registration data").WithInternalError(err) + } + + challenge := (&models.WebAuthnSessionData{SessionData: sessionData}).ToChallenge(factor.ID, ipAddress) + if err := factor.WriteChallengeToDatabase(db, challenge); err != nil { + return err + } + + response := &PasskeyRegistrationResponse{ + PasskeyID: factor.ID, + ChallengeID: challenge.ID, + FriendlyName: factor.FriendlyName, + ExpiresAt: challenge.GetExpiryTime(a.config.Passkey.ChallengeExpiryDuration).Unix(), + WebAuthn: &WebAuthnChallengeData{ + Type: "create", + CredentialOptions: options, + }, + } + return sendJSON(w, http.StatusOK, response) +} + +func (a *API) VerifyPasskey(w http.ResponseWriter, r *http.Request) error { + if err := a.ensurePasskeysEnabled(); err != nil { + return err + } + ctx := r.Context() + user := getUser(ctx) + if user == nil { + return apierrors.NewInternalServerError("No user in context") + } + factor := getFactor(ctx) + if factor == nil || !factor.IsPasskeyFactor() { + return apierrors.NewNotFoundError(apierrors.ErrorCodeMFAFactorNotFound, "Passkey not found") + } + + params := &PasskeyVerifyRequest{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + if params.WebAuthn == nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "WebAuthn config required") + } + if params.WebAuthn.Type != "create" { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "WebAuthn type must be create") + } + if params.WebAuthn.CredentialResponse == nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential_response required") + } + + webAuthn, err := params.WebAuthn.ToConfig() + if err != nil { + return err + } + + db := a.db.WithContext(ctx) + challenge, err := a.validateChallenge(r, db, factor, params.ChallengeID) + if err != nil { + return err + } + + parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse)) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response") + } + + credential, err := webAuthn.CreateCredential(user, *challenge.WebAuthnSessionData.SessionData, parsedResponse) + if err != nil { + return err + } + + if err := db.Destroy(challenge); err != nil { + return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err) + } + + err = db.Transaction(func(tx *storage.Connection) error { + if !factor.IsVerified() { + if terr := factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil { + return terr + } + } + if terr := factor.SaveWebAuthnCredential(tx, credential); terr != nil { + return terr + } + if terr := factor.UpdateLastWebAuthnChallenge(tx, challenge, params.WebAuthn.Type, parsedResponse); terr != nil { + return terr + } + if terr := models.NewAuditLogEntry(a.config.AuditLog, r, tx, user, models.VerifyFactorAction, utilities.GetIPAddress(r), map[string]interface{}{ + "factor_id": factor.ID, + "factor_type": factor.FactorType, + "is_passkey": true, + }); terr != nil { + return terr + } + return nil + }) + if err != nil { + return err + } + + return sendJSON(w, http.StatusOK, map[string]interface{}{"passkey_id": factor.ID}) +} + +func (a *API) DeletePasskey(w http.ResponseWriter, r *http.Request) error { + if err := a.ensurePasskeysEnabled(); err != nil { + return err + } + ctx := r.Context() + user := getUser(ctx) + session := getSession(ctx) + factor := getFactor(ctx) + if user == nil || session == nil || factor == nil { + return apierrors.NewInternalServerError("A valid session and passkey are required to delete a passkey") + } + if !factor.IsPasskeyFactor() { + return apierrors.NewNotFoundError(apierrors.ErrorCodeMFAFactorNotFound, "Passkey not found") + } + if factor.IsVerified() && !session.IsAAL2() { + return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeInsufficientAAL, "AAL2 required to delete verified passkey") + } + db := a.db.WithContext(ctx) + err := db.Transaction(func(tx *storage.Connection) error { + if terr := tx.Destroy(factor); terr != nil { + return terr + } + if terr := models.NewAuditLogEntry(a.config.AuditLog, r, tx, user, models.DeleteFactorAction, utilities.GetIPAddress(r), map[string]interface{}{ + "factor_id": factor.ID, + "factor_type": factor.FactorType, + "is_passkey": true, + }); terr != nil { + return terr + } + return nil + }) + if err != nil { + return err + } + return sendJSON(w, http.StatusOK, map[string]interface{}{"passkey_id": factor.ID}) +} + +func (a *API) PasskeySignIn(w http.ResponseWriter, r *http.Request) error { + if err := a.ensurePasskeysEnabled(); err != nil { + return err + } + params := &PasskeySignInRequest{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + if params.WebAuthn == nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "web_authn config required") + } + if params.WebAuthn.Type != "request" { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "WebAuthn type must be request") + } + webAuthn, err := params.WebAuthn.ToConfig() + if err != nil { + return err + } + + options, sessionData, err := webAuthn.BeginDiscoverableLogin() + if err != nil { + return apierrors.NewInternalServerError("Failed to generate WebAuthn passkey challenge").WithInternalError(err) + } + + db := a.db.WithContext(r.Context()) + ipAddress := utilities.GetIPAddress(r) + challenge := models.NewPasskeyChallenge(sessionData, nil, ipAddress) + if err := db.Create(challenge); err != nil { + return apierrors.NewInternalServerError("Database error creating challenge").WithInternalError(err) + } + + expiresAt := time.Now().Add(time.Second * time.Duration(a.config.Passkey.ChallengeExpiryDuration)) + + response := &PasskeySignInResponse{ + ChallengeID: challenge.ID, + ExpiresAt: expiresAt.Unix(), + WebAuthn: &WebAuthnChallengeData{ + Type: "request", + CredentialOptions: options, + }, + } + return sendJSON(w, http.StatusOK, response) +} + +func (a *API) PasskeySignInVerify(w http.ResponseWriter, r *http.Request) error { + if err := a.ensurePasskeysEnabled(); err != nil { + return err + } + params := &PasskeySignInVerifyRequest{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + if params.WebAuthn == nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "web_authn config required") + } + if params.WebAuthn.Type != "request" { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "WebAuthn type must be request") + } + if params.WebAuthn.CredentialResponse == nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential_response required") + } + webAuthn, err := params.WebAuthn.ToConfig() + if err != nil { + return err + } + + db := a.db.WithContext(r.Context()) + challenge, err := models.FindPasskeyChallengeByID(db, params.ChallengeID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeMFAFactorNotFound, "Challenge not found") + } + return apierrors.NewInternalServerError("Database error finding challenge").WithInternalError(err) + } + if challenge.WebAuthnSessionData == nil || challenge.WebAuthnSessionData.SessionData == nil { + return apierrors.NewInternalServerError("Challenge missing session data") + } + if challenge.HasExpired(time.Duration(a.config.Passkey.ChallengeExpiryDuration) * time.Second) { + if err := db.Destroy(challenge); err != nil { + return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err) + } + return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeMFAChallengeExpired, "Passkey challenge has expired") + } + if challenge.IPAddress != utilities.GetIPAddress(r) { + return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeMFAIPAddressMismatch, "Challenge and verify IP addresses mismatch.") + } + + parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse)) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response") + } + + assertion := parsedResponse + if assertion.Response.UserHandle == nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "User handle missing from credential_response") + } + + userID, err := uuid.FromString(string(assertion.Response.UserHandle)) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user handle in credential_response") + } + + baseUser, err := models.FindUserByID(db, userID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeUserNotFound, "User not found for passkey") + } + return apierrors.NewInternalServerError("Database error finding user").WithInternalError(err) + } + if err := db.Load(baseUser, "Factors"); err != nil { + return apierrors.NewInternalServerError("Database error loading passkeys").WithInternalError(err) + } + + credentials := baseUser.PasskeyCredentials() + if len(credentials) == 0 { + return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeMFAFactorNotFound, "No passkeys registered") + } + + passkeyUser := passkeyWebAuthnUser{user: baseUser, credentials: credentials} + sessionData := *challenge.WebAuthnSessionData.SessionData + sessionData.UserID = passkeyUser.WebAuthnID() + + credential, err := webAuthn.ValidateLogin(passkeyUser, sessionData, assertion) + if err != nil { + return apierrors.NewInternalServerError("Failed to validate passkey response").WithInternalError(err) + } + if baseUser.IsBanned() { + return apierrors.NewForbiddenError(apierrors.ErrorCodeUserBanned, "User is banned") + } + + factor, err := models.FindPasskeyFactorByCredentialID(db, baseUser.ID, credential.ID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeMFAFactorNotFound, "Passkey not found for user") + } + return apierrors.NewInternalServerError("Database error finding passkey").WithInternalError(err) + } + + if err := db.Destroy(challenge); err != nil { + return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err) + } + + grantParams := models.GrantParams{} + grantParams.FillGrantParams(r) + grantParams.FactorID = &factor.ID + + var token *tokens.AccessTokenResponse + err = db.Transaction(func(tx *storage.Connection) error { + if terr := models.NewAuditLogEntry(a.config.AuditLog, r, tx, baseUser, models.LoginAction, utilities.GetIPAddress(r), map[string]interface{}{ + "provider": PasskeyProvider, + }); terr != nil { + return terr + } + var terr error + token, terr = a.issueRefreshToken(r, tx, baseUser, models.Passkey, grantParams) + if terr != nil { + return terr + } + now := time.Now() + factor.LastChallengedAt = &now + if terr = tx.UpdateOnly(factor, "last_challenged_at", "updated_at"); terr != nil { + return terr + } + return nil + }) + if err != nil { + return err + } + + metering.RecordLogin(metering.LoginTypePasskey, baseUser.ID, &metering.LoginData{Provider: PasskeyProvider}) + + return sendJSON(w, http.StatusOK, token) +} diff --git a/internal/api/provider_constants.go b/internal/api/provider_constants.go index 4c246cb88..708d4b7c0 100644 --- a/internal/api/provider_constants.go +++ b/internal/api/provider_constants.go @@ -2,6 +2,7 @@ package api // Provider constants const ( - EmailProvider = "email" - PhoneProvider = "phone" + EmailProvider = "email" + PhoneProvider = "phone" + PasskeyProvider = "passkey" ) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index b7024e7c5..18b6f15b4 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -171,6 +171,11 @@ type MFAConfiguration struct { WebAuthn MFAFactorTypeConfiguration `split_words:"true"` } +type PasskeyConfiguration struct { + Enabled bool `json:"enabled" split_words:"true" default:"false"` + ChallengeExpiryDuration float64 `json:"challenge_expiry_duration" split_words:"true" default:"300"` +} + type APIConfiguration struct { Host string Port string `envconfig:"PORT" default:"8081"` @@ -338,6 +343,8 @@ type GlobalConfiguration struct { RateLimitWeb3 float64 `split_words:"true" default:"30"` RateLimitOAuthDynamicClientRegister float64 `split_words:"true" default:"10"` + Passkey PasskeyConfiguration `json:"passkey"` + SiteURL string `json:"site_url" split_words:"true" required:"true"` URIAllowList []string `json:"uri_allow_list" split_words:"true"` URIAllowListMap map[string]glob.Glob diff --git a/internal/metering/record.go b/internal/metering/record.go index a7c3991f9..b113d3f36 100644 --- a/internal/metering/record.go +++ b/internal/metering/record.go @@ -21,6 +21,7 @@ const ( LoginTypePKCE LoginType = "pkce" LoginTypeToken LoginType = "token" // for refresh token flows, to be backward-compatible with existing data LoginTypeMFA LoginType = "mfa" // for MFA verifications + LoginTypePasskey LoginType = "passkey" ) // Provider constants for consistent login analytics @@ -33,6 +34,7 @@ const ( ProviderMFATOTP = "totp" ProviderMFAPhone = "phone" ProviderMFAWebAuthn = "webauthn" + ProviderPasskey = "passkey" // SSO providers ProviderSAML = "saml" diff --git a/internal/models/errors.go b/internal/models/errors.go index fa3a2674e..79afb83e0 100644 --- a/internal/models/errors.go +++ b/internal/models/errors.go @@ -31,6 +31,8 @@ func IsNotFoundError(err error) bool { return true case OAuthServerAuthorizationNotFoundError, *OAuthServerAuthorizationNotFoundError: return true + case PasskeyChallengeNotFoundError, *PasskeyChallengeNotFoundError: + return true } return false } @@ -90,6 +92,12 @@ func (e ChallengeNotFoundError) Error() string { return "Challenge not found" } +type PasskeyChallengeNotFoundError struct{} + +func (e PasskeyChallengeNotFoundError) Error() string { + return "Passkey challenge not found" +} + // SSOProviderNotFoundError represents an error when a SSO Provider can't be // found. type SSOProviderNotFoundError struct{} diff --git a/internal/models/factor.go b/internal/models/factor.go index ad08e02ec..2c907d6b3 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -56,6 +56,7 @@ const ( Anonymous Web3 OAuthProviderAuthorizationCode + Passkey ) func (authMethod AuthenticationMethod) String() string { @@ -92,6 +93,8 @@ func (authMethod AuthenticationMethod) String() string { return "web3" case OAuthProviderAuthorizationCode: return "oauth_provider/authorization_code" + case Passkey: + return "passkey" } return "" } @@ -131,6 +134,8 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) return Web3, nil case "oauth_provider/authorization_code": return OAuthProviderAuthorizationCode, nil + case "passkey": + return Passkey, nil } return 0, fmt.Errorf("unsupported authentication method %q", authMethod) @@ -152,7 +157,9 @@ type Factor struct { LastChallengedAt *time.Time `json:"last_challenged_at" db:"last_challenged_at"` WebAuthnCredential *WebAuthnCredential `json:"-" db:"web_authn_credential"` WebAuthnAAGUID *uuid.UUID `json:"web_authn_aaguid,omitempty" db:"web_authn_aaguid"` + WebAuthnCredentialID []byte `json:"-" db:"web_authn_credential_id"` LastWebAuthnChallengeData *LastWebAuthnChallengeData `json:"last_webauthn_challenge_data,omitempty" db:"last_webauthn_challenge_data"` + IsPasskey bool `json:"is_passkey" db:"is_passkey"` } type WebAuthnCredential struct { @@ -255,6 +262,12 @@ func NewWebAuthnFactor(user *User, friendlyName string) *Factor { return factor } +func NewPasskeyFactor(user *User, friendlyName string) *Factor { + factor := NewWebAuthnFactor(user, friendlyName) + factor.IsPasskey = true + return factor +} + func (f *Factor) SetSecret(secret string, encrypt bool, encryptionKeyID, encryptionKey string) error { f.Secret = secret if encrypt { @@ -297,7 +310,9 @@ func (f *Factor) SaveWebAuthnCredential(tx *storage.Connection, credential *weba f.WebAuthnAAGUID = nil } - return tx.UpdateOnly(f, "web_authn_credential", "web_authn_aaguid", "updated_at") + f.WebAuthnCredentialID = credential.ID + + return tx.UpdateOnly(f, "web_authn_credential", "web_authn_aaguid", "web_authn_credential_id", "updated_at") } func (f *Factor) UpdateLastWebAuthnChallenge(tx *storage.Connection, challenge *Challenge, challengeType string, credentialResponse interface{}) error { @@ -326,6 +341,31 @@ func FindFactorByFactorID(conn *storage.Connection, factorID uuid.UUID) (*Factor return &factor, nil } +func FindPasskeyFactorByCredentialID(conn *storage.Connection, userID uuid.UUID, credentialID []byte) (*Factor, error) { + var factor Factor + err := conn.Q().Where("user_id = ? AND factor_type = ? AND is_passkey IS TRUE AND status = ?", userID, WebAuthn, FactorStateVerified.String()). + Where("web_authn_credential_id = ?", credentialID). + First(&factor) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, &FactorNotFoundError{} + } + return nil, err + } + return &factor, nil +} + +func DeleteUnverifiedPasskeyFactors(tx *storage.Connection, user *User) error { + if err := tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Factor{}}).TableName()+" WHERE user_id = ? and status = ? and factor_type = ? and is_passkey is true", user.ID, FactorStateUnverified.String(), WebAuthn).Exec(); err != nil { + return err + } + return nil +} + +func (f *Factor) IsPasskeyFactor() bool { + return f.IsPasskey +} + func DeleteUnverifiedFactors(tx *storage.Connection, user *User, factorType string) error { if err := tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Factor{}}).TableName()+" WHERE user_id = ? and status = ? and factor_type = ?", user.ID, FactorStateUnverified.String(), factorType).Exec(); err != nil { return err diff --git a/internal/models/passkey_challenge.go b/internal/models/passkey_challenge.go new file mode 100644 index 000000000..5f27b2742 --- /dev/null +++ b/internal/models/passkey_challenge.go @@ -0,0 +1,67 @@ +package models + +import ( + "database/sql" + "time" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "github.com/supabase/auth/internal/storage" +) + +type PasskeyChallenge struct { + ID uuid.UUID `json:"id" db:"id"` + UserID *uuid.UUID `json:"user_id" db:"user_id"` + WebAuthnSessionData *WebAuthnSessionData `json:"web_authn_session_data,omitempty" db:"web_authn_session_data"` + IPAddress string `json:"ip_address" db:"ip_address"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +func (PasskeyChallenge) TableName() string { + return "passkey_challenges" +} + +func NewPasskeyChallenge(sessionData *webauthn.SessionData, userID *uuid.UUID, ipAddress string) *PasskeyChallenge { + id := uuid.Must(uuid.NewV4()) + var ws *WebAuthnSessionData + if sessionData != nil { + ws = &WebAuthnSessionData{SessionData: sessionData} + } + return &PasskeyChallenge{ + ID: id, + UserID: userID, + WebAuthnSessionData: ws, + IPAddress: ipAddress, + } +} + +func FindPasskeyChallengeByID(tx *storage.Connection, id uuid.UUID) (*PasskeyChallenge, error) { + challenge := &PasskeyChallenge{} + if err := tx.Find(challenge, id); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, &PasskeyChallengeNotFoundError{} + } + return nil, err + } + return challenge, nil +} + +func (c *PasskeyChallenge) HasExpired(expiryDuration time.Duration) bool { + return time.Now().After(c.CreatedAt.Add(expiryDuration)) +} + +func (c *PasskeyChallenge) UpdateUserID(tx *storage.Connection, userID uuid.UUID) error { + c.UserID = &userID + return tx.UpdateOnly(c, "user_id", "updated_at") +} + +func (c *PasskeyChallenge) UpdateSession(tx *storage.Connection, sessionData *webauthn.SessionData) error { + if sessionData == nil { + c.WebAuthnSessionData = nil + } else { + c.WebAuthnSessionData = &WebAuthnSessionData{SessionData: sessionData} + } + return tx.UpdateOnly(c, "web_authn_session_data", "updated_at") +} diff --git a/internal/models/user.go b/internal/models/user.go index 068b0c970..d0b0e6d42 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -954,6 +954,17 @@ func (u *User) FindOwnedFactorByID(tx *storage.Connection, factorID uuid.UUID) ( return &factor, nil } +func (u *User) FindOwnedPasskeyByID(tx *storage.Connection, passkeyID uuid.UUID) (*Factor, error) { + factor, err := u.FindOwnedFactorByID(tx, passkeyID) + if err != nil { + return nil, err + } + if !factor.IsPasskeyFactor() { + return nil, &FactorNotFoundError{} + } + return factor, nil +} + func (user *User) WebAuthnID() []byte { return []byte(user.ID.String()) } @@ -970,7 +981,7 @@ func (user *User) WebAuthnCredentials() []webauthn.Credential { var credentials []webauthn.Credential for _, factor := range user.Factors { - if factor.IsVerified() && factor.FactorType == WebAuthn { + if factor.IsVerified() && factor.FactorType == WebAuthn && factor.WebAuthnCredential != nil { credential := factor.WebAuthnCredential.Credential credentials = append(credentials, credential) } @@ -979,6 +990,33 @@ func (user *User) WebAuthnCredentials() []webauthn.Credential { return credentials } +func (user *User) PasskeyCredentials() []webauthn.Credential { + var credentials []webauthn.Credential + + for _, factor := range user.Factors { + if !factor.IsPasskeyFactor() { + continue + } + if factor.IsVerified() && factor.FactorType == WebAuthn && factor.WebAuthnCredential != nil { + credentials = append(credentials, factor.WebAuthnCredential.Credential) + } + } + + return credentials +} + +func (user *User) PasskeyFactors() []Factor { + var factors []Factor + + for _, factor := range user.Factors { + if factor.IsPasskeyFactor() { + factors = append(factors, factor) + } + } + + return factors +} + func obfuscateValue(id uuid.UUID, value string) string { hash := sha256.Sum256([]byte(id.String() + value)) return base64.RawURLEncoding.EncodeToString(hash[:]) diff --git a/migrations/20250216000000_add_passkey_support.up.sql b/migrations/20250216000000_add_passkey_support.up.sql new file mode 100644 index 000000000..c36c9f454 --- /dev/null +++ b/migrations/20250216000000_add_passkey_support.up.sql @@ -0,0 +1,19 @@ +alter table {{ index .Options "Namespace" }}.mfa_factors + add column if not exists is_passkey boolean not null default false, + add column if not exists web_authn_credential_id bytea null; + +create index if not exists mfa_factors_user_passkey_idx + on {{ index .Options "Namespace" }}.mfa_factors (user_id) + where is_passkey is true; + +create table if not exists {{ index .Options "Namespace" }}.passkey_challenges ( + id uuid primary key, + user_id uuid null references {{ index .Options "Namespace" }}.users(id) on delete cascade, + web_authn_session_data jsonb null, + ip_address inet not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table {{ index .Options "Namespace" }}.passkey_challenges is 'auth: stores metadata about passkey sign-in challenges'; +comment on column {{ index .Options "Namespace" }}.passkey_challenges.web_authn_session_data is 'WebAuthn session data for validating passkey challenges'; diff --git a/openapi.yaml b/openapi.yaml index 83f940a20..6f342c9f4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1222,6 +1222,254 @@ paths: 429: $ref: "#/components/responses/RateLimitResponse" + /passkeys: + get: + summary: List registered passkeys for the authenticated user. + tags: + - user + security: + - APIKeyAuth: [] + UserAuth: [] + responses: + 200: + description: A collection of passkeys registered by the user. + content: + application/json: + schema: + type: object + properties: + passkeys: + type: array + items: + $ref: '#/components/schemas/PasskeySummary' + 400: + $ref: "#/components/responses/BadRequestResponse" + post: + summary: Begin registering a new passkey for the authenticated user. + tags: + - user + security: + - APIKeyAuth: [] + UserAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - webauthn + properties: + friendly_name: + type: string + description: Optional display name for the passkey. + webauthn: + type: object + required: + - rpId + - rpOrigins + properties: + rpId: + type: string + description: The relying party identifier (usually the domain). + rpOrigins: + type: array + items: + type: string + minItems: 1 + description: List of allowed origins for WebAuthn challenges. + responses: + 200: + description: Passkey registration challenge created. + content: + application/json: + schema: + $ref: '#/components/schemas/PasskeyRegistrationResponse' + 400: + $ref: "#/components/responses/BadRequestResponse" + 429: + $ref: "#/components/responses/RateLimitResponse" + + /passkeys/{passkey_id}/verify: + post: + summary: Complete passkey registration by verifying the attestation response. + tags: + - user + security: + - APIKeyAuth: [] + UserAuth: [] + parameters: + - name: passkey_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - challenge_id + - webauthn + properties: + challenge_id: + type: string + format: uuid + description: Identifier of the registration challenge to verify. + webauthn: + type: object + required: + - rpId + - rpOrigins + - type + - credential_response + properties: + rpId: + type: string + rpOrigins: + type: array + items: + type: string + minItems: 1 + type: + type: string + enum: [create] + credential_response: + type: object + responses: + 200: + description: Passkey verified and ready for use. + content: + application/json: + schema: + $ref: '#/components/schemas/PasskeyVerifyResponse' + 400: + $ref: "#/components/responses/BadRequestResponse" + 429: + $ref: "#/components/responses/RateLimitResponse" + + /passkeys/{passkey_id}: + delete: + summary: Remove a registered passkey from the authenticated user. + tags: + - user + security: + - APIKeyAuth: [] + UserAuth: [] + parameters: + - name: passkey_id + in: path + required: true + schema: + type: string + format: uuid + responses: + 200: + description: Passkey successfully removed. + content: + application/json: + schema: + type: object + properties: + passkey_id: + type: string + format: uuid + 400: + $ref: "#/components/responses/BadRequestResponse" + + /passkeys/sign-in: + post: + summary: Begin a passkey sign-in flow. + tags: + - user + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - webauthn + properties: + webauthn: + type: object + required: + - rpId + - rpOrigins + properties: + rpId: + type: string + rpOrigins: + type: array + items: + type: string + minItems: 1 + responses: + 200: + description: Passkey sign-in challenge issued. + content: + application/json: + schema: + $ref: '#/components/schemas/PasskeySignInChallengeResponse' + 400: + $ref: "#/components/responses/BadRequestResponse" + 429: + $ref: "#/components/responses/RateLimitResponse" + + /passkeys/sign-in/verify: + post: + summary: Complete a passkey sign-in using the authenticator response. + tags: + - user + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - challenge_id + - webauthn + properties: + challenge_id: + type: string + format: uuid + description: Identifier of the sign-in challenge to verify. + webauthn: + type: object + required: + - rpId + - rpOrigins + - type + - credential_response + properties: + rpId: + type: string + rpOrigins: + type: array + items: + type: string + minItems: 1 + type: + type: string + enum: [request] + credential_response: + type: object + responses: + 200: + description: Passkey sign-in successful. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessTokenResponseSchema" + 400: + $ref: "#/components/responses/BadRequestResponse" + 429: + $ref: "#/components/responses/RateLimitResponse" + /invite: post: summary: Invite a user by email. @@ -2807,6 +3055,70 @@ components: discriminator: propertyName: type + PasskeyRegistrationResponse: + type: object + required: + - passkey_id + - challenge_id + - webauthn + properties: + passkey_id: + type: string + format: uuid + challenge_id: + type: string + format: uuid + friendly_name: + type: string + expires_at: + type: integer + description: UNIX seconds of the timestamp when the challenge expires. + webauthn: + type: object + description: WebAuthn options needed to complete registration. + + PasskeySignInChallengeResponse: + type: object + required: + - challenge_id + - webauthn + properties: + challenge_id: + type: string + format: uuid + expires_at: + type: integer + description: UNIX seconds of the timestamp when the challenge expires. + webauthn: + type: object + description: WebAuthn options needed to complete sign-in. + + PasskeySummary: + type: object + properties: + id: + type: string + format: uuid + friendly_name: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + last_used_at: + type: string + format: date-time + nullable: true + + PasskeyVerifyResponse: + type: object + properties: + passkey_id: + type: string + format: uuid + CredentialRequestOptions: type: object description: PublicKeyCredentialRequestOptions for WebAuthn authentication