Skip to content

Commit 9868df6

Browse files
committed
fix(passkeys): enforce passkey cap during registration verify
1 parent dba676e commit 9868df6

2 files changed

Lines changed: 57 additions & 0 deletions

File tree

internal/api/passkey_registration.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,14 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request)
190190
passkeyCredential := models.NewWebAuthnCredential(user.ID, credential, "")
191191

192192
err = db.Transaction(func(tx *storage.Connection) error {
193+
count, terr := models.CountWebAuthnCredentialsByUserID(tx, user.ID)
194+
if terr != nil {
195+
return terr
196+
}
197+
if count >= config.Passkey.MaxPasskeysPerUser {
198+
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeTooManyPasskeys, "Maximum number of passkeys reached")
199+
}
200+
193201
if terr := tx.Create(passkeyCredential); terr != nil {
194202
if models.IsUniqueConstraintViolatedError(terr) {
195203
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeWebAuthnCredentialExists, "This credential is already registered")
@@ -211,6 +219,9 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request)
211219
return nil
212220
})
213221
if err != nil {
222+
if httpErr, ok := err.(*apierrors.HTTPError); ok {
223+
return httpErr
224+
}
214225
return apierrors.NewInternalServerError("Database error creating passkey").WithInternalError(err)
215226
}
216227

internal/api/passkey_registration_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
178224
func (ts *PasskeyTestSuite) TestRegisterVerifyChallengeNotFound() {
179225
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)

0 commit comments

Comments
 (0)