Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions pkg/config/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down
99 changes: 99 additions & 0 deletions pkg/config/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
63 changes: 63 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading