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")