Skip to content

Commit 4cc4c2c

Browse files
committed
feat(passkeys): add CAPTCHA to options endpoint for authentication
1 parent a71da22 commit 4cc4c2c

3 files changed

Lines changed: 89 additions & 8 deletions

File tree

internal/api/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
313313
r.Use(api.requirePasskeyEnabled)
314314

315315
r.Route("/authentication", func(r *router) {
316-
r.Post("/options", api.PasskeyAuthenticationOptions)
316+
r.With(api.verifyCaptcha).Post("/options", api.PasskeyAuthenticationOptions)
317317
r.Post("/verify", api.PasskeyAuthenticationVerify)
318318
})
319319

internal/api/passkey_authentication_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/gofrs/uuid"
99
"github.com/stretchr/testify/require"
1010
"github.com/supabase/auth/internal/models"
11+
"github.com/supabase/auth/internal/security"
1112
)
1213

1314
// TestDiscoverableAuthenticationHappyPath tests the full discoverable credential authentication flow.
@@ -201,6 +202,78 @@ func (ts *PasskeyTestSuite) TestAuthenticationPasskeyDisabled() {
201202
ts.Equal(http.StatusNotFound, w.Code)
202203
}
203204

205+
// TestAuthenticationOptionsCaptchaRequired tests that CAPTCHA enabled + no token → 400.
206+
func (ts *PasskeyTestSuite) TestAuthenticationOptionsCaptchaRequired() {
207+
ts.Config.Security.Captcha.Enabled = true
208+
ts.Config.Security.Captcha.Provider = "hcaptcha"
209+
ts.Config.Security.Captcha.Secret = "test-secret"
210+
211+
// No captcha_token in request body
212+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/options", map[string]any{})
213+
ts.Equal(http.StatusBadRequest, w.Code)
214+
215+
var errResp map[string]any
216+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errResp))
217+
ts.Equal("captcha_failed", errResp["error_code"])
218+
}
219+
220+
// TestAuthenticationOptionsCaptchaValid tests that CAPTCHA enabled + valid token → 200.
221+
func (ts *PasskeyTestSuite) TestAuthenticationOptionsCaptchaValid() {
222+
ts.Config.Security.Captcha.Enabled = true
223+
ts.Config.Security.Captcha.Provider = "hcaptcha"
224+
ts.Config.Security.Captcha.Secret = "test-secret"
225+
226+
ts.CaptchaVerifier.Result = &security.VerificationResponse{Success: true}
227+
ts.CaptchaVerifier.Err = nil
228+
229+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/options", map[string]any{
230+
"gotrue_meta_security": map[string]any{
231+
"captcha_token": "valid-token",
232+
},
233+
})
234+
ts.Equal(http.StatusOK, w.Code)
235+
236+
var optionsResp PasskeyAuthenticationOptionsResponse
237+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&optionsResp))
238+
ts.NotEmpty(optionsResp.ChallengeID)
239+
}
240+
241+
// TestAuthenticationOptionsCaptchaInvalid tests that CAPTCHA enabled + mock failure → 400.
242+
func (ts *PasskeyTestSuite) TestAuthenticationOptionsCaptchaInvalid() {
243+
ts.Config.Security.Captcha.Enabled = true
244+
ts.Config.Security.Captcha.Provider = "hcaptcha"
245+
ts.Config.Security.Captcha.Secret = "test-secret"
246+
247+
ts.CaptchaVerifier.Result = &security.VerificationResponse{
248+
Success: false,
249+
ErrorCodes: []string{"invalid-input-response"},
250+
}
251+
ts.CaptchaVerifier.Err = nil
252+
253+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/options", map[string]any{
254+
"gotrue_meta_security": map[string]any{
255+
"captcha_token": "bad-token",
256+
},
257+
})
258+
ts.Equal(http.StatusBadRequest, w.Code)
259+
260+
var errResp map[string]any
261+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errResp))
262+
ts.Equal("captcha_failed", errResp["error_code"])
263+
}
264+
265+
// TestAuthenticationOptionsCaptchaDisabled tests that CAPTCHA disabled → 200 without token.
266+
func (ts *PasskeyTestSuite) TestAuthenticationOptionsCaptchaDisabled() {
267+
ts.Config.Security.Captcha.Enabled = false
268+
269+
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/options", nil)
270+
ts.Equal(http.StatusOK, w.Code)
271+
272+
var optionsResp PasskeyAuthenticationOptionsResponse
273+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&optionsResp))
274+
ts.NotEmpty(optionsResp.ChallengeID)
275+
}
276+
204277
// registerPasskey is a test helper that registers a passkey for the test user
205278
// and returns the authenticator (with stored credential) for later assertion.
206279
func (ts *PasskeyTestSuite) registerPasskey() (*virtualAuthenticator, *PasskeyMetadataResponse) {

internal/api/passkey_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,21 @@ import (
2222
// Flow-specific test methods live in their own files (passkey_register_test.go, etc.).
2323
type PasskeyTestSuite struct {
2424
suite.Suite
25-
API *API
26-
Config *conf.GlobalConfiguration
27-
TestUser *models.User
28-
TestSession *models.Session
25+
API *API
26+
Config *conf.GlobalConfiguration
27+
TestUser *models.User
28+
TestSession *models.Session
29+
CaptchaVerifier *MockCaptchaVerifier
2930
}
3031

3132
func TestPasskey(t *testing.T) {
32-
api, config, err := setupAPIForTest()
33+
mockCaptcha := &MockCaptchaVerifier{}
34+
api, config, err := setupAPIForTest(WithCaptchaVerifier(mockCaptcha))
3335
require.NoError(t, err)
3436
ts := &PasskeyTestSuite{
35-
API: api,
36-
Config: config,
37+
API: api,
38+
Config: config,
39+
CaptchaVerifier: mockCaptcha,
3740
}
3841
defer api.db.Close()
3942
suite.Run(t, ts)
@@ -42,6 +45,11 @@ func TestPasskey(t *testing.T) {
4245
func (ts *PasskeyTestSuite) SetupTest() {
4346
models.TruncateAll(ts.API.db)
4447

48+
// Reset captcha state
49+
ts.Config.Security.Captcha.Enabled = false
50+
ts.CaptchaVerifier.Result = nil
51+
ts.CaptchaVerifier.Err = nil
52+
4553
// Enable passkeys
4654
ts.Config.Passkey.Enabled = true
4755
ts.Config.Passkey.MaxPasskeysPerUser = 10

0 commit comments

Comments
 (0)