diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 0de7315a61..1c7b7d1b94 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -12,25 +12,24 @@ import ( type ( auth struct { - Enabled bool `toml:"enabled"` - Image string `toml:"-"` - SiteUrl string `toml:"site_url"` - AdditionalRedirectUrls []string `toml:"additional_redirect_urls"` + Enabled bool `toml:"enabled"` + Image string `toml:"-"` - JwtExpiry uint `toml:"jwt_expiry"` - EnableRefreshTokenRotation bool `toml:"enable_refresh_token_rotation"` - RefreshTokenReuseInterval uint `toml:"refresh_token_reuse_interval"` - EnableManualLinking bool `toml:"enable_manual_linking"` + SiteUrl string `toml:"site_url"` + AdditionalRedirectUrls []string `toml:"additional_redirect_urls"` + JwtExpiry uint `toml:"jwt_expiry"` + EnableRefreshTokenRotation bool `toml:"enable_refresh_token_rotation"` + RefreshTokenReuseInterval uint `toml:"refresh_token_reuse_interval"` + EnableManualLinking bool `toml:"enable_manual_linking"` + EnableSignup bool `toml:"enable_signup"` + EnableAnonymousSignIns bool `toml:"enable_anonymous_sign_ins"` Hook hook `toml:"hook"` MFA mfa `toml:"mfa"` Sessions sessions `toml:"sessions"` - - EnableSignup bool `toml:"enable_signup"` - EnableAnonymousSignIns bool `toml:"enable_anonymous_sign_ins"` - Email email `toml:"email"` - Sms sms `toml:"sms"` - External external `toml:"external"` + Email email `toml:"email"` + Sms sms `toml:"sms"` + External external `toml:"external"` // Custom secrets can be injected from .env file JwtSecret string `toml:"-" mapstructure:"jwt_secret"` @@ -192,6 +191,10 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody { DisableSignup: cast.Ptr(!a.EnableSignup), ExternalAnonymousUsersEnabled: &a.EnableAnonymousSignIns, } + a.Hook.toAuthConfigBody(&body) + a.MFA.toAuthConfigBody(&body) + a.Sessions.toAuthConfigBody(&body) + // TODO: email a.Sms.toAuthConfigBody(&body) a.External.toAuthConfigBody(&body) return body @@ -207,12 +210,105 @@ func (a *auth) fromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) auth result.EnableManualLinking = cast.Val(remoteConfig.SecurityManualLinkingEnabled, false) result.EnableSignup = !cast.Val(remoteConfig.DisableSignup, false) result.EnableAnonymousSignIns = cast.Val(remoteConfig.ExternalAnonymousUsersEnabled, false) + result.Hook.fromAuthConfig(remoteConfig) + result.MFA.fromAuthConfig(remoteConfig) + result.Sessions.fromAuthConfig(remoteConfig) result.Sms.fromAuthConfig(remoteConfig) result.External = maps.Clone(result.External) result.External.fromAuthConfig(remoteConfig) return result } +func (h hook) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { + if body.HookCustomAccessTokenEnabled = &h.CustomAccessToken.Enabled; *body.HookCustomAccessTokenEnabled { + body.HookCustomAccessTokenUri = &h.CustomAccessToken.URI + body.HookCustomAccessTokenSecrets = &h.CustomAccessToken.Secrets + } + if body.HookSendEmailEnabled = &h.SendEmail.Enabled; *body.HookSendEmailEnabled { + body.HookSendEmailUri = &h.SendEmail.URI + body.HookSendEmailSecrets = &h.SendEmail.Secrets + } + if body.HookSendSmsEnabled = &h.SendSMS.Enabled; *body.HookSendSmsEnabled { + body.HookSendSmsUri = &h.SendSMS.URI + body.HookSendSmsSecrets = &h.SendSMS.Secrets + } + // Enterprise and team only features + if body.HookMfaVerificationAttemptEnabled = &h.MFAVerificationAttempt.Enabled; *body.HookMfaVerificationAttemptEnabled { + body.HookMfaVerificationAttemptUri = &h.MFAVerificationAttempt.URI + body.HookMfaVerificationAttemptSecrets = &h.MFAVerificationAttempt.Secrets + } + if body.HookPasswordVerificationAttemptEnabled = &h.PasswordVerificationAttempt.Enabled; *body.HookPasswordVerificationAttemptEnabled { + body.HookPasswordVerificationAttemptUri = &h.PasswordVerificationAttempt.URI + body.HookPasswordVerificationAttemptSecrets = &h.PasswordVerificationAttempt.Secrets + } +} + +func (h *hook) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { + // Ignore disabled hooks because their envs are not loaded + if h.CustomAccessToken.Enabled { + h.CustomAccessToken.URI = cast.Val(remoteConfig.HookCustomAccessTokenUri, "") + h.CustomAccessToken.Secrets = hashPrefix + cast.Val(remoteConfig.HookCustomAccessTokenSecrets, "") + } + h.CustomAccessToken.Enabled = cast.Val(remoteConfig.HookCustomAccessTokenEnabled, false) + if h.SendEmail.Enabled { + h.SendEmail.URI = cast.Val(remoteConfig.HookSendEmailUri, "") + h.SendEmail.Secrets = hashPrefix + cast.Val(remoteConfig.HookSendEmailSecrets, "") + } + h.SendEmail.Enabled = cast.Val(remoteConfig.HookSendEmailEnabled, false) + if h.SendSMS.Enabled { + h.SendSMS.URI = cast.Val(remoteConfig.HookSendSmsUri, "") + h.SendSMS.Secrets = hashPrefix + cast.Val(remoteConfig.HookSendSmsSecrets, "") + } + h.SendSMS.Enabled = cast.Val(remoteConfig.HookSendSmsEnabled, false) + // Enterprise and team only features + if h.MFAVerificationAttempt.Enabled { + h.MFAVerificationAttempt.URI = cast.Val(remoteConfig.HookMfaVerificationAttemptUri, "") + h.MFAVerificationAttempt.Secrets = hashPrefix + cast.Val(remoteConfig.HookMfaVerificationAttemptSecrets, "") + } + h.MFAVerificationAttempt.Enabled = cast.Val(remoteConfig.HookMfaVerificationAttemptEnabled, false) + if h.PasswordVerificationAttempt.Enabled { + h.PasswordVerificationAttempt.URI = cast.Val(remoteConfig.HookPasswordVerificationAttemptUri, "") + h.PasswordVerificationAttempt.Secrets = hashPrefix + cast.Val(remoteConfig.HookPasswordVerificationAttemptSecrets, "") + } + h.PasswordVerificationAttempt.Enabled = cast.Val(remoteConfig.HookPasswordVerificationAttemptEnabled, false) +} + +func (m mfa) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { + body.MfaMaxEnrolledFactors = cast.UintToIntPtr(&m.MaxEnrolledFactors) + body.MfaTotpEnrollEnabled = &m.TOTP.EnrollEnabled + body.MfaTotpVerifyEnabled = &m.TOTP.VerifyEnabled + body.MfaPhoneEnrollEnabled = &m.Phone.EnrollEnabled + body.MfaPhoneVerifyEnabled = &m.Phone.VerifyEnabled + body.MfaPhoneOtpLength = cast.UintToIntPtr(&m.Phone.OtpLength) + body.MfaPhoneTemplate = &m.Phone.Template + body.MfaPhoneMaxFrequency = cast.Ptr(int(m.Phone.MaxFrequency.Seconds())) + body.MfaWebAuthnEnrollEnabled = &m.WebAuthn.EnrollEnabled + body.MfaWebAuthnVerifyEnabled = &m.WebAuthn.VerifyEnabled +} + +func (m *mfa) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { + m.MaxEnrolledFactors = cast.IntToUint(cast.Val(remoteConfig.MfaMaxEnrolledFactors, 0)) + m.TOTP.EnrollEnabled = cast.Val(remoteConfig.MfaTotpEnrollEnabled, false) + m.TOTP.VerifyEnabled = cast.Val(remoteConfig.MfaTotpVerifyEnabled, false) + m.Phone.EnrollEnabled = cast.Val(remoteConfig.MfaPhoneEnrollEnabled, false) + m.Phone.VerifyEnabled = cast.Val(remoteConfig.MfaPhoneVerifyEnabled, false) + m.Phone.OtpLength = cast.IntToUint(remoteConfig.MfaPhoneOtpLength) + m.Phone.Template = cast.Val(remoteConfig.MfaPhoneTemplate, "") + m.Phone.MaxFrequency = time.Duration(cast.Val(remoteConfig.MfaPhoneMaxFrequency, 0)) * time.Second + m.WebAuthn.EnrollEnabled = cast.Val(remoteConfig.MfaWebAuthnEnrollEnabled, false) + m.WebAuthn.VerifyEnabled = cast.Val(remoteConfig.MfaWebAuthnVerifyEnabled, false) +} + +func (s sessions) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { + body.SessionsTimebox = cast.Ptr(int(s.Timebox.Seconds())) + body.SessionsInactivityTimeout = cast.Ptr(int(s.InactivityTimeout.Seconds())) +} + +func (s *sessions) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { + s.Timebox = time.Duration(cast.Val(remoteConfig.SessionsTimebox, 0)) * time.Second + s.InactivityTimeout = time.Duration(cast.Val(remoteConfig.SessionsInactivityTimeout, 0)) * time.Second +} + func (s sms) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { body.ExternalPhoneEnabled = &s.EnableSignup body.SmsMaxFrequency = cast.Ptr(int(s.MaxFrequency.Seconds())) diff --git a/pkg/config/auth_test.go b/pkg/config/auth_test.go index 4de37b8d12..df63f898f6 100644 --- a/pkg/config/auth_test.go +++ b/pkg/config/auth_test.go @@ -9,6 +9,202 @@ import ( "github.com/supabase/cli/pkg/cast" ) +func TestHookDiff(t *testing.T) { + t.Run("local and remote enabled", func(t *testing.T) { + c := auth{EnableSignup: true, Hook: hook{ + CustomAccessToken: hookConfig{Enabled: true}, + SendSMS: hookConfig{Enabled: true}, + SendEmail: hookConfig{Enabled: true}, + MFAVerificationAttempt: hookConfig{Enabled: true}, + PasswordVerificationAttempt: hookConfig{Enabled: true}, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + HookCustomAccessTokenEnabled: cast.Ptr(true), + HookCustomAccessTokenUri: cast.Ptr(""), + HookCustomAccessTokenSecrets: cast.Ptr("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + HookSendEmailEnabled: cast.Ptr(true), + HookSendEmailUri: cast.Ptr(""), + HookSendEmailSecrets: cast.Ptr("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + HookSendSmsEnabled: cast.Ptr(true), + HookSendSmsUri: cast.Ptr(""), + HookSendSmsSecrets: cast.Ptr("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + HookMfaVerificationAttemptEnabled: cast.Ptr(true), + HookMfaVerificationAttemptUri: cast.Ptr(""), + HookMfaVerificationAttemptSecrets: cast.Ptr("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + HookPasswordVerificationAttemptEnabled: cast.Ptr(true), + HookPasswordVerificationAttemptUri: cast.Ptr(""), + HookPasswordVerificationAttemptSecrets: cast.Ptr("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + }) + // Check error + assert.NoError(t, err) + assert.Empty(t, string(diff)) + }) + + t.Run("local enabled and disabled", func(t *testing.T) { + c := auth{EnableSignup: true, Hook: hook{ + CustomAccessToken: hookConfig{Enabled: true}, + MFAVerificationAttempt: hookConfig{Enabled: false}, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + HookCustomAccessTokenEnabled: cast.Ptr(false), + HookCustomAccessTokenUri: cast.Ptr(""), + HookCustomAccessTokenSecrets: cast.Ptr("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + HookMfaVerificationAttemptEnabled: cast.Ptr(true), + HookMfaVerificationAttemptUri: cast.Ptr(""), + HookMfaVerificationAttemptSecrets: cast.Ptr("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + }) + // Check error + assert.NoError(t, err) + + assert.Contains(t, string(diff), ` [hook.mfa_verification_attempt]`) + assert.Contains(t, string(diff), `-enabled = true`) + assert.Contains(t, string(diff), `+enabled = false`) + assert.Contains(t, string(diff), ` uri = ""`) + assert.Contains(t, string(diff), ` secrets = ""`) + + assert.Contains(t, string(diff), ` [hook.custom_access_token]`) + assert.Contains(t, string(diff), `-enabled = false`) + assert.Contains(t, string(diff), `+enabled = true`) + assert.Contains(t, string(diff), ` uri = ""`) + assert.Contains(t, string(diff), ` secrets = "hash:b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"`) + }) + + t.Run("local and remote disabled", func(t *testing.T) { + c := auth{EnableSignup: true} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + HookCustomAccessTokenEnabled: cast.Ptr(false), + HookSendEmailEnabled: cast.Ptr(false), + HookSendSmsEnabled: cast.Ptr(false), + HookMfaVerificationAttemptEnabled: cast.Ptr(false), + HookPasswordVerificationAttemptEnabled: cast.Ptr(false), + }) + // Check error + assert.NoError(t, err) + assert.Empty(t, string(diff)) + }) +} + +func TestMfaDiff(t *testing.T) { + t.Run("local and remote enabled", func(t *testing.T) { + c := auth{EnableSignup: true, MFA: mfa{ + TOTP: factorTypeConfiguration{ + EnrollEnabled: true, + VerifyEnabled: true, + }, + Phone: phoneFactorTypeConfiguration{ + factorTypeConfiguration: factorTypeConfiguration{ + EnrollEnabled: true, + VerifyEnabled: true, + }, + OtpLength: 6, + Template: "Your code is {{ .Code }}", + MaxFrequency: 5 * time.Second, + }, + WebAuthn: factorTypeConfiguration{ + EnrollEnabled: true, + VerifyEnabled: true, + }, + MaxEnrolledFactors: 10, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + MfaMaxEnrolledFactors: cast.Ptr(10), + MfaTotpEnrollEnabled: cast.Ptr(true), + MfaTotpVerifyEnabled: cast.Ptr(true), + MfaPhoneEnrollEnabled: cast.Ptr(true), + MfaPhoneVerifyEnabled: cast.Ptr(true), + MfaPhoneOtpLength: 6, + MfaPhoneTemplate: cast.Ptr("Your code is {{ .Code }}"), + MfaPhoneMaxFrequency: cast.Ptr(5), + MfaWebAuthnEnrollEnabled: cast.Ptr(true), + MfaWebAuthnVerifyEnabled: cast.Ptr(true), + }) + // Check error + assert.NoError(t, err) + assert.Empty(t, string(diff)) + }) + + t.Run("local enabled and disabled", func(t *testing.T) { + c := auth{EnableSignup: true, MFA: mfa{ + TOTP: factorTypeConfiguration{ + EnrollEnabled: false, + VerifyEnabled: false, + }, + Phone: phoneFactorTypeConfiguration{ + factorTypeConfiguration: factorTypeConfiguration{ + EnrollEnabled: true, + VerifyEnabled: true, + }, + }, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + MfaMaxEnrolledFactors: cast.Ptr(10), + MfaTotpEnrollEnabled: cast.Ptr(false), + MfaTotpVerifyEnabled: cast.Ptr(false), + MfaPhoneEnrollEnabled: cast.Ptr(false), + MfaPhoneVerifyEnabled: cast.Ptr(false), + MfaPhoneOtpLength: 6, + MfaPhoneTemplate: cast.Ptr("Your code is {{ .Code }}"), + MfaPhoneMaxFrequency: cast.Ptr(5), + MfaWebAuthnEnrollEnabled: cast.Ptr(false), + MfaWebAuthnVerifyEnabled: cast.Ptr(false), + }) + // Check error + assert.NoError(t, err) + assert.Contains(t, string(diff), ` [mfa]`) + assert.Contains(t, string(diff), `-max_enrolled_factors = 10`) + assert.Contains(t, string(diff), `+max_enrolled_factors = 0`) + assert.Contains(t, string(diff), ` [mfa.totp]`) + assert.Contains(t, string(diff), ` enroll_enabled = false`) + assert.Contains(t, string(diff), ` verify_enabled = false`) + assert.Contains(t, string(diff), ` [mfa.phone]`) + assert.Contains(t, string(diff), `-enroll_enabled = false`) + assert.Contains(t, string(diff), `-verify_enabled = false`) + assert.Contains(t, string(diff), `-otp_length = 6`) + assert.Contains(t, string(diff), `-template = "Your code is {{ .Code }}"`) + assert.Contains(t, string(diff), `-max_frequency = "5s"`) + assert.Contains(t, string(diff), `+enroll_enabled = true`) + assert.Contains(t, string(diff), `+verify_enabled = true`) + assert.Contains(t, string(diff), `+otp_length = 0`) + assert.Contains(t, string(diff), `+template = ""`) + assert.Contains(t, string(diff), `+max_frequency = "0s"`) + assert.Contains(t, string(diff), ` [mfa.web_authn]`) + assert.Contains(t, string(diff), ` enroll_enabled = false`) + assert.Contains(t, string(diff), ` verify_enabled = false`) + }) + + t.Run("local and remote disabled", func(t *testing.T) { + c := auth{EnableSignup: true, MFA: mfa{ + MaxEnrolledFactors: 10, + Phone: phoneFactorTypeConfiguration{ + OtpLength: 6, + Template: "Your code is {{ .Code }}", + MaxFrequency: 5 * time.Second, + }, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + MfaMaxEnrolledFactors: cast.Ptr(10), + MfaTotpEnrollEnabled: cast.Ptr(false), + MfaTotpVerifyEnabled: cast.Ptr(false), + MfaPhoneEnrollEnabled: cast.Ptr(false), + MfaPhoneVerifyEnabled: cast.Ptr(false), + MfaPhoneOtpLength: 6, + MfaPhoneTemplate: cast.Ptr("Your code is {{ .Code }}"), + MfaPhoneMaxFrequency: cast.Ptr(5), + MfaWebAuthnEnrollEnabled: cast.Ptr(false), + MfaWebAuthnVerifyEnabled: cast.Ptr(false), + }) + // Check error + assert.NoError(t, err) + assert.Empty(t, string(diff)) + }) +} + func TestSmsDiff(t *testing.T) { t.Run("local enabled remote enabled", func(t *testing.T) { c := auth{EnableSignup: true, Sms: sms{ @@ -86,7 +282,7 @@ func TestSmsDiff(t *testing.T) { assert.Contains(t, string(diff), `+template = ""`) assert.Contains(t, string(diff), `+max_frequency = "0s"`) - assert.Contains(t, string(diff), `[sms.twilio]`) + assert.Contains(t, string(diff), ` [sms.twilio]`) assert.Contains(t, string(diff), `-enabled = true`) assert.Contains(t, string(diff), `+enabled = false`) @@ -134,11 +330,11 @@ func TestSmsDiff(t *testing.T) { assert.Contains(t, string(diff), `+template = "Your code is {{ .Code }}"`) assert.Contains(t, string(diff), `+max_frequency = "1m0s"`) - assert.Contains(t, string(diff), `[sms.twilio]`) + assert.Contains(t, string(diff), ` [sms.twilio]`) assert.Contains(t, string(diff), `-enabled = true`) assert.Contains(t, string(diff), `+enabled = false`) - assert.Contains(t, string(diff), `[sms.messagebird]`) + assert.Contains(t, string(diff), ` [sms.messagebird]`) assert.Contains(t, string(diff), `-enabled = false`) assert.Contains(t, string(diff), `-originator = ""`) assert.Contains(t, string(diff), `-access_key = "hash:"`) @@ -190,7 +386,7 @@ func TestSmsDiff(t *testing.T) { }) // Check error assert.NoError(t, err) - assert.Contains(t, string(diff), `[sms]`) + assert.Contains(t, string(diff), ` [sms]`) assert.Contains(t, string(diff), `-enable_signup = false`) assert.Contains(t, string(diff), `+enable_signup = true`) }) @@ -352,13 +548,13 @@ func TestExternalDiff(t *testing.T) { }) // Check error assert.NoError(t, err) - assert.Contains(t, string(diff), `[external.apple]`) + assert.Contains(t, string(diff), ` [external.apple]`) assert.Contains(t, string(diff), `-enabled = false`) assert.Contains(t, string(diff), `+enabled = true`) - assert.Contains(t, string(diff), `client_id = "test-client-1,test-client-2"`) - assert.Contains(t, string(diff), `secret = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"`) + assert.Contains(t, string(diff), ` client_id = "test-client-1,test-client-2"`) + assert.Contains(t, string(diff), ` secret = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"`) - assert.Contains(t, string(diff), `[external.google]`) + assert.Contains(t, string(diff), ` [external.google]`) assert.Contains(t, string(diff), `-enabled = true`) assert.Contains(t, string(diff), `+enabled = false`) }) diff --git a/pkg/config/config.go b/pkg/config/config.go index c1860fe4a6..2ae693b057 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -666,124 +666,21 @@ func (c *baseConfig) Validate(fsys fs.FS) error { if c.Auth.Email.Smtp.Pass, err = maybeLoadEnv(c.Auth.Email.Smtp.Pass); err != nil { return err } - // Validate sms config - switch { - case c.Auth.Sms.Twilio.Enabled: - if len(c.Auth.Sms.Twilio.AccountSid) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio.account_sid") - } - if len(c.Auth.Sms.Twilio.MessageServiceSid) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio.message_service_sid") - } - if len(c.Auth.Sms.Twilio.AuthToken) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio.auth_token") - } - if c.Auth.Sms.Twilio.AuthToken, err = maybeLoadEnv(c.Auth.Sms.Twilio.AuthToken); err != nil { - return err - } - case c.Auth.Sms.TwilioVerify.Enabled: - if len(c.Auth.Sms.TwilioVerify.AccountSid) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid") - } - if len(c.Auth.Sms.TwilioVerify.MessageServiceSid) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio_verify.message_service_sid") - } - if len(c.Auth.Sms.TwilioVerify.AuthToken) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio_verify.auth_token") - } - if c.Auth.Sms.TwilioVerify.AuthToken, err = maybeLoadEnv(c.Auth.Sms.TwilioVerify.AuthToken); err != nil { - return err - } - case c.Auth.Sms.Messagebird.Enabled: - if len(c.Auth.Sms.Messagebird.Originator) == 0 { - return errors.New("Missing required field in config: auth.sms.messagebird.originator") - } - if len(c.Auth.Sms.Messagebird.AccessKey) == 0 { - return errors.New("Missing required field in config: auth.sms.messagebird.access_key") - } - if c.Auth.Sms.Messagebird.AccessKey, err = maybeLoadEnv(c.Auth.Sms.Messagebird.AccessKey); err != nil { - return err - } - case c.Auth.Sms.Textlocal.Enabled: - if len(c.Auth.Sms.Textlocal.Sender) == 0 { - return errors.New("Missing required field in config: auth.sms.textlocal.sender") - } - if len(c.Auth.Sms.Textlocal.ApiKey) == 0 { - return errors.New("Missing required field in config: auth.sms.textlocal.api_key") - } - if c.Auth.Sms.Textlocal.ApiKey, err = maybeLoadEnv(c.Auth.Sms.Textlocal.ApiKey); err != nil { - return err - } - case c.Auth.Sms.Vonage.Enabled: - if len(c.Auth.Sms.Vonage.From) == 0 { - return errors.New("Missing required field in config: auth.sms.vonage.from") - } - if len(c.Auth.Sms.Vonage.ApiKey) == 0 { - return errors.New("Missing required field in config: auth.sms.vonage.api_key") - } - if len(c.Auth.Sms.Vonage.ApiSecret) == 0 { - return errors.New("Missing required field in config: auth.sms.vonage.api_secret") - } - if c.Auth.Sms.Vonage.ApiKey, err = maybeLoadEnv(c.Auth.Sms.Vonage.ApiKey); err != nil { - return err - } - if c.Auth.Sms.Vonage.ApiSecret, err = maybeLoadEnv(c.Auth.Sms.Vonage.ApiSecret); err != nil { - return err - } - case c.Auth.Sms.EnableSignup: - c.Auth.Sms.EnableSignup = false - fmt.Fprintln(os.Stderr, "WARN: no SMS provider is enabled. Disabling phone login") - } - if err := c.Auth.Hook.MFAVerificationAttempt.HandleHook("mfa_verification_attempt"); err != nil { + if err := c.Auth.Hook.validate(); err != nil { return err } - if err := c.Auth.Hook.PasswordVerificationAttempt.HandleHook("password_verification_attempt"); err != nil { + if err := c.Auth.MFA.validate(); err != nil { return err } - if err := c.Auth.Hook.CustomAccessToken.HandleHook("custom_access_token"); err != nil { + if err := c.Auth.Sms.validate(); err != nil { return err } - if err := c.Auth.Hook.SendSMS.HandleHook("send_sms"); err != nil { + if err := c.Auth.External.validate(); err != nil { return err } - if err := c.Auth.Hook.SendEmail.HandleHook("send_email"); err != nil { + if err := c.Auth.ThirdParty.validate(); err != nil { return err } - // Validate oauth config - for _, ext := range []string{"linkedin", "slack"} { - if c.Auth.External[ext].Enabled { - fmt.Fprintf(os.Stderr, `WARN: disabling deprecated "%[1]s" provider. Please use [auth.external.%[1]s_oidc] instead\n`, ext) - } - delete(c.Auth.External, ext) - } - for ext, provider := range c.Auth.External { - if !provider.Enabled { - continue - } - if provider.ClientId == "" { - return errors.Errorf("Missing required field in config: auth.external.%s.client_id", ext) - } - if !sliceContains([]string{"apple", "google"}, ext) && provider.Secret == "" { - return errors.Errorf("Missing required field in config: auth.external.%s.secret", ext) - } - if provider.ClientId, err = maybeLoadEnv(provider.ClientId); err != nil { - return err - } - if provider.Secret, err = maybeLoadEnv(provider.Secret); err != nil { - return err - } - if provider.RedirectUri, err = maybeLoadEnv(provider.RedirectUri); err != nil { - return err - } - if provider.Url, err = maybeLoadEnv(provider.Url); err != nil { - return err - } - c.Auth.External[ext] = provider - } - } - // Validate Third-Party Auth config - if err := c.Auth.ThirdParty.validate(); err != nil { - return err } // Validate functions config if c.EdgeRuntime.Enabled { @@ -817,7 +714,7 @@ func (c *baseConfig) Validate(fsys fs.FS) error { return errors.Errorf("Invalid config for analytics.backend. Must be one of: %v", allowed) } } - if err := c.Experimental.validateWebhooks(); err != nil { + if err := c.Experimental.validate(); err != nil { return err } return nil @@ -917,31 +814,154 @@ func (c *seed) loadSeedPaths(basePath string, fsys fs.FS) error { return nil } -func (h *hookConfig) HandleHook(hookType string) error { +func (s *sms) validate() (err error) { + switch { + case s.Twilio.Enabled: + if len(s.Twilio.AccountSid) == 0 { + return errors.New("Missing required field in config: auth.sms.twilio.account_sid") + } + if len(s.Twilio.MessageServiceSid) == 0 { + return errors.New("Missing required field in config: auth.sms.twilio.message_service_sid") + } + if len(s.Twilio.AuthToken) == 0 { + return errors.New("Missing required field in config: auth.sms.twilio.auth_token") + } + if s.Twilio.AuthToken, err = maybeLoadEnv(s.Twilio.AuthToken); err != nil { + return err + } + case s.TwilioVerify.Enabled: + if len(s.TwilioVerify.AccountSid) == 0 { + return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid") + } + if len(s.TwilioVerify.MessageServiceSid) == 0 { + return errors.New("Missing required field in config: auth.sms.twilio_verify.message_service_sid") + } + if len(s.TwilioVerify.AuthToken) == 0 { + return errors.New("Missing required field in config: auth.sms.twilio_verify.auth_token") + } + if s.TwilioVerify.AuthToken, err = maybeLoadEnv(s.TwilioVerify.AuthToken); err != nil { + return err + } + case s.Messagebird.Enabled: + if len(s.Messagebird.Originator) == 0 { + return errors.New("Missing required field in config: auth.sms.messagebird.originator") + } + if len(s.Messagebird.AccessKey) == 0 { + return errors.New("Missing required field in config: auth.sms.messagebird.access_key") + } + if s.Messagebird.AccessKey, err = maybeLoadEnv(s.Messagebird.AccessKey); err != nil { + return err + } + case s.Textlocal.Enabled: + if len(s.Textlocal.Sender) == 0 { + return errors.New("Missing required field in config: auth.sms.textlocal.sender") + } + if len(s.Textlocal.ApiKey) == 0 { + return errors.New("Missing required field in config: auth.sms.textlocal.api_key") + } + if s.Textlocal.ApiKey, err = maybeLoadEnv(s.Textlocal.ApiKey); err != nil { + return err + } + case s.Vonage.Enabled: + if len(s.Vonage.From) == 0 { + return errors.New("Missing required field in config: auth.sms.vonage.from") + } + if len(s.Vonage.ApiKey) == 0 { + return errors.New("Missing required field in config: auth.sms.vonage.api_key") + } + if len(s.Vonage.ApiSecret) == 0 { + return errors.New("Missing required field in config: auth.sms.vonage.api_secret") + } + if s.Vonage.ApiKey, err = maybeLoadEnv(s.Vonage.ApiKey); err != nil { + return err + } + if s.Vonage.ApiSecret, err = maybeLoadEnv(s.Vonage.ApiSecret); err != nil { + return err + } + case s.EnableSignup: + s.EnableSignup = false + fmt.Fprintln(os.Stderr, "WARN: no SMS provider is enabled. Disabling phone login") + } + return nil +} + +func (e external) validate() (err error) { + for _, ext := range []string{"linkedin", "slack"} { + if e[ext].Enabled { + fmt.Fprintf(os.Stderr, `WARN: disabling deprecated "%[1]s" provider. Please use [auth.external.%[1]s_oidc] instead\n`, ext) + } + delete(e, ext) + } + for ext, provider := range e { + if !provider.Enabled { + continue + } + if provider.ClientId == "" { + return errors.Errorf("Missing required field in config: auth.external.%s.client_id", ext) + } + if !sliceContains([]string{"apple", "google"}, ext) && provider.Secret == "" { + return errors.Errorf("Missing required field in config: auth.external.%s.secret", ext) + } + if provider.ClientId, err = maybeLoadEnv(provider.ClientId); err != nil { + return err + } + if provider.Secret, err = maybeLoadEnv(provider.Secret); err != nil { + return err + } + if provider.RedirectUri, err = maybeLoadEnv(provider.RedirectUri); err != nil { + return err + } + if provider.Url, err = maybeLoadEnv(provider.Url); err != nil { + return err + } + e[ext] = provider + } + return nil +} + +func (h *hook) validate() error { + if err := h.MFAVerificationAttempt.validate("mfa_verification_attempt"); err != nil { + return err + } + if err := h.PasswordVerificationAttempt.validate("password_verification_attempt"); err != nil { + return err + } + if err := h.CustomAccessToken.validate("custom_access_token"); err != nil { + return err + } + if err := h.SendSMS.validate("send_sms"); err != nil { + return err + } + return h.SendEmail.validate("send_email") +} + +func (h *hookConfig) validate(hookType string) (err error) { // If not enabled do nothing if !h.Enabled { return nil } if h.URI == "" { return errors.Errorf("missing required field in config: auth.hook.%s.uri", hookType) + } else if parsed, err := url.Parse(h.URI); err != nil { + return errors.Errorf("failed to parse template url: %w", err) + } else if !(parsed.Scheme == "http" || parsed.Scheme == "https" || parsed.Scheme == "pg-functions") { + return errors.Errorf("Invalid HTTP hook config: auth.hook.%v should be a Postgres function URI, or a HTTP or HTTPS URL", hookType) } - if err := validateHookURI(h.URI, hookType); err != nil { - return err - } - var err error if h.Secrets, err = maybeLoadEnv(h.Secrets); err != nil { return errors.Errorf("missing required field in config: auth.hook.%s.secrets", hookType) } return nil } -func validateHookURI(uri, hookName string) error { - parsed, err := url.Parse(uri) - if err != nil { - return errors.Errorf("failed to parse template url: %w", err) +func (m *mfa) validate() error { + if m.TOTP.EnrollEnabled && !m.TOTP.VerifyEnabled { + return errors.Errorf("Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled") + } + if m.Phone.EnrollEnabled && !m.Phone.VerifyEnabled { + return errors.Errorf("Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled") } - if !(parsed.Scheme == "http" || parsed.Scheme == "https" || parsed.Scheme == "pg-functions") { - return errors.Errorf("Invalid HTTP hook config: auth.hook.%v should be a Postgres function URI, or a HTTP or HTTPS URL", hookName) + if m.WebAuthn.EnrollEnabled && !m.WebAuthn.VerifyEnabled { + return errors.Errorf("Invalid MFA config: auth.mfa.web_authn.enroll_enabled requires verify_enabled") } return nil } @@ -1186,11 +1206,9 @@ func ToTomlBytes(config any) ([]byte, error) { return buf.Bytes(), nil } -func (e *experimental) validateWebhooks() error { - if e.Webhooks != nil { - if !e.Webhooks.Enabled { - return errors.Errorf("Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined") - } +func (e *experimental) validate() error { + if e.Webhooks != nil && !e.Webhooks.Enabled { + return errors.Errorf("Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined") } return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d80ea915bd..8f1169d5e6 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -247,7 +247,12 @@ func TestValidateHookURI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateHookURI(tt.uri, tt.hookName) + h := hookConfig{ + Enabled: true, + URI: tt.uri, + Secrets: "test-secret", + } + err := h.validate(tt.hookName) if tt.shouldErr { assert.Error(t, err, "Expected an error for %v", tt.name) assert.EqualError(t, err, tt.errorMsg, "Expected error message does not match for %v", tt.name) diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index 8df89bea39..43854f7d63 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -184,12 +184,12 @@ enroll_enabled = true verify_enabled = true # Configure Multi-factor-authentication via Phone Messaging -# [auth.mfa.phone] -# enroll_enabled = true -# verify_enabled = true -# otp_length = 6 -# template = "Your code is {{ `{{ .Code }}` }} ." -# max_frequency = "10s" +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ `{{ .Code }}` }}" +max_frequency = "5s" # Configure Multi-factor-authentication via WebAuthn # [auth.mfa.web_authn] diff --git a/pkg/config/testdata/config.toml b/pkg/config/testdata/config.toml index b8314644d9..9aba86c3d2 100644 --- a/pkg/config/testdata/config.toml +++ b/pkg/config/testdata/config.toml @@ -188,8 +188,8 @@ verify_enabled = true enroll_enabled = true verify_enabled = true otp_length = 6 -template = "Your code is {{ `{{ .Code }}` }} ." -max_frequency = "10s" +template = "Your code is {{ `{{ .Code }}` }}" +max_frequency = "5s" # Configure Multi-factor-authentication via Phone Messaging [auth.mfa.web_authn]