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
38 changes: 26 additions & 12 deletions pkg/config/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -502,27 +510,33 @@ 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) {
// 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 (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 {
Expand Down
43 changes: 28 additions & 15 deletions pkg/config/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
Expand All @@ -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"),
Expand All @@ -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)
}
})

Expand All @@ -292,6 +304,7 @@ func TestPasskeyConfigMapping(t *testing.T) {
})
// Check result
assert.Nil(t, c.Passkey)
assert.Nil(t, c.Webauthn)
})
}

Expand Down
37 changes: 21 additions & 16 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
56 changes: 50 additions & 6 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand All @@ -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"]
Expand All @@ -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{
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading