From f2279b98d0d1ffa9fda2275394ce58786de135a1 Mon Sep 17 00:00:00 2001 From: Milind Gupta Date: Wed, 2 Sep 2026 10:04:41 +0530 Subject: [PATCH] fix(passkey): resolve Backup Eligible flag inconsistency on login (#2732) Fix passkey verification failure when an authenticator's Backup Eligible (BE) flag changes between registration and authentication. This issue affected Apple iCloud Keychain passkeys where BE is false during initial registration before iCloud sync completes, and flips to true during subsequent logins. - Update newWebAuthnUserWithAssertion to align cred.Flags.BackupEligible with incoming assertion data for matching credentials. - Add UpdateLastUsedWithSignCountAndFlags to persist updated sign count, backup eligibility, and backup state in webauthn_credentials. - Add unit tests verifying Backup Eligible flag synchronization. Fixes #2732 --- internal/api/passkey_authentication.go | 4 +- .../api/passkey_virtual_authenticator_test.go | 6 +- internal/api/passkey_webauthn.go | 12 +++- internal/api/passkey_webauthn_test.go | 56 +++++++++++++++++++ internal/models/webauthn_credential.go | 9 +++ 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/internal/api/passkey_authentication.go b/internal/api/passkey_authentication.go index 6967d3f0a5..eb1ea26029 100644 --- a/internal/api/passkey_authentication.go +++ b/internal/api/passkey_authentication.go @@ -137,7 +137,7 @@ func (a *API) PasskeyAuthenticationVerify(w http.ResponseWriter, r *http.Request return nil, models.WebAuthnCredentialNotFoundError{} } - return newWebAuthnUser(u, creds), nil + return newWebAuthnUserWithAssertion(u, creds, parsedResponse), nil } webauthnUser, credential, err := webAuthn.ValidatePasskeyLogin(handler, sessionData, parsedResponse) @@ -179,7 +179,7 @@ func (a *API) PasskeyAuthenticationVerify(w http.ResponseWriter, r *http.Request err = db.Transaction(func(tx *storage.Connection) error { var terr error - if terr = passkeyCredential.UpdateLastUsedWithSignCount(tx, credential.Authenticator.SignCount); terr != nil { + if terr = passkeyCredential.UpdateLastUsedWithSignCountAndFlags(tx, credential.Authenticator.SignCount, credential.Flags.BackupEligible, credential.Flags.BackupState); terr != nil { return terr } diff --git a/internal/api/passkey_virtual_authenticator_test.go b/internal/api/passkey_virtual_authenticator_test.go index efbd81f1fc..788972db2a 100644 --- a/internal/api/passkey_virtual_authenticator_test.go +++ b/internal/api/passkey_virtual_authenticator_test.go @@ -132,6 +132,10 @@ func (va *virtualAuthenticator) createCredential(options *protocol.PublicKeyCred // authentication options. It picks the first stored credential (discoverable flow) and signs // the authenticator data + client data hash. func (va *virtualAuthenticator) getAssertion(options *protocol.PublicKeyCredentialRequestOptions) (*virtualAssertionResponse, error) { + return va.getAssertionWithFlags(options, 0x05) +} + +func (va *virtualAuthenticator) getAssertionWithFlags(options *protocol.PublicKeyCredentialRequestOptions, flags byte) (*virtualAssertionResponse, error) { if len(va.credentials) == 0 { return nil, fmt.Errorf("no stored credentials") } @@ -156,8 +160,6 @@ func (va *virtualAuthenticator) getAssertion(options *protocol.PublicKeyCredenti // Build authenticator data for assertion (no attested credential data) rpIDHash := sha256.Sum256([]byte(va.rpID)) - // flags: UP (bit 0) | UV (bit 2) = 0x05 - flags := byte(0x05) var authData []byte authData = append(authData, rpIDHash[:]...) authData = append(authData, flags) diff --git a/internal/api/passkey_webauthn.go b/internal/api/passkey_webauthn.go index 104eda515c..9cbd23fee7 100644 --- a/internal/api/passkey_webauthn.go +++ b/internal/api/passkey_webauthn.go @@ -1,6 +1,8 @@ package api import ( + "bytes" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/supabase/auth/internal/api/apierrors" @@ -48,10 +50,18 @@ type webAuthnUser struct { } func newWebAuthnUser(user *models.User, passkeyCredentials []*models.WebAuthnCredential) *webAuthnUser { + return newWebAuthnUserWithAssertion(user, passkeyCredentials, nil) +} + +func newWebAuthnUserWithAssertion(user *models.User, passkeyCredentials []*models.WebAuthnCredential, assertion *protocol.ParsedCredentialAssertionData) *webAuthnUser { credentials := make([]webauthn.Credential, len(passkeyCredentials)) for i, pc := range passkeyCredentials { - credentials[i] = pc.ToWebAuthnCredential() + cred := pc.ToWebAuthnCredential() + if assertion != nil && bytes.Equal(pc.CredentialID, assertion.RawID) { + cred.Flags.BackupEligible = assertion.Response.AuthenticatorData.Flags.HasBackupEligible() + } + credentials[i] = cred } return &webAuthnUser{ diff --git a/internal/api/passkey_webauthn_test.go b/internal/api/passkey_webauthn_test.go index 0546e1baa8..fdc74fc20d 100644 --- a/internal/api/passkey_webauthn_test.go +++ b/internal/api/passkey_webauthn_test.go @@ -190,3 +190,59 @@ func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnUserWithCredentials() { ts.True(webauthnCreds[1].Flags.BackupEligible) ts.True(webauthnCreds[1].Flags.BackupState) } + +func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnUserWithAssertionAlignsBackupEligible() { + user := &models.User{ + ID: uuid.Must(uuid.NewV4()), + } + user.Email = "user@example.com" + + credID := []byte("cred-be-flip") + creds := []*models.WebAuthnCredential{ + { + ID: uuid.Must(uuid.NewV4()), + UserID: user.ID, + CredentialID: credID, + PublicKey: []byte("pk-1"), + AttestationType: "none", + SignCount: 0, + BackupEligible: false, + BackedUp: false, + }, + } + + assertion := &protocol.ParsedCredentialAssertionData{ + ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{ + RawID: credID, + }, + Response: protocol.ParsedAssertionResponse{ + AuthenticatorData: protocol.AuthenticatorData{ + Flags: protocol.FlagBackupEligible, // BE = true + }, + }, + } + + wu := newWebAuthnUserWithAssertion(user, creds, assertion) + webauthnCreds := wu.WebAuthnCredentials() + ts.Require().Len(webauthnCreds, 1) + + // Verify that BackupEligible is updated to true matching assertion + ts.True(webauthnCreds[0].Flags.BackupEligible) + + // Non-matching assertion leaves BackupEligible unchanged + otherAssertion := &protocol.ParsedCredentialAssertionData{ + ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{ + RawID: []byte("other-cred-id"), + }, + Response: protocol.ParsedAssertionResponse{ + AuthenticatorData: protocol.AuthenticatorData{ + Flags: protocol.FlagBackupEligible, + }, + }, + } + + wuOther := newWebAuthnUserWithAssertion(user, creds, otherAssertion) + webauthnCredsOther := wuOther.WebAuthnCredentials() + ts.Require().Len(webauthnCredsOther, 1) + ts.False(webauthnCredsOther[0].Flags.BackupEligible) +} diff --git a/internal/models/webauthn_credential.go b/internal/models/webauthn_credential.go index b7dc60e1dc..15e1fc8439 100644 --- a/internal/models/webauthn_credential.go +++ b/internal/models/webauthn_credential.go @@ -184,6 +184,15 @@ func (pc *WebAuthnCredential) UpdateLastUsedWithSignCount(tx *storage.Connection return tx.UpdateOnly(pc, "sign_count", "last_used_at", "updated_at") } +func (pc *WebAuthnCredential) UpdateLastUsedWithSignCountAndFlags(tx *storage.Connection, signCount uint32, backupEligible, backedUp bool) error { + now := time.Now() + pc.SignCount = signCount + pc.BackupEligible = backupEligible + pc.BackedUp = backedUp + pc.LastUsedAt = &now + return tx.UpdateOnly(pc, "sign_count", "backup_eligible", "backed_up", "last_used_at", "updated_at") +} + func (pc *WebAuthnCredential) UpdateFriendlyName(tx *storage.Connection, friendlyName string) error { pc.FriendlyName = friendlyName return tx.UpdateOnly(pc, "friendly_name", "updated_at")