Skip to content

Commit a1942ee

Browse files
authored
feat(auth): add support for configuring passkeys and webauthn (#5077)
1 parent 90be78a commit a1942ee

5 files changed

Lines changed: 128 additions & 49 deletions

File tree

pkg/config/auth.go

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ type (
163163
SigningKeysPath string `toml:"signing_keys_path" json:"signing_keys_path"`
164164
SigningKeys []JWK `toml:"-" json:"-"`
165165
Passkey *passkey `toml:"passkey" json:"passkey"`
166+
Webauthn *webauthn `toml:"webauthn" json:"webauthn"`
166167

167168
RateLimit rateLimit `toml:"rate_limit" json:"rate_limit"`
168169
Captcha *captcha `toml:"captcha" json:"captcha"`
@@ -380,7 +381,10 @@ type (
380381
}
381382

382383
passkey struct {
383-
Enabled bool `toml:"enabled" json:"enabled"`
384+
Enabled bool `toml:"enabled" json:"enabled"`
385+
}
386+
387+
webauthn struct {
384388
RpDisplayName string `toml:"rp_display_name" json:"rp_display_name"`
385389
RpId string `toml:"rp_id" json:"rp_id"`
386390
RpOrigins []string `toml:"rp_origins" json:"rp_origins"`
@@ -418,6 +422,9 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody {
418422
if a.Passkey != nil {
419423
a.Passkey.toAuthConfigBody(&body)
420424
}
425+
if a.Webauthn != nil {
426+
a.Webauthn.toAuthConfigBody(&body)
427+
}
421428
a.Hook.toAuthConfigBody(&body)
422429
a.MFA.toAuthConfigBody(&body)
423430
a.Sessions.toAuthConfigBody(&body)
@@ -442,6 +449,7 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) {
442449
prc := ValOrDefault(remoteConfig.PasswordRequiredCharacters, "")
443450
a.PasswordRequirements = NewPasswordRequirement(v1API.UpdateAuthConfigBodyPasswordRequiredCharacters(prc))
444451
a.Passkey.fromAuthConfig(remoteConfig)
452+
a.Webauthn.fromAuthConfig(remoteConfig)
445453
a.RateLimit.fromAuthConfig(remoteConfig)
446454
if s := a.Email.Smtp; s != nil && s.Enabled {
447455
a.RateLimit.EmailSent = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitEmailSent, 0))
@@ -502,27 +510,33 @@ func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) {
502510
}
503511

504512
func (p passkey) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) {
505-
if body.PasskeyEnabled = cast.Ptr(p.Enabled); p.Enabled {
506-
body.WebauthnRpDisplayName = nullable.NewNullableWithValue(p.RpDisplayName)
507-
body.WebauthnRpId = nullable.NewNullableWithValue(p.RpId)
508-
body.WebauthnRpOrigins = nullable.NewNullableWithValue(strings.Join(p.RpOrigins, ","))
509-
}
513+
body.PasskeyEnabled = cast.Ptr(p.Enabled)
510514
}
511515

512516
func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) {
513517
// When local config is not set, we assume platform defaults should not change
514518
if p == nil {
515519
return
516520
}
517-
// Ignore disabled passkey fields to minimise config diff
518-
if p.Enabled {
519-
p.RpDisplayName = ValOrDefault(remoteConfig.WebauthnRpDisplayName, "")
520-
p.RpId = ValOrDefault(remoteConfig.WebauthnRpId, "")
521-
p.RpOrigins = strToArr(ValOrDefault(remoteConfig.WebauthnRpOrigins, ""))
522-
}
523521
p.Enabled = remoteConfig.PasskeyEnabled
524522
}
525523

524+
func (w webauthn) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) {
525+
body.WebauthnRpDisplayName = nullable.NewNullableWithValue(w.RpDisplayName)
526+
body.WebauthnRpId = nullable.NewNullableWithValue(w.RpId)
527+
body.WebauthnRpOrigins = nullable.NewNullableWithValue(strings.Join(w.RpOrigins, ","))
528+
}
529+
530+
func (w *webauthn) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) {
531+
// When local config is not set, we assume platform defaults should not change
532+
if w == nil {
533+
return
534+
}
535+
w.RpDisplayName = ValOrDefault(remoteConfig.WebauthnRpDisplayName, "")
536+
w.RpId = ValOrDefault(remoteConfig.WebauthnRpId, "")
537+
w.RpOrigins = strToArr(ValOrDefault(remoteConfig.WebauthnRpOrigins, ""))
538+
}
539+
526540
func (h hook) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) {
527541
// When local config is not set, we assume platform defaults should not change
528542
if hook := h.BeforeUserCreated; hook != nil {

pkg/config/auth_test.go

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,8 @@ func TestCaptchaDiff(t *testing.T) {
215215
func TestPasskeyConfigMapping(t *testing.T) {
216216
t.Run("serializes passkey config to update body", func(t *testing.T) {
217217
c := newWithDefaults()
218-
c.Passkey = &passkey{
219-
Enabled: true,
218+
c.Passkey = &passkey{Enabled: true}
219+
c.Webauthn = &webauthn{
220220
RpDisplayName: "Supabase CLI",
221221
RpId: "localhost",
222222
RpOrigins: []string{
@@ -235,14 +235,9 @@ func TestPasskeyConfigMapping(t *testing.T) {
235235
assert.Equal(t, "http://127.0.0.1:3000,https://localhost:3000", ValOrDefault(body.WebauthnRpOrigins, ""))
236236
})
237237

238-
t.Run("does not serialize rp fields when passkey is disabled", func(t *testing.T) {
238+
t.Run("does not serialize rp fields when webauthn is undefined", func(t *testing.T) {
239239
c := newWithDefaults()
240-
c.Passkey = &passkey{
241-
Enabled: false,
242-
RpDisplayName: "Supabase CLI",
243-
RpId: "localhost",
244-
RpOrigins: []string{"http://127.0.0.1:3000"},
245-
}
240+
c.Passkey = &passkey{Enabled: false}
246241
// Run test
247242
body := c.ToUpdateAuthConfigBody()
248243
// Check result
@@ -257,12 +252,27 @@ func TestPasskeyConfigMapping(t *testing.T) {
257252
assert.Error(t, err)
258253
})
259254

260-
t.Run("hydrates passkey config from remote", func(t *testing.T) {
255+
t.Run("serializes webauthn fields independently of passkey", func(t *testing.T) {
261256
c := newWithDefaults()
262-
c.Passkey = &passkey{
263-
Enabled: true,
257+
c.Webauthn = &webauthn{
258+
RpDisplayName: "Supabase CLI",
259+
RpId: "localhost",
260+
RpOrigins: []string{"http://127.0.0.1:3000"},
264261
}
265262
// Run test
263+
body := c.ToUpdateAuthConfigBody()
264+
// Check result
265+
assert.Nil(t, body.PasskeyEnabled)
266+
assert.Equal(t, "Supabase CLI", ValOrDefault(body.WebauthnRpDisplayName, ""))
267+
assert.Equal(t, "localhost", ValOrDefault(body.WebauthnRpId, ""))
268+
assert.Equal(t, "http://127.0.0.1:3000", ValOrDefault(body.WebauthnRpOrigins, ""))
269+
})
270+
271+
t.Run("hydrates passkey and webauthn config from remote", func(t *testing.T) {
272+
c := newWithDefaults()
273+
c.Passkey = &passkey{Enabled: true}
274+
c.Webauthn = &webauthn{}
275+
// Run test
266276
c.FromRemoteAuthConfig(v1API.AuthConfigResponse{
267277
PasskeyEnabled: true,
268278
WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"),
@@ -272,12 +282,14 @@ func TestPasskeyConfigMapping(t *testing.T) {
272282
// Check result
273283
if assert.NotNil(t, c.Passkey) {
274284
assert.True(t, c.Passkey.Enabled)
275-
assert.Equal(t, "Supabase CLI", c.Passkey.RpDisplayName)
276-
assert.Equal(t, "localhost", c.Passkey.RpId)
285+
}
286+
if assert.NotNil(t, c.Webauthn) {
287+
assert.Equal(t, "Supabase CLI", c.Webauthn.RpDisplayName)
288+
assert.Equal(t, "localhost", c.Webauthn.RpId)
277289
assert.Equal(t, []string{
278290
"http://127.0.0.1:3000",
279291
"https://localhost:3000",
280-
}, c.Passkey.RpOrigins)
292+
}, c.Webauthn.RpOrigins)
281293
}
282294
})
283295

@@ -292,6 +304,7 @@ func TestPasskeyConfigMapping(t *testing.T) {
292304
})
293305
// Check result
294306
assert.Nil(t, c.Passkey)
307+
assert.Nil(t, c.Webauthn)
295308
})
296309
}
297310

pkg/config/config.go

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,13 @@ func (a *auth) Clone() auth {
262262
}
263263
if copy.Passkey != nil {
264264
passkey := *a.Passkey
265-
passkey.RpOrigins = slices.Clone(a.Passkey.RpOrigins)
266265
copy.Passkey = &passkey
267266
}
267+
if copy.Webauthn != nil {
268+
webauthn := *a.Webauthn
269+
webauthn.RpOrigins = slices.Clone(a.Webauthn.RpOrigins)
270+
copy.Webauthn = &webauthn
271+
}
268272
copy.External = maps.Clone(a.External)
269273
if a.Email.Smtp != nil {
270274
mailer := *a.Email.Smtp
@@ -921,21 +925,22 @@ func (c *config) Validate(fsys fs.FS) error {
921925
return errors.Errorf("failed to decode signing keys: %w", err)
922926
}
923927
}
924-
if c.Auth.Passkey != nil {
925-
if c.Auth.Passkey.Enabled {
926-
if len(c.Auth.Passkey.RpId) == 0 {
927-
return errors.New("Missing required field in config: auth.passkey.rp_id")
928-
}
929-
if len(c.Auth.Passkey.RpOrigins) == 0 {
930-
return errors.New("Missing required field in config: auth.passkey.rp_origins")
931-
}
932-
if err := assertEnvLoaded(c.Auth.Passkey.RpId); err != nil {
933-
return errors.Errorf("Invalid config for auth.passkey.rp_id: %v", err)
934-
}
935-
for i, origin := range c.Auth.Passkey.RpOrigins {
936-
if err := assertEnvLoaded(origin); err != nil {
937-
return errors.Errorf("Invalid config for auth.passkey.rp_origins[%d]: %v", i, err)
938-
}
928+
if c.Auth.Passkey != nil && c.Auth.Passkey.Enabled {
929+
if c.Auth.Webauthn == nil {
930+
return errors.New("Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)")
931+
}
932+
if len(c.Auth.Webauthn.RpId) == 0 {
933+
return errors.New("Missing required field in config: auth.webauthn.rp_id")
934+
}
935+
if len(c.Auth.Webauthn.RpOrigins) == 0 {
936+
return errors.New("Missing required field in config: auth.webauthn.rp_origins")
937+
}
938+
if err := assertEnvLoaded(c.Auth.Webauthn.RpId); err != nil {
939+
return errors.Errorf("Invalid config for auth.webauthn.rp_id: %v", err)
940+
}
941+
for i, origin := range c.Auth.Webauthn.RpOrigins {
942+
if err := assertEnvLoaded(origin); err != nil {
943+
return errors.Errorf("Invalid config for auth.webauthn.rp_origins[%d]: %v", i, err)
939944
}
940945
}
941946
}

pkg/config/config_test.go

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ func TestConfigParsing(t *testing.T) {
7474
// Run test
7575
assert.Error(t, config.Load("", fsys))
7676
})
77-
t.Run("config file with passkey settings", func(t *testing.T) {
77+
t.Run("config file with passkey and webauthn settings", func(t *testing.T) {
7878
config := NewConfig()
7979
fsys := fs.MapFS{
8080
"supabase/config.toml": &fs.MapFile{Data: []byte(`
@@ -83,6 +83,7 @@ enabled = true
8383
site_url = "http://127.0.0.1:3000"
8484
[auth.passkey]
8585
enabled = true
86+
[auth.webauthn]
8687
rp_display_name = "Supabase CLI"
8788
rp_id = "localhost"
8889
rp_origins = ["http://127.0.0.1:3000", "https://localhost:3000"]
@@ -93,15 +94,56 @@ rp_origins = ["http://127.0.0.1:3000", "https://localhost:3000"]
9394
// Check result
9495
if assert.NotNil(t, config.Auth.Passkey) {
9596
assert.True(t, config.Auth.Passkey.Enabled)
96-
assert.Equal(t, "Supabase CLI", config.Auth.Passkey.RpDisplayName)
97-
assert.Equal(t, "localhost", config.Auth.Passkey.RpId)
97+
}
98+
if assert.NotNil(t, config.Auth.Webauthn) {
99+
assert.Equal(t, "Supabase CLI", config.Auth.Webauthn.RpDisplayName)
100+
assert.Equal(t, "localhost", config.Auth.Webauthn.RpId)
98101
assert.Equal(t, []string{
99102
"http://127.0.0.1:3000",
100103
"https://localhost:3000",
101-
}, config.Auth.Passkey.RpOrigins)
104+
}, config.Auth.Webauthn.RpOrigins)
102105
}
103106
})
104107

108+
t.Run("webauthn section without passkey loads successfully", func(t *testing.T) {
109+
config := NewConfig()
110+
fsys := fs.MapFS{
111+
"supabase/config.toml": &fs.MapFile{Data: []byte(`
112+
[auth]
113+
enabled = true
114+
site_url = "http://127.0.0.1:3000"
115+
[auth.webauthn]
116+
rp_display_name = "Supabase CLI"
117+
rp_id = "localhost"
118+
rp_origins = ["http://127.0.0.1:3000"]
119+
`)},
120+
}
121+
// Run test
122+
assert.NoError(t, config.Load("", fsys))
123+
// Check result
124+
assert.Nil(t, config.Auth.Passkey)
125+
if assert.NotNil(t, config.Auth.Webauthn) {
126+
assert.Equal(t, "localhost", config.Auth.Webauthn.RpId)
127+
}
128+
})
129+
130+
t.Run("passkey enabled requires webauthn section", func(t *testing.T) {
131+
config := NewConfig()
132+
fsys := fs.MapFS{
133+
"supabase/config.toml": &fs.MapFile{Data: []byte(`
134+
[auth]
135+
enabled = true
136+
site_url = "http://127.0.0.1:3000"
137+
[auth.passkey]
138+
enabled = true
139+
`)},
140+
}
141+
// Run test
142+
err := config.Load("", fsys)
143+
// Check result
144+
assert.ErrorContains(t, err, "Missing required config section: auth.webauthn")
145+
})
146+
105147
t.Run("passkey enabled requires rp_id", func(t *testing.T) {
106148
config := NewConfig()
107149
fsys := fs.MapFS{
@@ -111,13 +153,14 @@ enabled = true
111153
site_url = "http://127.0.0.1:3000"
112154
[auth.passkey]
113155
enabled = true
156+
[auth.webauthn]
114157
rp_origins = ["http://127.0.0.1:3000"]
115158
`)},
116159
}
117160
// Run test
118161
err := config.Load("", fsys)
119162
// Check result
120-
assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_id")
163+
assert.ErrorContains(t, err, "Missing required field in config: auth.webauthn.rp_id")
121164
})
122165

123166
t.Run("passkey enabled requires rp_origins", func(t *testing.T) {
@@ -129,13 +172,14 @@ enabled = true
129172
site_url = "http://127.0.0.1:3000"
130173
[auth.passkey]
131174
enabled = true
175+
[auth.webauthn]
132176
rp_id = "localhost"
133177
`)},
134178
}
135179
// Run test
136180
err := config.Load("", fsys)
137181
// Check result
138-
assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_origins")
182+
assert.ErrorContains(t, err, "Missing required field in config: auth.webauthn.rp_origins")
139183
})
140184

141185
t.Run("parses experimental pgdelta config", func(t *testing.T) {

pkg/config/templates/config.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ password_requirements = ""
180180
# Configure passkey sign-ins.
181181
# [auth.passkey]
182182
# enabled = false
183+
184+
# Configure WebAuthn relying party settings (required when passkey is enabled).
185+
# [auth.webauthn]
183186
# rp_display_name = "Supabase"
184187
# rp_id = "localhost"
185188
# rp_origins = ["http://127.0.0.1:3000"]

0 commit comments

Comments
 (0)