Skip to content

Commit c7b58be

Browse files
authored
feat(passkeys): add CAPTCHA to options endpoint for authentication (#2416)
* Adds CAPTCHA to the `/passkeys/authentication/options` endpoint. * Refactors the CAPTCHA implementation to use DI for easier mocking in tests while keeping a small subset of live tests against the provider APIs with the test tokens
1 parent 33f81f7 commit c7b58be

11 files changed

Lines changed: 295 additions & 104 deletions

internal/api/api.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/supabase/auth/internal/models"
2222
"github.com/supabase/auth/internal/observability"
2323
"github.com/supabase/auth/internal/sbff"
24+
"github.com/supabase/auth/internal/security"
2425
"github.com/supabase/auth/internal/storage"
2526
"github.com/supabase/auth/internal/tokens"
2627
"github.com/supabase/auth/internal/utilities"
@@ -48,6 +49,8 @@ type API struct {
4849
mailer mailer.Mailer
4950
oidcCache *provider.OIDCProviderCache
5051

52+
captchaVerifier security.CaptchaVerifier
53+
5154
// overrideTime can be used to override the clock used by handlers. Should only be used in tests!
5255
overrideTime func() time.Time
5356

@@ -103,6 +106,9 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
103106
for _, o := range opt {
104107
o.apply(api)
105108
}
109+
if api.captchaVerifier == nil {
110+
api.captchaVerifier = security.NewCaptchaVerifier(&globalConfig.Security.Captcha)
111+
}
106112
if api.limiterOpts == nil {
107113
api.limiterOpts = NewLimiterOptions(globalConfig)
108114
}
@@ -308,6 +314,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
308314

309315
r.Route("/authentication", func(r *router) {
310316
r.With(api.limitHandler(api.limiterOpts.PasskeyAuthentication)).
317+
With(api.verifyCaptcha).
311318
Post("/options", api.PasskeyAuthenticationOptions)
312319
r.Post("/verify", api.PasskeyAuthenticationVerify)
313320
})

internal/api/captcha_mock_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package api
2+
3+
import (
4+
"context"
5+
6+
"github.com/supabase/auth/internal/security"
7+
)
8+
9+
// MockCaptchaVerifier is a mock implementation of security.CaptchaVerifier.
10+
type MockCaptchaVerifier struct {
11+
Result *security.VerificationResponse
12+
Err error
13+
LastToken string
14+
LastClientIP string
15+
}
16+
17+
func (m *MockCaptchaVerifier) Verify(ctx context.Context, token, clientIP string) (*security.VerificationResponse, error) {
18+
m.LastToken = token
19+
m.LastClientIP = clientIP
20+
21+
if m.Err != nil {
22+
return nil, m.Err
23+
}
24+
25+
return m.Result, nil
26+
}

internal/api/helpers.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"github.com/supabase/auth/internal/api/shared"
1111
"github.com/supabase/auth/internal/conf"
1212
"github.com/supabase/auth/internal/models"
13-
"github.com/supabase/auth/internal/security"
1413

1514
"github.com/supabase/auth/internal/utilities"
1615
)
@@ -69,7 +68,7 @@ type RequestParams interface {
6968
VerifyParams |
7069
adminUserUpdateFactorParams |
7170
adminUserDeleteParams |
72-
security.GotrueRequest |
71+
captchaRequest |
7372
ChallengeFactorParams |
7473

7574
struct {

internal/api/middleware.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,21 @@ import (
2121
"github.com/supabase/auth/internal/models"
2222
"github.com/supabase/auth/internal/observability"
2323
"github.com/supabase/auth/internal/sbff"
24-
"github.com/supabase/auth/internal/security"
2524
"github.com/supabase/auth/internal/utilities"
2625

2726
"github.com/didip/tollbooth/v5"
2827
"github.com/didip/tollbooth/v5/limiter"
2928
jwt "github.com/golang-jwt/jwt/v5"
3029
)
3130

31+
type captchaRequest struct {
32+
Security captchaSecurity `json:"gotrue_meta_security"`
33+
}
34+
35+
type captchaSecurity struct {
36+
Token string `json:"captcha_token"`
37+
}
38+
3239
type FunctionHooks map[string][]string
3340

3441
type AuthMicroserviceClaims struct {
@@ -217,12 +224,21 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
217224
return ctx, nil
218225
}
219226

220-
body := &security.GotrueRequest{}
227+
body := &captchaRequest{}
221228
if err := retrieveRequestParams(req, body); err != nil {
222229
return nil, err
223230
}
224231

225-
verificationResult, err := security.VerifyRequest(body, utilities.GetIPAddress(req), strings.TrimSpace(config.Security.Captcha.Secret), config.Security.Captcha.Provider)
232+
token := strings.TrimSpace(body.Security.Token)
233+
if token == "" {
234+
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeCaptchaFailed, "captcha protection: request disallowed (no captcha_token found)")
235+
}
236+
237+
verificationResult, err := a.captchaVerifier.Verify(
238+
ctx,
239+
token,
240+
utilities.GetIPAddress(req),
241+
)
226242
if err != nil {
227243
return nil, apierrors.NewInternalServerError("captcha verification process failed").WithInternalError(err)
228244
}

internal/api/middleware_test.go

Lines changed: 46 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,28 @@ import (
2020
"github.com/supabase/auth/internal/api/apierrors"
2121
"github.com/supabase/auth/internal/conf"
2222
"github.com/supabase/auth/internal/sbff"
23+
"github.com/supabase/auth/internal/security"
2324
"github.com/supabase/auth/internal/storage"
2425
)
2526

26-
const (
27-
HCaptchaSecret string = "0x0000000000000000000000000000000000000000"
28-
CaptchaResponse string = "10000000-aaaa-bbbb-cccc-000000000001"
29-
TurnstileCaptchaSecret string = "1x0000000000000000000000000000000AA"
30-
)
27+
const captchaResponse string = "10000000-aaaa-bbbb-cccc-000000000001"
3128

3229
type MiddlewareTestSuite struct {
3330
suite.Suite
34-
API *API
35-
Config *conf.GlobalConfiguration
31+
API *API
32+
Config *conf.GlobalConfiguration
33+
CaptchaVerifier *MockCaptchaVerifier
3634
}
3735

3836
func TestMiddlewareFunctions(t *testing.T) {
39-
api, config, err := setupAPIForTest()
37+
mockCaptcha := &MockCaptchaVerifier{}
38+
api, config, err := setupAPIForTest(WithCaptchaVerifier(mockCaptcha))
4039
require.NoError(t, err)
4140

4241
ts := &MiddlewareTestSuite{
43-
API: api,
44-
Config: config,
42+
API: api,
43+
Config: config,
44+
CaptchaVerifier: mockCaptcha,
4545
}
4646
defer api.db.Close()
4747

@@ -50,50 +50,41 @@ func TestMiddlewareFunctions(t *testing.T) {
5050

5151
func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
5252
ts.Config.Security.Captcha.Enabled = true
53+
ts.Config.Security.Captcha.Provider = "hcaptcha"
54+
ts.Config.Security.Captcha.Secret = "test-secret"
55+
56+
// Configure mock to return success
57+
ts.CaptchaVerifier.Result = &security.VerificationResponse{Success: true}
58+
ts.CaptchaVerifier.Err = nil
5359

5460
adminClaims := &AccessTokenClaims{
5561
Role: "supabase_admin",
5662
}
5763
adminJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, adminClaims).SignedString([]byte(ts.Config.JWT.Secret))
5864
require.NoError(ts.T(), err)
5965
cases := []struct {
60-
desc string
61-
adminJwt string
62-
captcha_token string
63-
captcha_provider string
66+
desc string
67+
adminJwt string
68+
captcha_token string
69+
expectVerify bool
6470
}{
6571
{
6672
"Valid captcha response",
6773
"",
68-
CaptchaResponse,
69-
"hcaptcha",
70-
},
71-
{
72-
"Valid captcha response",
73-
"",
74-
CaptchaResponse,
75-
"turnstile",
76-
},
77-
{
78-
"Ignore captcha if admin role is present",
79-
adminJwt,
80-
"",
81-
"hcaptcha",
74+
captchaResponse,
75+
true,
8276
},
8377
{
8478
"Ignore captcha if admin role is present",
8579
adminJwt,
8680
"",
87-
"turnstile",
81+
false,
8882
},
8983
}
9084
for _, c := range cases {
91-
ts.Config.Security.Captcha.Provider = c.captcha_provider
92-
if c.captcha_provider == "turnstile" {
93-
ts.Config.Security.Captcha.Secret = TurnstileCaptchaSecret
94-
} else if c.captcha_provider == "hcaptcha" {
95-
ts.Config.Security.Captcha.Secret = HCaptchaSecret
96-
}
85+
// Reset mock state between cases
86+
ts.CaptchaVerifier.LastToken = ""
87+
ts.CaptchaVerifier.LastClientIP = ""
9788

9889
var buffer bytes.Buffer
9990
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
@@ -117,6 +108,12 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
117108
afterCtx, err := ts.API.verifyCaptcha(w, req)
118109
require.NoError(ts.T(), err)
119110

111+
if c.expectVerify {
112+
require.Equal(ts.T(), c.captcha_token, ts.CaptchaVerifier.LastToken)
113+
} else {
114+
require.Empty(ts.T(), ts.CaptchaVerifier.LastToken)
115+
}
116+
120117
body, err := io.ReadAll(req.Body)
121118
require.NoError(ts.T(), err)
122119

@@ -138,40 +135,41 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
138135
func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
139136
cases := []struct {
140137
desc string
141-
captchaConf *conf.CaptchaConfiguration
138+
errorCodes []string
142139
expectedCode int
143140
expectedMsg string
144141
}{
145142
{
146143
"Captcha validation failed",
147-
&conf.CaptchaConfiguration{
148-
Enabled: true,
149-
Provider: "hcaptcha",
150-
Secret: "test",
151-
},
144+
[]string{"not-using-dummy-secret"},
152145
http.StatusBadRequest,
153146
"captcha protection: request disallowed (not-using-dummy-secret)",
154147
},
155148
{
156149
"Captcha validation failed",
157-
&conf.CaptchaConfiguration{
158-
Enabled: true,
159-
Provider: "turnstile",
160-
Secret: "anothertest",
161-
},
150+
[]string{"invalid-input-secret"},
162151
http.StatusBadRequest,
163152
"captcha protection: request disallowed (invalid-input-secret)",
164153
},
165154
}
166155
for _, c := range cases {
167156
ts.Run(c.desc, func() {
168-
ts.Config.Security.Captcha = *c.captchaConf
157+
ts.Config.Security.Captcha.Enabled = true
158+
ts.Config.Security.Captcha.Provider = "hcaptcha"
159+
ts.Config.Security.Captcha.Secret = "test-secret"
160+
161+
ts.CaptchaVerifier.Result = &security.VerificationResponse{
162+
Success: false,
163+
ErrorCodes: c.errorCodes,
164+
}
165+
ts.CaptchaVerifier.Err = nil
166+
169167
var buffer bytes.Buffer
170168
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
171169
"email": "test@example.com",
172170
"password": "secret",
173171
"gotrue_meta_security": map[string]interface{}{
174-
"captcha_token": CaptchaResponse,
172+
"captcha_token": captchaResponse,
175173
},
176174
}))
177175
req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)

internal/api/options.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/supabase/auth/internal/conf"
99
"github.com/supabase/auth/internal/mailer"
1010
"github.com/supabase/auth/internal/ratelimit"
11+
"github.com/supabase/auth/internal/security"
1112
"github.com/supabase/auth/internal/tokens"
1213
)
1314

@@ -31,6 +32,12 @@ func WithTokenService(service *tokens.Service) Option {
3132
})
3233
}
3334

35+
func WithCaptchaVerifier(v security.CaptchaVerifier) Option {
36+
return optionFunc(func(a *API) {
37+
a.captchaVerifier = v
38+
})
39+
}
40+
3441
type LimiterOptions struct {
3542
Email ratelimit.Limiter
3643
Phone ratelimit.Limiter

0 commit comments

Comments
 (0)