diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 82c708e37..c1795c897 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -163,6 +163,7 @@ type ( SigningKeysPath string `toml:"signing_keys_path" json:"signing_keys_path"` SigningKeys []JWK `toml:"-" json:"-"` Passkey *passkey `toml:"passkey" json:"passkey"` + Webauthn *webauthn `toml:"webauthn" json:"webauthn"` RateLimit rateLimit `toml:"rate_limit" json:"rate_limit"` Captcha *captcha `toml:"captcha" json:"captcha"` @@ -380,7 +381,10 @@ type ( } passkey struct { - Enabled bool `toml:"enabled" json:"enabled"` + Enabled bool `toml:"enabled" json:"enabled"` + } + + webauthn struct { RpDisplayName string `toml:"rp_display_name" json:"rp_display_name"` RpId string `toml:"rp_id" json:"rp_id"` RpOrigins []string `toml:"rp_origins" json:"rp_origins"` @@ -418,6 +422,9 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody { if a.Passkey != nil { a.Passkey.toAuthConfigBody(&body) } + if a.Webauthn != nil { + a.Webauthn.toAuthConfigBody(&body) + } a.Hook.toAuthConfigBody(&body) a.MFA.toAuthConfigBody(&body) a.Sessions.toAuthConfigBody(&body) @@ -442,6 +449,7 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) { prc := ValOrDefault(remoteConfig.PasswordRequiredCharacters, "") a.PasswordRequirements = NewPasswordRequirement(v1API.UpdateAuthConfigBodyPasswordRequiredCharacters(prc)) a.Passkey.fromAuthConfig(remoteConfig) + a.Webauthn.fromAuthConfig(remoteConfig) a.RateLimit.fromAuthConfig(remoteConfig) if s := a.Email.Smtp; s != nil && s.Enabled { a.RateLimit.EmailSent = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitEmailSent, 0)) @@ -502,11 +510,7 @@ func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { } func (p passkey) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - if body.PasskeyEnabled = cast.Ptr(p.Enabled); p.Enabled { - body.WebauthnRpDisplayName = nullable.NewNullableWithValue(p.RpDisplayName) - body.WebauthnRpId = nullable.NewNullableWithValue(p.RpId) - body.WebauthnRpOrigins = nullable.NewNullableWithValue(strings.Join(p.RpOrigins, ",")) - } + body.PasskeyEnabled = cast.Ptr(p.Enabled) } func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { @@ -514,15 +518,25 @@ func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { if p == nil { return } - // Ignore disabled passkey fields to minimise config diff - if p.Enabled { - p.RpDisplayName = ValOrDefault(remoteConfig.WebauthnRpDisplayName, "") - p.RpId = ValOrDefault(remoteConfig.WebauthnRpId, "") - p.RpOrigins = strToArr(ValOrDefault(remoteConfig.WebauthnRpOrigins, "")) - } p.Enabled = remoteConfig.PasskeyEnabled } +func (w webauthn) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { + body.WebauthnRpDisplayName = nullable.NewNullableWithValue(w.RpDisplayName) + body.WebauthnRpId = nullable.NewNullableWithValue(w.RpId) + body.WebauthnRpOrigins = nullable.NewNullableWithValue(strings.Join(w.RpOrigins, ",")) +} + +func (w *webauthn) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { + // When local config is not set, we assume platform defaults should not change + if w == nil { + return + } + w.RpDisplayName = ValOrDefault(remoteConfig.WebauthnRpDisplayName, "") + w.RpId = ValOrDefault(remoteConfig.WebauthnRpId, "") + w.RpOrigins = strToArr(ValOrDefault(remoteConfig.WebauthnRpOrigins, "")) +} + func (h hook) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { // When local config is not set, we assume platform defaults should not change if hook := h.BeforeUserCreated; hook != nil { diff --git a/pkg/config/auth_test.go b/pkg/config/auth_test.go index 65f0066da..61ba5b429 100644 --- a/pkg/config/auth_test.go +++ b/pkg/config/auth_test.go @@ -215,8 +215,8 @@ func TestCaptchaDiff(t *testing.T) { func TestPasskeyConfigMapping(t *testing.T) { t.Run("serializes passkey config to update body", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{ - Enabled: true, + c.Passkey = &passkey{Enabled: true} + c.Webauthn = &webauthn{ RpDisplayName: "Supabase CLI", RpId: "localhost", RpOrigins: []string{ @@ -235,14 +235,9 @@ func TestPasskeyConfigMapping(t *testing.T) { assert.Equal(t, "http://127.0.0.1:3000,https://localhost:3000", ValOrDefault(body.WebauthnRpOrigins, "")) }) - t.Run("does not serialize rp fields when passkey is disabled", func(t *testing.T) { + t.Run("does not serialize rp fields when webauthn is undefined", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{ - Enabled: false, - RpDisplayName: "Supabase CLI", - RpId: "localhost", - RpOrigins: []string{"http://127.0.0.1:3000"}, - } + c.Passkey = &passkey{Enabled: false} // Run test body := c.ToUpdateAuthConfigBody() // Check result @@ -257,12 +252,27 @@ func TestPasskeyConfigMapping(t *testing.T) { assert.Error(t, err) }) - t.Run("hydrates passkey config from remote", func(t *testing.T) { + t.Run("serializes webauthn fields independently of passkey", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{ - Enabled: true, + c.Webauthn = &webauthn{ + RpDisplayName: "Supabase CLI", + RpId: "localhost", + RpOrigins: []string{"http://127.0.0.1:3000"}, } // Run test + body := c.ToUpdateAuthConfigBody() + // Check result + assert.Nil(t, body.PasskeyEnabled) + assert.Equal(t, "Supabase CLI", ValOrDefault(body.WebauthnRpDisplayName, "")) + assert.Equal(t, "localhost", ValOrDefault(body.WebauthnRpId, "")) + assert.Equal(t, "http://127.0.0.1:3000", ValOrDefault(body.WebauthnRpOrigins, "")) + }) + + t.Run("hydrates passkey and webauthn config from remote", func(t *testing.T) { + c := newWithDefaults() + c.Passkey = &passkey{Enabled: true} + c.Webauthn = &webauthn{} + // Run test c.FromRemoteAuthConfig(v1API.AuthConfigResponse{ PasskeyEnabled: true, WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"), @@ -272,12 +282,14 @@ func TestPasskeyConfigMapping(t *testing.T) { // Check result if assert.NotNil(t, c.Passkey) { assert.True(t, c.Passkey.Enabled) - assert.Equal(t, "Supabase CLI", c.Passkey.RpDisplayName) - assert.Equal(t, "localhost", c.Passkey.RpId) + } + if assert.NotNil(t, c.Webauthn) { + assert.Equal(t, "Supabase CLI", c.Webauthn.RpDisplayName) + assert.Equal(t, "localhost", c.Webauthn.RpId) assert.Equal(t, []string{ "http://127.0.0.1:3000", "https://localhost:3000", - }, c.Passkey.RpOrigins) + }, c.Webauthn.RpOrigins) } }) @@ -292,6 +304,7 @@ func TestPasskeyConfigMapping(t *testing.T) { }) // Check result assert.Nil(t, c.Passkey) + assert.Nil(t, c.Webauthn) }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 90d81741b..2aa7f99f2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -262,9 +262,13 @@ func (a *auth) Clone() auth { } if copy.Passkey != nil { passkey := *a.Passkey - passkey.RpOrigins = slices.Clone(a.Passkey.RpOrigins) copy.Passkey = &passkey } + if copy.Webauthn != nil { + webauthn := *a.Webauthn + webauthn.RpOrigins = slices.Clone(a.Webauthn.RpOrigins) + copy.Webauthn = &webauthn + } copy.External = maps.Clone(a.External) if a.Email.Smtp != nil { mailer := *a.Email.Smtp @@ -921,21 +925,22 @@ func (c *config) Validate(fsys fs.FS) error { return errors.Errorf("failed to decode signing keys: %w", err) } } - if c.Auth.Passkey != nil { - if c.Auth.Passkey.Enabled { - if len(c.Auth.Passkey.RpId) == 0 { - return errors.New("Missing required field in config: auth.passkey.rp_id") - } - if len(c.Auth.Passkey.RpOrigins) == 0 { - return errors.New("Missing required field in config: auth.passkey.rp_origins") - } - if err := assertEnvLoaded(c.Auth.Passkey.RpId); err != nil { - return errors.Errorf("Invalid config for auth.passkey.rp_id: %v", err) - } - for i, origin := range c.Auth.Passkey.RpOrigins { - if err := assertEnvLoaded(origin); err != nil { - return errors.Errorf("Invalid config for auth.passkey.rp_origins[%d]: %v", i, err) - } + if c.Auth.Passkey != nil && c.Auth.Passkey.Enabled { + if c.Auth.Webauthn == nil { + return errors.New("Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)") + } + if len(c.Auth.Webauthn.RpId) == 0 { + return errors.New("Missing required field in config: auth.webauthn.rp_id") + } + if len(c.Auth.Webauthn.RpOrigins) == 0 { + return errors.New("Missing required field in config: auth.webauthn.rp_origins") + } + if err := assertEnvLoaded(c.Auth.Webauthn.RpId); err != nil { + return errors.Errorf("Invalid config for auth.webauthn.rp_id: %v", err) + } + for i, origin := range c.Auth.Webauthn.RpOrigins { + if err := assertEnvLoaded(origin); err != nil { + return errors.Errorf("Invalid config for auth.webauthn.rp_origins[%d]: %v", i, err) } } } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 695733116..f019b7cbc 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -74,7 +74,7 @@ func TestConfigParsing(t *testing.T) { // Run test assert.Error(t, config.Load("", fsys)) }) - t.Run("config file with passkey settings", func(t *testing.T) { + t.Run("config file with passkey and webauthn settings", func(t *testing.T) { config := NewConfig() fsys := fs.MapFS{ "supabase/config.toml": &fs.MapFile{Data: []byte(` @@ -83,6 +83,7 @@ enabled = true site_url = "http://127.0.0.1:3000" [auth.passkey] enabled = true +[auth.webauthn] rp_display_name = "Supabase CLI" rp_id = "localhost" 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"] // Check result if assert.NotNil(t, config.Auth.Passkey) { assert.True(t, config.Auth.Passkey.Enabled) - assert.Equal(t, "Supabase CLI", config.Auth.Passkey.RpDisplayName) - assert.Equal(t, "localhost", config.Auth.Passkey.RpId) + } + if assert.NotNil(t, config.Auth.Webauthn) { + assert.Equal(t, "Supabase CLI", config.Auth.Webauthn.RpDisplayName) + assert.Equal(t, "localhost", config.Auth.Webauthn.RpId) assert.Equal(t, []string{ "http://127.0.0.1:3000", "https://localhost:3000", - }, config.Auth.Passkey.RpOrigins) + }, config.Auth.Webauthn.RpOrigins) } }) + t.Run("webauthn section without passkey loads successfully", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[auth] +enabled = true +site_url = "http://127.0.0.1:3000" +[auth.webauthn] +rp_display_name = "Supabase CLI" +rp_id = "localhost" +rp_origins = ["http://127.0.0.1:3000"] +`)}, + } + // Run test + assert.NoError(t, config.Load("", fsys)) + // Check result + assert.Nil(t, config.Auth.Passkey) + if assert.NotNil(t, config.Auth.Webauthn) { + assert.Equal(t, "localhost", config.Auth.Webauthn.RpId) + } + }) + + t.Run("passkey enabled requires webauthn section", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[auth] +enabled = true +site_url = "http://127.0.0.1:3000" +[auth.passkey] +enabled = true +`)}, + } + // Run test + err := config.Load("", fsys) + // Check result + assert.ErrorContains(t, err, "Missing required config section: auth.webauthn") + }) + t.Run("passkey enabled requires rp_id", func(t *testing.T) { config := NewConfig() fsys := fs.MapFS{ @@ -111,13 +153,14 @@ enabled = true site_url = "http://127.0.0.1:3000" [auth.passkey] enabled = true +[auth.webauthn] rp_origins = ["http://127.0.0.1:3000"] `)}, } // Run test err := config.Load("", fsys) // Check result - assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_id") + assert.ErrorContains(t, err, "Missing required field in config: auth.webauthn.rp_id") }) t.Run("passkey enabled requires rp_origins", func(t *testing.T) { @@ -129,13 +172,14 @@ enabled = true site_url = "http://127.0.0.1:3000" [auth.passkey] enabled = true +[auth.webauthn] rp_id = "localhost" `)}, } // Run test err := config.Load("", fsys) // Check result - assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_origins") + assert.ErrorContains(t, err, "Missing required field in config: auth.webauthn.rp_origins") }) t.Run("parses experimental pgdelta config", func(t *testing.T) { diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index 2909f8223..97ed4e566 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -180,6 +180,9 @@ password_requirements = "" # Configure passkey sign-ins. # [auth.passkey] # enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] # rp_display_name = "Supabase" # rp_id = "localhost" # rp_origins = ["http://127.0.0.1:3000"]