Skip to content

Commit 30b3aeb

Browse files
authored
feat(passkeys): add rate limiter on the /options endpoint for authentication (#2422)
Adds rate limiter to the `/passkeys/authentication/options` endpoint to restrict challenge creation.
1 parent e00ff1a commit 30b3aeb

File tree

7 files changed

+106
-84
lines changed

7 files changed

+106
-84
lines changed

internal/api/api.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,8 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
307307
r.Use(api.requirePasskeyEnabled)
308308

309309
r.Route("/authentication", func(r *router) {
310-
r.Post("/options", api.PasskeyAuthenticationOptions)
310+
r.With(api.limitHandler(api.limiterOpts.PasskeyAuthentication)).
311+
Post("/options", api.PasskeyAuthenticationOptions)
311312
r.Post("/verify", api.PasskeyAuthenticationVerify)
312313
})
313314

internal/api/options.go

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,22 @@ type LimiterOptions struct {
3535
Email ratelimit.Limiter
3636
Phone ratelimit.Limiter
3737

38-
Signups *limiter.Limiter
39-
AnonymousSignIns *limiter.Limiter
40-
Recover *limiter.Limiter
41-
Resend *limiter.Limiter
42-
MagicLink *limiter.Limiter
43-
Otp *limiter.Limiter
44-
Token *limiter.Limiter
45-
Verify *limiter.Limiter
46-
User *limiter.Limiter
47-
FactorVerify *limiter.Limiter
48-
FactorChallenge *limiter.Limiter
49-
SSO *limiter.Limiter
50-
SAMLAssertion *limiter.Limiter
51-
Web3 *limiter.Limiter
52-
OAuthClientRegister *limiter.Limiter
38+
Signups *limiter.Limiter
39+
AnonymousSignIns *limiter.Limiter
40+
Recover *limiter.Limiter
41+
Resend *limiter.Limiter
42+
MagicLink *limiter.Limiter
43+
Otp *limiter.Limiter
44+
Token *limiter.Limiter
45+
Verify *limiter.Limiter
46+
User *limiter.Limiter
47+
FactorVerify *limiter.Limiter
48+
FactorChallenge *limiter.Limiter
49+
SSO *limiter.Limiter
50+
SAMLAssertion *limiter.Limiter
51+
Web3 *limiter.Limiter
52+
OAuthClientRegister *limiter.Limiter
53+
PasskeyAuthentication *limiter.Limiter
5354
}
5455

5556
func (lo *LimiterOptions) apply(a *API) { a.limiterOpts = lo }
@@ -108,6 +109,7 @@ func NewLimiterOptions(gc *conf.GlobalConfiguration) *LimiterOptions {
108109
o.User = newLimiterPer5mOver1h(gc.RateLimitOtp)
109110
o.Signups = newLimiterPer5mOver1h(gc.RateLimitOtp)
110111
o.OAuthClientRegister = newLimiterPer5mOver1h(gc.RateLimitOAuthDynamicClientRegister)
112+
o.PasskeyAuthentication = newLimiterPer5mOver1h(gc.RateLimitPasskey)
111113

112114
return o
113115
}

internal/api/passkey_authentication_test.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,20 @@ func (ts *PasskeyTestSuite) TestAuthenticationPasskeyDisabled() {
201201
ts.Equal(http.StatusNotFound, w.Code)
202202
}
203203

204+
// TestAuthenticationOptionsRateLimited tests that the passkey authentication options endpoint is rate limited.
205+
func (ts *PasskeyTestSuite) TestAuthenticationOptionsRateLimited() {
206+
// The passkey authentication limiter has a burst of 30 (from newLimiterPer5mOver1h).
207+
// Send 30 requests that consume the burst, then verify the 31st is rejected.
208+
for i := 0; i < 30; i++ {
209+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/options", nil, withHeader(ts.Config.RateLimitHeader, "1.2.3.4"))
210+
require.Equal(ts.T(), http.StatusOK, w.Code)
211+
}
212+
213+
// 31st request should be rate limited
214+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/options", nil, withHeader(ts.Config.RateLimitHeader, "1.2.3.4"))
215+
require.Equal(ts.T(), http.StatusTooManyRequests, w.Code)
216+
}
217+
204218
// registerPasskey is a test helper that registers a passkey for the test user
205219
// and returns the authenticator (with stored credential) for later assertion.
206220
func (ts *PasskeyTestSuite) registerPasskey() (*virtualAuthenticator, *PasskeyMetadataResponse) {
@@ -210,7 +224,7 @@ func (ts *PasskeyTestSuite) registerPasskey() (*virtualAuthenticator, *PasskeyMe
210224
origin: ts.Config.WebAuthn.RPOrigins[0],
211225
}
212226

213-
w := ts.makeAuthenticatedRequest(http.MethodPost, "http://localhost/passkeys/registration/options", token, nil)
227+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/options", nil, withBearerToken(token))
214228
ts.Require().Equal(http.StatusOK, w.Code)
215229

216230
var optionsResp PasskeyRegistrationOptionsResponse
@@ -219,10 +233,10 @@ func (ts *PasskeyTestSuite) registerPasskey() (*virtualAuthenticator, *PasskeyMe
219233
credResp, err := authenticator.createCredential(optionsResp.Options)
220234
require.NoError(ts.T(), err)
221235

222-
w = ts.makeAuthenticatedRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", token, map[string]any{
236+
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
223237
"challenge_id": optionsResp.ChallengeID,
224238
"credential_response": json.RawMessage(credResp.JSON),
225-
})
239+
}, withBearerToken(token))
226240
ts.Require().Equal(http.StatusOK, w.Code)
227241

228242
var passkeyResp PasskeyMetadataResponse

internal/api/passkey_manage_test.go

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func (ts *PasskeyTestSuite) createTestPasskey(userID uuid.UUID, friendlyName str
3131

3232
func (ts *PasskeyTestSuite) TestPasskeyListEmpty() {
3333
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
34-
w := ts.makeAuthenticatedRequest(http.MethodGet, "http://localhost/passkeys", token, nil)
34+
w := ts.makeRequest(http.MethodGet, "http://localhost/passkeys", nil, withBearerToken(token))
3535

3636
ts.Equal(http.StatusOK, w.Code)
3737

@@ -45,7 +45,7 @@ func (ts *PasskeyTestSuite) TestPasskeyListWithPasskeys() {
4545
pk2 := ts.createTestPasskey(ts.TestUser.ID, "My MacBook")
4646

4747
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
48-
w := ts.makeAuthenticatedRequest(http.MethodGet, "http://localhost/passkeys", token, nil)
48+
w := ts.makeRequest(http.MethodGet, "http://localhost/passkeys", nil, withBearerToken(token))
4949

5050
ts.Equal(http.StatusOK, w.Code)
5151

@@ -74,7 +74,7 @@ func (ts *PasskeyTestSuite) TestPasskeyListDoesNotReturnOtherUsersPasskeys() {
7474
ts.createTestPasskey(otherUser.ID, "Other User Passkey")
7575

7676
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
77-
w := ts.makeAuthenticatedRequest(http.MethodGet, "http://localhost/passkeys", token, nil)
77+
w := ts.makeRequest(http.MethodGet, "http://localhost/passkeys", nil, withBearerToken(token))
7878

7979
ts.Equal(http.StatusOK, w.Code)
8080

@@ -93,9 +93,9 @@ func (ts *PasskeyTestSuite) TestPasskeyUpdateFriendlyName() {
9393
cred := ts.createTestPasskey(ts.TestUser.ID, "Old Name")
9494

9595
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
96-
w := ts.makeAuthenticatedRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), token, map[string]any{
96+
w := ts.makeRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), map[string]any{
9797
"friendly_name": "New Name",
98-
})
98+
}, withBearerToken(token))
9999

100100
ts.Equal(http.StatusOK, w.Code)
101101

@@ -113,7 +113,7 @@ func (ts *PasskeyTestSuite) TestPasskeyUpdateMissingFriendlyName() {
113113
cred := ts.createTestPasskey(ts.TestUser.ID, "Name")
114114

115115
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
116-
w := ts.makeAuthenticatedRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), token, map[string]any{})
116+
w := ts.makeRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), map[string]any{}, withBearerToken(token))
117117

118118
ts.Equal(http.StatusBadRequest, w.Code)
119119
}
@@ -122,9 +122,9 @@ func (ts *PasskeyTestSuite) TestPasskeyUpdateFriendlyNameTooLong() {
122122
cred := ts.createTestPasskey(ts.TestUser.ID, "Name")
123123

124124
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
125-
w := ts.makeAuthenticatedRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), token, map[string]any{
125+
w := ts.makeRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), map[string]any{
126126
"friendly_name": strings.Repeat("a", 121),
127-
})
127+
}, withBearerToken(token))
128128

129129
ts.Equal(http.StatusBadRequest, w.Code)
130130

@@ -139,9 +139,9 @@ func (ts *PasskeyTestSuite) TestPasskeyUpdateFriendlyNameAtMaxLength() {
139139

140140
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
141141
longName := strings.Repeat("a", 120)
142-
w := ts.makeAuthenticatedRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), token, map[string]any{
142+
w := ts.makeRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), map[string]any{
143143
"friendly_name": longName,
144-
})
144+
}, withBearerToken(token))
145145

146146
ts.Equal(http.StatusOK, w.Code)
147147

@@ -157,9 +157,9 @@ func (ts *PasskeyTestSuite) TestPasskeyUpdateOtherUsersPasskey() {
157157
otherCred := ts.createTestPasskey(otherUser.ID, "Other Passkey")
158158

159159
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
160-
w := ts.makeAuthenticatedRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", otherCred.ID), token, map[string]any{
160+
w := ts.makeRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", otherCred.ID), map[string]any{
161161
"friendly_name": "Stolen Passkey",
162-
})
162+
}, withBearerToken(token))
163163

164164
ts.Equal(http.StatusNotFound, w.Code)
165165

@@ -171,18 +171,18 @@ func (ts *PasskeyTestSuite) TestPasskeyUpdateOtherUsersPasskey() {
171171

172172
func (ts *PasskeyTestSuite) TestPasskeyUpdateNonExistent() {
173173
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
174-
w := ts.makeAuthenticatedRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", uuid.Must(uuid.NewV4())), token, map[string]any{
174+
w := ts.makeRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", uuid.Must(uuid.NewV4())), map[string]any{
175175
"friendly_name": "New Name",
176-
})
176+
}, withBearerToken(token))
177177

178178
ts.Equal(http.StatusNotFound, w.Code)
179179
}
180180

181181
func (ts *PasskeyTestSuite) TestPasskeyUpdateInvalidID() {
182182
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
183-
w := ts.makeAuthenticatedRequest(http.MethodPatch, "http://localhost/passkeys/not-a-uuid", token, map[string]any{
183+
w := ts.makeRequest(http.MethodPatch, "http://localhost/passkeys/not-a-uuid", map[string]any{
184184
"friendly_name": "New Name",
185-
})
185+
}, withBearerToken(token))
186186

187187
ts.Equal(http.StatusNotFound, w.Code)
188188
}
@@ -199,7 +199,7 @@ func (ts *PasskeyTestSuite) TestPasskeyDelete() {
199199
cred := ts.createTestPasskey(ts.TestUser.ID, "To Delete")
200200

201201
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
202-
w := ts.makeAuthenticatedRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), token, nil)
202+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", cred.ID), nil, withBearerToken(token))
203203

204204
ts.Equal(http.StatusOK, w.Code)
205205

@@ -219,7 +219,7 @@ func (ts *PasskeyTestSuite) TestPasskeyDeleteOtherUsersPasskey() {
219219
otherCred := ts.createTestPasskey(otherUser.ID, "Other Passkey")
220220

221221
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
222-
w := ts.makeAuthenticatedRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", otherCred.ID), token, nil)
222+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", otherCred.ID), nil, withBearerToken(token))
223223

224224
ts.Equal(http.StatusNotFound, w.Code)
225225

@@ -230,7 +230,7 @@ func (ts *PasskeyTestSuite) TestPasskeyDeleteOtherUsersPasskey() {
230230

231231
func (ts *PasskeyTestSuite) TestPasskeyDeleteNonExistent() {
232232
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
233-
w := ts.makeAuthenticatedRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", uuid.Must(uuid.NewV4())), token, nil)
233+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", uuid.Must(uuid.NewV4())), nil, withBearerToken(token))
234234

235235
ts.Equal(http.StatusNotFound, w.Code)
236236
}
@@ -246,16 +246,16 @@ func (ts *PasskeyTestSuite) TestPasskeyManageDisabled() {
246246
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
247247

248248
// List
249-
w := ts.makeAuthenticatedRequest(http.MethodGet, "http://localhost/passkeys/", token, nil)
249+
w := ts.makeRequest(http.MethodGet, "http://localhost/passkeys/", nil, withBearerToken(token))
250250
ts.Equal(http.StatusNotFound, w.Code)
251251

252252
// Update
253-
w = ts.makeAuthenticatedRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", uuid.Must(uuid.NewV4())), token, map[string]any{
253+
w = ts.makeRequest(http.MethodPatch, fmt.Sprintf("http://localhost/passkeys/%s", uuid.Must(uuid.NewV4())), map[string]any{
254254
"friendly_name": "Name",
255-
})
255+
}, withBearerToken(token))
256256
ts.Equal(http.StatusNotFound, w.Code)
257257

258258
// Delete
259-
w = ts.makeAuthenticatedRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", uuid.Must(uuid.NewV4())), token, nil)
259+
w = ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/passkeys/%s", uuid.Must(uuid.NewV4())), nil, withBearerToken(token))
260260
ts.Equal(http.StatusNotFound, w.Code)
261261
}

0 commit comments

Comments
 (0)