@@ -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+ }
0 commit comments