Skip to content

Commit a6c26af

Browse files
authored
fix: handle new passkeys in config (#5034)
1 parent b2ba75f commit a6c26af

File tree

5 files changed

+226
-0
lines changed

5 files changed

+226
-0
lines changed

pkg/config/auth.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ type (
162162
PasswordRequirements PasswordRequirements `toml:"password_requirements" json:"password_requirements"`
163163
SigningKeysPath string `toml:"signing_keys_path" json:"signing_keys_path"`
164164
SigningKeys []JWK `toml:"-" json:"-"`
165+
Passkey *passkey `toml:"passkey" json:"passkey"`
165166

166167
RateLimit rateLimit `toml:"rate_limit" json:"rate_limit"`
167168
Captcha *captcha `toml:"captcha" json:"captcha"`
@@ -378,6 +379,13 @@ type (
378379
Ethereum ethereum `toml:"ethereum" json:"ethereum"`
379380
}
380381

382+
passkey struct {
383+
Enabled bool `toml:"enabled" json:"enabled"`
384+
RpDisplayName string `toml:"rp_display_name" json:"rp_display_name"`
385+
RpId string `toml:"rp_id" json:"rp_id"`
386+
RpOrigins []string `toml:"rp_origins" json:"rp_origins"`
387+
}
388+
381389
OAuthServer struct {
382390
Enabled bool `toml:"enabled" json:"enabled"`
383391
AllowDynamicRegistration bool `toml:"allow_dynamic_registration" json:"allow_dynamic_registration"`
@@ -407,6 +415,9 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody {
407415
if a.Captcha != nil {
408416
a.Captcha.toAuthConfigBody(&body)
409417
}
418+
if a.Passkey != nil {
419+
a.Passkey.toAuthConfigBody(&body)
420+
}
410421
a.Hook.toAuthConfigBody(&body)
411422
a.MFA.toAuthConfigBody(&body)
412423
a.Sessions.toAuthConfigBody(&body)
@@ -430,6 +441,7 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) {
430441
a.MinimumPasswordLength = cast.IntToUint(ValOrDefault(remoteConfig.PasswordMinLength, 0))
431442
prc := ValOrDefault(remoteConfig.PasswordRequiredCharacters, "")
432443
a.PasswordRequirements = NewPasswordRequirement(v1API.UpdateAuthConfigBodyPasswordRequiredCharacters(prc))
444+
a.Passkey.fromAuthConfig(remoteConfig)
433445
a.RateLimit.fromAuthConfig(remoteConfig)
434446
if s := a.Email.Smtp; s != nil && s.Enabled {
435447
a.RateLimit.EmailSent = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitEmailSent, 0))
@@ -489,6 +501,28 @@ func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) {
489501
c.Enabled = ValOrDefault(remoteConfig.SecurityCaptchaEnabled, false)
490502
}
491503

504+
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+
}
510+
}
511+
512+
func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) {
513+
// When local config is not set, we assume platform defaults should not change
514+
if p == nil {
515+
return
516+
}
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+
}
523+
p.Enabled = remoteConfig.PasskeyEnabled
524+
}
525+
492526
func (h hook) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) {
493527
// When local config is not set, we assume platform defaults should not change
494528
if hook := h.BeforeUserCreated; hook != nil {

pkg/config/auth_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,105 @@ func TestCaptchaDiff(t *testing.T) {
212212
})
213213
}
214214

215+
func TestPasskeyConfigMapping(t *testing.T) {
216+
t.Run("serializes passkey config to update body", func(t *testing.T) {
217+
c := newWithDefaults()
218+
c.Passkey = &passkey{
219+
Enabled: true,
220+
RpDisplayName: "Supabase CLI",
221+
RpId: "localhost",
222+
RpOrigins: []string{
223+
"http://127.0.0.1:3000",
224+
"https://localhost:3000",
225+
},
226+
}
227+
// Run test
228+
body := c.ToUpdateAuthConfigBody()
229+
// Check result
230+
if assert.NotNil(t, body.PasskeyEnabled) {
231+
assert.True(t, *body.PasskeyEnabled)
232+
}
233+
assert.Equal(t, "Supabase CLI", ValOrDefault(body.WebauthnRpDisplayName, ""))
234+
assert.Equal(t, "localhost", ValOrDefault(body.WebauthnRpId, ""))
235+
assert.Equal(t, "http://127.0.0.1:3000,https://localhost:3000", ValOrDefault(body.WebauthnRpOrigins, ""))
236+
})
237+
238+
t.Run("does not serialize rp fields when passkey is disabled", func(t *testing.T) {
239+
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+
}
246+
// Run test
247+
body := c.ToUpdateAuthConfigBody()
248+
// Check result
249+
if assert.NotNil(t, body.PasskeyEnabled) {
250+
assert.False(t, *body.PasskeyEnabled)
251+
}
252+
_, err := body.WebauthnRpDisplayName.Get()
253+
assert.Error(t, err)
254+
_, err = body.WebauthnRpId.Get()
255+
assert.Error(t, err)
256+
_, err = body.WebauthnRpOrigins.Get()
257+
assert.Error(t, err)
258+
})
259+
260+
t.Run("hydrates passkey config from remote", func(t *testing.T) {
261+
c := newWithDefaults()
262+
c.Passkey = &passkey{
263+
Enabled: true,
264+
}
265+
// Run test
266+
c.FromRemoteAuthConfig(v1API.AuthConfigResponse{
267+
PasskeyEnabled: true,
268+
WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"),
269+
WebauthnRpId: nullable.NewNullableWithValue("localhost"),
270+
WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000,https://localhost:3000"),
271+
})
272+
// Check result
273+
if assert.NotNil(t, c.Passkey) {
274+
assert.True(t, c.Passkey.Enabled)
275+
assert.Equal(t, "Supabase CLI", c.Passkey.RpDisplayName)
276+
assert.Equal(t, "localhost", c.Passkey.RpId)
277+
assert.Equal(t, []string{
278+
"http://127.0.0.1:3000",
279+
"https://localhost:3000",
280+
}, c.Passkey.RpOrigins)
281+
}
282+
})
283+
284+
t.Run("ignores remote settings when local passkey config is undefined", func(t *testing.T) {
285+
c := newWithDefaults()
286+
// Run test
287+
c.FromRemoteAuthConfig(v1API.AuthConfigResponse{
288+
PasskeyEnabled: true,
289+
WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"),
290+
WebauthnRpId: nullable.NewNullableWithValue("localhost"),
291+
WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000"),
292+
})
293+
// Check result
294+
assert.Nil(t, c.Passkey)
295+
})
296+
}
297+
298+
func TestPasskeyDiff(t *testing.T) {
299+
t.Run("ignores undefined config", func(t *testing.T) {
300+
c := newWithDefaults()
301+
// Run test
302+
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
303+
PasskeyEnabled: true,
304+
WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"),
305+
WebauthnRpId: nullable.NewNullableWithValue("localhost"),
306+
WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000"),
307+
})
308+
// Check error
309+
assert.NoError(t, err)
310+
assert.Empty(t, string(diff))
311+
})
312+
}
313+
215314
func TestHookDiff(t *testing.T) {
216315
t.Run("local and remote enabled", func(t *testing.T) {
217316
c := newWithDefaults()

pkg/config/config.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,11 @@ func (a *auth) Clone() auth {
260260
capt := *a.Captcha
261261
copy.Captcha = &capt
262262
}
263+
if copy.Passkey != nil {
264+
passkey := *a.Passkey
265+
passkey.RpOrigins = slices.Clone(a.Passkey.RpOrigins)
266+
copy.Passkey = &passkey
267+
}
263268
copy.External = maps.Clone(a.External)
264269
if a.Email.Smtp != nil {
265270
mailer := *a.Email.Smtp
@@ -916,6 +921,24 @@ func (c *config) Validate(fsys fs.FS) error {
916921
return errors.Errorf("failed to decode signing keys: %w", err)
917922
}
918923
}
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+
}
939+
}
940+
}
941+
}
919942
if err := c.Auth.Hook.validate(); err != nil {
920943
return err
921944
}

pkg/config/config_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,69 @@ 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) {
78+
config := NewConfig()
79+
fsys := fs.MapFS{
80+
"supabase/config.toml": &fs.MapFile{Data: []byte(`
81+
[auth]
82+
enabled = true
83+
site_url = "http://127.0.0.1:3000"
84+
[auth.passkey]
85+
enabled = true
86+
rp_display_name = "Supabase CLI"
87+
rp_id = "localhost"
88+
rp_origins = ["http://127.0.0.1:3000", "https://localhost:3000"]
89+
`)},
90+
}
91+
// Run test
92+
assert.NoError(t, config.Load("", fsys))
93+
// Check result
94+
if assert.NotNil(t, config.Auth.Passkey) {
95+
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)
98+
assert.Equal(t, []string{
99+
"http://127.0.0.1:3000",
100+
"https://localhost:3000",
101+
}, config.Auth.Passkey.RpOrigins)
102+
}
103+
})
104+
105+
t.Run("passkey enabled requires rp_id", func(t *testing.T) {
106+
config := NewConfig()
107+
fsys := fs.MapFS{
108+
"supabase/config.toml": &fs.MapFile{Data: []byte(`
109+
[auth]
110+
enabled = true
111+
site_url = "http://127.0.0.1:3000"
112+
[auth.passkey]
113+
enabled = true
114+
rp_origins = ["http://127.0.0.1:3000"]
115+
`)},
116+
}
117+
// Run test
118+
err := config.Load("", fsys)
119+
// Check result
120+
assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_id")
121+
})
122+
123+
t.Run("passkey enabled requires rp_origins", func(t *testing.T) {
124+
config := NewConfig()
125+
fsys := fs.MapFS{
126+
"supabase/config.toml": &fs.MapFile{Data: []byte(`
127+
[auth]
128+
enabled = true
129+
site_url = "http://127.0.0.1:3000"
130+
[auth.passkey]
131+
enabled = true
132+
rp_id = "localhost"
133+
`)},
134+
}
135+
// Run test
136+
err := config.Load("", fsys)
137+
// Check result
138+
assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_origins")
139+
})
77140

78141
t.Run("parses experimental pgdelta config", func(t *testing.T) {
79142
config := NewConfig()

pkg/config/templates/config.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ minimum_password_length = 6
177177
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
178178
password_requirements = ""
179179

180+
# Configure passkey sign-ins.
181+
# [auth.passkey]
182+
# enabled = false
183+
# rp_display_name = "Supabase"
184+
# rp_id = "localhost"
185+
# rp_origins = ["http://127.0.0.1:3000"]
186+
180187
[auth.rate_limit]
181188
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
182189
email_sent = 2

0 commit comments

Comments
 (0)