Skip to content

Commit 7e6f2e4

Browse files
authored
fix(passkeys): enforce AAL checks on passkey registration and deletion (#2565)
If a user has a verified MFA factor enrolled, then enforce AAL2 for passkey registration and deletion. Note: we explicitly do not gate the passkey update operation since it can only be used to update the `friendly_name` of the passkey.
1 parent 7e1c060 commit 7e6f2e4

6 files changed

Lines changed: 163 additions & 2 deletions

File tree

internal/api/passkey_manage.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,13 @@ func (a *API) PasskeyDelete(w http.ResponseWriter, r *http.Request) error {
112112
ctx := r.Context()
113113
config := a.config
114114
user := getUser(ctx)
115+
session := getSession(ctx)
115116
db := a.db.WithContext(ctx)
116117

118+
if err := requirePasskeyManagementAAL(user, session); err != nil {
119+
return err
120+
}
121+
117122
passkeyID, err := uuid.FromString(chi.URLParam(r, "passkey_id"))
118123
if err != nil {
119124
return apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "Passkey not found")

internal/api/passkey_manage_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,41 @@ func (ts *PasskeyTestSuite) TestPasskeyDeleteUnauthenticated() {
238238
ts.Equal(http.StatusUnauthorized, w.Code)
239239
}
240240

241+
// TestPasskeyDeleteBlockedAtAAL1WhenMFAEnabled verifies that a user enrolled in a
242+
// verified MFA factor cannot delete a passkey from an AAL1 session.
243+
func (ts *PasskeyTestSuite) TestPasskeyDeleteBlockedAtAAL1WhenMFAEnabled() {
244+
ts.enrollVerifiedFactor(ts.TestUser)
245+
cred := ts.createTestPasskey(ts.TestUser.ID, "Protected")
246+
247+
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
248+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), nil, withBearerToken(token))
249+
250+
ts.Equal(http.StatusForbidden, w.Code)
251+
var errResp map[string]any
252+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errResp))
253+
ts.Equal("insufficient_aal", errResp["error_code"])
254+
255+
// The passkey must still exist.
256+
_, err := models.FindWebAuthnCredentialByID(ts.API.db, cred.ID)
257+
require.NoError(ts.T(), err)
258+
}
259+
260+
// TestPasskeyDeleteAllowedAtAAL2WhenMFAEnabled verifies that a user with a verified
261+
// MFA factor who has stepped up to AAL2 can delete a passkey.
262+
func (ts *PasskeyTestSuite) TestPasskeyDeleteAllowedAtAAL2WhenMFAEnabled() {
263+
f := ts.enrollVerifiedFactor(ts.TestUser)
264+
ts.elevateSessionToAAL2(ts.TestSession, f.ID)
265+
cred := ts.createTestPasskey(ts.TestUser.ID, "To Delete")
266+
267+
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
268+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), nil, withBearerToken(token))
269+
270+
ts.Equal(http.StatusNoContent, w.Code)
271+
272+
_, err := models.FindWebAuthnCredentialByID(ts.API.db, cred.ID)
273+
ts.True(models.IsNotFoundError(err))
274+
}
275+
241276
func (ts *PasskeyTestSuite) TestPasskeyManageDisabled() {
242277
ts.Config.Passkey.Enabled = false
243278
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)

internal/api/passkey_registration.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,17 @@ func (a *API) PasskeyRegistrationOptions(w http.ResponseWriter, r *http.Request)
4343
ctx := r.Context()
4444
config := a.config
4545
user := getUser(ctx)
46+
session := getSession(ctx)
4647
db := a.db.WithContext(ctx)
4748

4849
if user.IsSSOUser {
4950
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeValidationFailed, "SSO users cannot register passkeys")
5051
}
5152

53+
if err := requirePasskeyManagementAAL(user, session); err != nil {
54+
return err
55+
}
56+
5257
// Check passkey limit
5358
count, err := models.CountWebAuthnCredentialsByUserID(db, user.ID)
5459
if err != nil {
@@ -78,7 +83,7 @@ func (a *API) PasskeyRegistrationOptions(w http.ResponseWriter, r *http.Request)
7883
}
7984

8085
webAuthnUser := newWebAuthnUser(user, existingCreds)
81-
options, session, err := webAuthn.BeginRegistration(webAuthnUser, webauthn.WithExclusions(excludeList))
86+
options, webAuthnSessionData, err := webAuthn.BeginRegistration(webAuthnUser, webauthn.WithExclusions(excludeList))
8287
if err != nil {
8388
return apierrors.NewInternalServerError("Failed to generate WebAuthn registration options").WithInternalError(err)
8489
}
@@ -87,7 +92,7 @@ func (a *API) PasskeyRegistrationOptions(w http.ResponseWriter, r *http.Request)
8792
challenge := models.NewWebAuthnChallenge(
8893
&user.ID,
8994
models.WebAuthnChallengeTypeRegistration,
90-
&models.WebAuthnSessionData{SessionData: session},
95+
&models.WebAuthnSessionData{SessionData: webAuthnSessionData},
9196
expiresAt,
9297
)
9398

@@ -108,12 +113,17 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request)
108113
ctx := r.Context()
109114
config := a.config
110115
user := getUser(ctx)
116+
session := getSession(ctx)
111117
db := a.db.WithContext(ctx)
112118

113119
if user.IsSSOUser {
114120
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeValidationFailed, "SSO users cannot register passkeys")
115121
}
116122

123+
if err := requirePasskeyManagementAAL(user, session); err != nil {
124+
return err
125+
}
126+
117127
params := &PasskeyRegistrationVerifyParams{}
118128
body, err := utilities.GetBodyBytes(r)
119129
if err != nil {

internal/api/passkey_registration_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,83 @@ func (ts *PasskeyTestSuite) TestRegisterVerifySSOUser() {
364364

365365
ts.Equal(http.StatusUnprocessableEntity, w.Code)
366366
}
367+
368+
// TestRegistrationOptionsBlockedAtAAL1WhenMFAEnabled verifies that a user enrolled
369+
// in a verified MFA factor cannot get registration options from an AAL1 session.
370+
func (ts *PasskeyTestSuite) TestRegistrationOptionsBlockedAtAAL1WhenMFAEnabled() {
371+
ts.enrollVerifiedFactor(ts.TestUser)
372+
373+
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
374+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/options", nil, withBearerToken(token))
375+
376+
ts.Equal(http.StatusForbidden, w.Code)
377+
var errResp map[string]any
378+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errResp))
379+
ts.Equal("insufficient_aal", errResp["error_code"])
380+
}
381+
382+
// TestRegistrationVerifyBlockedAtAAL1WhenMFAEnabled verifies that the AAL2 gate on
383+
// verify runs before the registration challenge is consumed, so a blocked request
384+
// leaves the challenge intact.
385+
func (ts *PasskeyTestSuite) TestRegistrationVerifyBlockedAtAAL1WhenMFAEnabled() {
386+
ts.enrollVerifiedFactor(ts.TestUser)
387+
388+
challenge := models.NewWebAuthnChallenge(
389+
&ts.TestUser.ID,
390+
models.WebAuthnChallengeTypeRegistration,
391+
dummySessionData(),
392+
time.Now().Add(5*time.Minute),
393+
)
394+
require.NoError(ts.T(), ts.API.db.Create(challenge))
395+
396+
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
397+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
398+
"challenge_id": challenge.ID.String(),
399+
"credential": map[string]any{},
400+
}, withBearerToken(token))
401+
402+
ts.Equal(http.StatusForbidden, w.Code)
403+
var errResp map[string]any
404+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errResp))
405+
ts.Equal("insufficient_aal", errResp["error_code"])
406+
407+
// The challenge must not have been consumed by a request that failed the gate.
408+
_, err := models.FindWebAuthnChallengeByID(ts.API.db, challenge.ID)
409+
require.NoError(ts.T(), err)
410+
}
411+
412+
// TestRegisterPasskeyAAL2HappyPathWhenMFAEnabled verifies that a user with a
413+
// verified MFA factor who has stepped up to AAL2 can still register a passkey
414+
// end-to-end (both the options and verify endpoints pass the gate).
415+
func (ts *PasskeyTestSuite) TestRegisterPasskeyAAL2HappyPathWhenMFAEnabled() {
416+
f := ts.enrollVerifiedFactor(ts.TestUser)
417+
ts.elevateSessionToAAL2(ts.TestSession, f.ID)
418+
419+
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
420+
421+
// Step 1: options succeed at AAL2
422+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/options", nil, withBearerToken(token))
423+
ts.Require().Equal(http.StatusOK, w.Code)
424+
425+
var optionsResp PasskeyRegistrationOptionsResponse
426+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&optionsResp))
427+
428+
// Step 2: simulate the authenticator creating a credential
429+
authenticator := &virtualAuthenticator{
430+
rpID: ts.Config.WebAuthn.RPID,
431+
origin: ts.Config.WebAuthn.RPOrigins[0],
432+
}
433+
credResp, err := authenticator.createCredential(optionsResp.Options)
434+
require.NoError(ts.T(), err)
435+
436+
// Step 3: verify succeeds at AAL2
437+
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
438+
"challenge_id": optionsResp.ChallengeID,
439+
"credential": json.RawMessage(credResp.JSON),
440+
}, withBearerToken(token))
441+
ts.Require().Equal(http.StatusOK, w.Code)
442+
443+
var passkeyResp PasskeyMetadataResponse
444+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&passkeyResp))
445+
ts.NotEmpty(passkeyResp.ID)
446+
}

internal/api/passkey_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,22 @@ func (ts *PasskeyTestSuite) generateToken(user *models.User, sessionID *uuid.UUI
8585
return token
8686
}
8787

88+
// enrollVerifiedFactor creates a verified TOTP MFA factor for the user so that
89+
// user.HasMFAEnabled() returns true, exercising the passkey AAL2 step-up gate.
90+
func (ts *PasskeyTestSuite) enrollVerifiedFactor(user *models.User) *models.Factor {
91+
f := models.NewTOTPFactor(user, fmt.Sprintf("factor-%s", uuid.Must(uuid.NewV4()).String()[:8]))
92+
require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey))
93+
require.NoError(ts.T(), ts.API.db.Create(f))
94+
require.NoError(ts.T(), f.UpdateStatus(ts.API.db, models.FactorStateVerified))
95+
return f
96+
}
97+
98+
// elevateSessionToAAL2 marks the session as AAL2 in the database, simulating a
99+
// completed MFA verification.
100+
func (ts *PasskeyTestSuite) elevateSessionToAAL2(session *models.Session, factorID uuid.UUID) {
101+
require.NoError(ts.T(), session.UpdateAALAndAssociatedFactor(ts.API.db, models.AAL2, &factorID))
102+
}
103+
88104
// requestOption configures an HTTP request built by makeRequest.
89105
type requestOption func(*http.Request)
90106

internal/api/passkey_webauthn.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,24 @@ package api
33
import (
44
"github.com/go-webauthn/webauthn/protocol"
55
"github.com/go-webauthn/webauthn/webauthn"
6+
"github.com/supabase/auth/internal/api/apierrors"
67
"github.com/supabase/auth/internal/models"
78
)
89

10+
// requirePasskeyManagementAAL enforces an AAL2 session for passkey credential
11+
// management (registration and deletion) when the user has a verified MFA
12+
// factor.
13+
//
14+
// When the user has no verified factor the check is skipped: their maximum
15+
// assurance level is AAL1, so requiring AAL2 could never be satisfied. The
16+
// check fails closed, treating a missing session as not meeting AAL2.
17+
func requirePasskeyManagementAAL(user *models.User, session *models.Session) error {
18+
if user.HasMFAEnabled() && (session == nil || !session.IsAAL2()) {
19+
return apierrors.NewForbiddenError(apierrors.ErrorCodeInsufficientAAL, "AAL2 session is required to manage passkeys when MFA is enabled")
20+
}
21+
return nil
22+
}
23+
924
// getPasskeyWebAuthn creates a *webauthn.WebAuthn instance from the shared server-side WebAuthn configuration.
1025
func (a *API) getPasskeyWebAuthn() (*webauthn.WebAuthn, error) {
1126
rpConfig := a.config.WebAuthn

0 commit comments

Comments
 (0)