@@ -174,6 +174,52 @@ func (ts *PasskeyTestSuite) TestRegistrationOptionsPasskeyDisabled() {
174174 ts .Equal (http .StatusNotFound , w .Code )
175175}
176176
177+ // TestRegisterVerifyCapEnforcedAtVerifyTime tests that the passkey cap is enforced during verify,
178+ // preventing a race where multiple challenges are issued under the cap but all verified after.
179+ func (ts * PasskeyTestSuite ) TestRegisterVerifyCapEnforcedAtVerifyTime () {
180+ ts .Config .Passkey .MaxPasskeysPerUser = 1
181+
182+ token := ts .generateToken (ts .TestUser , & ts .TestSession .ID )
183+ authenticator := & virtualAuthenticator {
184+ rpID : ts .Config .WebAuthn .RPID ,
185+ origin : ts .Config .WebAuthn .RPOrigins [0 ],
186+ }
187+
188+ // Get two challenges while under the cap (0 existing, cap=1)
189+ w1 := ts .makeAuthenticatedRequest (http .MethodPost , "http://localhost/passkeys/registration/options" , token , nil )
190+ ts .Require ().Equal (http .StatusOK , w1 .Code )
191+ var opts1 PasskeyRegistrationOptionsResponse
192+ require .NoError (ts .T (), json .NewDecoder (w1 .Body ).Decode (& opts1 ))
193+
194+ w2 := ts .makeAuthenticatedRequest (http .MethodPost , "http://localhost/passkeys/registration/options" , token , nil )
195+ ts .Require ().Equal (http .StatusOK , w2 .Code )
196+ var opts2 PasskeyRegistrationOptionsResponse
197+ require .NoError (ts .T (), json .NewDecoder (w2 .Body ).Decode (& opts2 ))
198+
199+ // Simulate authenticator responses for both challenges
200+ cred1 , err := authenticator .createCredential (opts1 .Options )
201+ require .NoError (ts .T (), err )
202+ cred2 , err := authenticator .createCredential (opts2 .Options )
203+ require .NoError (ts .T (), err )
204+
205+ // Verify the first challenge — should succeed
206+ v1 := ts .makeAuthenticatedRequest (http .MethodPost , "http://localhost/passkeys/registration/verify" , token , map [string ]any {
207+ "challenge_id" : opts1 .ChallengeID ,
208+ "credential_response" : json .RawMessage (cred1 .JSON ),
209+ })
210+ ts .Require ().Equal (http .StatusOK , v1 .Code )
211+
212+ // Verify the second challenge — should fail with too_many_passkeys
213+ v2 := ts .makeAuthenticatedRequest (http .MethodPost , "http://localhost/passkeys/registration/verify" , token , map [string ]any {
214+ "challenge_id" : opts2 .ChallengeID ,
215+ "credential_response" : json .RawMessage (cred2 .JSON ),
216+ })
217+ ts .Equal (http .StatusUnprocessableEntity , v2 .Code )
218+ var errResp map [string ]any
219+ require .NoError (ts .T (), json .NewDecoder (v2 .Body ).Decode (& errResp ))
220+ ts .Equal ("too_many_passkeys" , errResp ["error_code" ])
221+ }
222+
177223// TestRegisterVerifyChallengeNotFound tests that a missing challenge returns the correct error.
178224func (ts * PasskeyTestSuite ) TestRegisterVerifyChallengeNotFound () {
179225 token := ts .generateToken (ts .TestUser , & ts .TestSession .ID )
0 commit comments