diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 1c021d374..82c708e37 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -162,6 +162,7 @@ type ( PasswordRequirements PasswordRequirements `toml:"password_requirements" json:"password_requirements"` SigningKeysPath string `toml:"signing_keys_path" json:"signing_keys_path"` SigningKeys []JWK `toml:"-" json:"-"` + Passkey *passkey `toml:"passkey" json:"passkey"` RateLimit rateLimit `toml:"rate_limit" json:"rate_limit"` Captcha *captcha `toml:"captcha" json:"captcha"` @@ -378,6 +379,13 @@ type ( Ethereum ethereum `toml:"ethereum" json:"ethereum"` } + passkey struct { + Enabled bool `toml:"enabled" json:"enabled"` + 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"` + } + OAuthServer struct { Enabled bool `toml:"enabled" json:"enabled"` AllowDynamicRegistration bool `toml:"allow_dynamic_registration" json:"allow_dynamic_registration"` @@ -407,6 +415,9 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody { if a.Captcha != nil { a.Captcha.toAuthConfigBody(&body) } + if a.Passkey != nil { + a.Passkey.toAuthConfigBody(&body) + } a.Hook.toAuthConfigBody(&body) a.MFA.toAuthConfigBody(&body) a.Sessions.toAuthConfigBody(&body) @@ -430,6 +441,7 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) { a.MinimumPasswordLength = cast.IntToUint(ValOrDefault(remoteConfig.PasswordMinLength, 0)) prc := ValOrDefault(remoteConfig.PasswordRequiredCharacters, "") a.PasswordRequirements = NewPasswordRequirement(v1API.UpdateAuthConfigBodyPasswordRequiredCharacters(prc)) + a.Passkey.fromAuthConfig(remoteConfig) a.RateLimit.fromAuthConfig(remoteConfig) if s := a.Email.Smtp; s != nil && s.Enabled { a.RateLimit.EmailSent = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitEmailSent, 0)) @@ -489,6 +501,28 @@ func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { c.Enabled = ValOrDefault(remoteConfig.SecurityCaptchaEnabled, false) } +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, ",")) + } +} + +func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { + // When local config is not set, we assume platform defaults should not change + 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 (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 fc0b2c6f4..65f0066da 100644 --- a/pkg/config/auth_test.go +++ b/pkg/config/auth_test.go @@ -212,6 +212,105 @@ 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, + RpDisplayName: "Supabase CLI", + RpId: "localhost", + RpOrigins: []string{ + "http://127.0.0.1:3000", + "https://localhost:3000", + }, + } + // Run test + body := c.ToUpdateAuthConfigBody() + // Check result + if assert.NotNil(t, body.PasskeyEnabled) { + assert.True(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,https://localhost:3000", ValOrDefault(body.WebauthnRpOrigins, "")) + }) + + t.Run("does not serialize rp fields when passkey is disabled", func(t *testing.T) { + c := newWithDefaults() + c.Passkey = &passkey{ + Enabled: false, + RpDisplayName: "Supabase CLI", + RpId: "localhost", + RpOrigins: []string{"http://127.0.0.1:3000"}, + } + // Run test + body := c.ToUpdateAuthConfigBody() + // Check result + if assert.NotNil(t, body.PasskeyEnabled) { + assert.False(t, *body.PasskeyEnabled) + } + _, err := body.WebauthnRpDisplayName.Get() + assert.Error(t, err) + _, err = body.WebauthnRpId.Get() + assert.Error(t, err) + _, err = body.WebauthnRpOrigins.Get() + assert.Error(t, err) + }) + + t.Run("hydrates passkey config from remote", func(t *testing.T) { + c := newWithDefaults() + c.Passkey = &passkey{ + Enabled: true, + } + // Run test + c.FromRemoteAuthConfig(v1API.AuthConfigResponse{ + PasskeyEnabled: true, + WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"), + WebauthnRpId: nullable.NewNullableWithValue("localhost"), + WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000,https://localhost:3000"), + }) + // 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) + assert.Equal(t, []string{ + "http://127.0.0.1:3000", + "https://localhost:3000", + }, c.Passkey.RpOrigins) + } + }) + + t.Run("ignores remote settings when local passkey config is undefined", func(t *testing.T) { + c := newWithDefaults() + // Run test + c.FromRemoteAuthConfig(v1API.AuthConfigResponse{ + PasskeyEnabled: true, + WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"), + WebauthnRpId: nullable.NewNullableWithValue("localhost"), + WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000"), + }) + // Check result + assert.Nil(t, c.Passkey) + }) +} + +func TestPasskeyDiff(t *testing.T) { + t.Run("ignores undefined config", func(t *testing.T) { + c := newWithDefaults() + // Run test + diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{ + PasskeyEnabled: true, + WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"), + WebauthnRpId: nullable.NewNullableWithValue("localhost"), + WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000"), + }) + // Check error + assert.NoError(t, err) + assert.Empty(t, string(diff)) + }) +} + func TestHookDiff(t *testing.T) { t.Run("local and remote enabled", func(t *testing.T) { c := newWithDefaults() diff --git a/pkg/config/config.go b/pkg/config/config.go index 018528a50..90d81741b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -260,6 +260,11 @@ func (a *auth) Clone() auth { capt := *a.Captcha copy.Captcha = &capt } + if copy.Passkey != nil { + passkey := *a.Passkey + passkey.RpOrigins = slices.Clone(a.Passkey.RpOrigins) + copy.Passkey = &passkey + } copy.External = maps.Clone(a.External) if a.Email.Smtp != nil { mailer := *a.Email.Smtp @@ -916,6 +921,24 @@ 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 err := c.Auth.Hook.validate(); err != nil { return err } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 42c28acfd..695733116 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -74,6 +74,69 @@ func TestConfigParsing(t *testing.T) { // Run test assert.Error(t, config.Load("", fsys)) }) + t.Run("config file with passkey settings", 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 +rp_display_name = "Supabase CLI" +rp_id = "localhost" +rp_origins = ["http://127.0.0.1:3000", "https://localhost:3000"] +`)}, + } + // Run test + assert.NoError(t, config.Load("", fsys)) + // 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) + assert.Equal(t, []string{ + "http://127.0.0.1:3000", + "https://localhost:3000", + }, config.Auth.Passkey.RpOrigins) + } + }) + + t.Run("passkey enabled requires rp_id", 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 +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") + }) + + t.Run("passkey enabled requires rp_origins", 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 +rp_id = "localhost" +`)}, + } + // Run test + err := config.Load("", fsys) + // Check result + assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_origins") + }) t.Run("parses experimental pgdelta config", func(t *testing.T) { config := NewConfig() diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index f4d5a7961..93426ddd5 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -177,6 +177,13 @@ minimum_password_length = 6 # are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` password_requirements = "" +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + [auth.rate_limit] # Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. email_sent = 2