diff --git a/example.env b/example.env index c43db016d..8cce927f3 100644 --- a/example.env +++ b/example.env @@ -218,6 +218,18 @@ GOTRUE_COOKIE_KEY="sb" GOTRUE_COOKIE_DOMAIN="localhost" GOTRUE_MAX_VERIFIED_FACTORS=10 +# Auth Hook Configuration +GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED=false +GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI="" +# Only for HTTPS Hooks +GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRET="" + +GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_ENABLED=false +GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_URI="" +# Only for HTTPS Hooks +GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_SECRET="" + + # Test OTP Config GOTRUE_SMS_TEST_OTP=":, :..." GOTRUE_SMS_TEST_OTP_VALID_UNTIL="" # (e.g. 2023-09-29T08:14:06Z) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index c81bf4fa0..7057472e3 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -20,8 +20,16 @@ const defaultMinPasswordLength int = 6 const defaultChallengeExpiryDuration float64 = 300 const defaultFlowStateExpiryDuration time.Duration = 300 * time.Second +// See: https://www.postgresql.org/docs/7.0/syntax525.htm var postgresNamesRegexp = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]{0,62}$`) +// See: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md +// We use 4 * Math.ceil(n/3) to obtain unpadded length in base 64 +// So this 4 * Math.ceil(24/3) = 32 and 4 * Math.ceil(64/3) = 88 for symmetric secrets +// Since Ed25519 key is 32 bytes so we have 4 * Math.ceil(32/3) = 44 +var symmetricSecretFormat = regexp.MustCompile(`^v1,whsec_[A-Za-z0-9+/=]{32,88}`) +var asymmetricSecretFormat = regexp.MustCompile(`^v1a,whpk_[A-Za-z0-9+/=]{44,};whsk_[A-Za-z0-9+/=]{44,}$`) + // Time is used to represent timestamps in the configuration, as envconfig has // trouble parsing empty strings, due to time.Time.UnmarshalText(). type Time struct { @@ -435,12 +443,14 @@ type HookConfiguration struct { MFAVerificationAttempt ExtensibilityPointConfiguration `json:"mfa_verification_attempt" split_words:"true"` PasswordVerificationAttempt ExtensibilityPointConfiguration `json:"password_verification_attempt" split_words:"true"` CustomAccessToken ExtensibilityPointConfiguration `json:"custom_access_token" split_words:"true"` + CustomSMSProvider ExtensibilityPointConfiguration `json:"custom_sms_provider" split_words:"true"` } type ExtensibilityPointConfiguration struct { - URI string `json:"uri"` - Enabled bool `json:"enabled"` - HookName string `json:"hook_name"` + URI string `json:"uri"` + Enabled bool `json:"enabled"` + HookName string `json:"hook_name"` + HTTPHookSecrets []string `json:"secrets"` } func (h *HookConfiguration) Validate() error { @@ -448,6 +458,7 @@ func (h *HookConfiguration) Validate() error { h.MFAVerificationAttempt, h.PasswordVerificationAttempt, h.CustomAccessToken, + h.CustomSMSProvider, } for _, point := range points { if err := point.ValidateExtensibilityPoint(); err != nil { @@ -458,26 +469,49 @@ func (h *HookConfiguration) Validate() error { } func (e *ExtensibilityPointConfiguration) ValidateExtensibilityPoint() error { - if e.URI != "" { - u, err := url.Parse(e.URI) - if err != nil { - return err - } - pathParts := strings.Split(u.Path, "/") - if len(pathParts) < 3 { - return fmt.Errorf("URI path does not contain enough parts") - } - if u.Scheme != "pg-functions" { - return fmt.Errorf("only postgres hooks are supported at the moment") - } - schema := pathParts[1] - table := pathParts[2] - // Validate schema and table names - if !postgresNamesRegexp.MatchString(schema) { - return fmt.Errorf("invalid schema name: %s", schema) - } - if !postgresNamesRegexp.MatchString(table) { - return fmt.Errorf("invalid table name: %s", table) + if e.URI == "" { + return nil + } + u, err := url.Parse(e.URI) + if err != nil { + return err + } + switch strings.ToLower(u.Scheme) { + case "pg-functions": + return validatePostgresPath(u) + case "https": + return validateHTTPSHookSecrets(e.HTTPHookSecrets) + default: + return fmt.Errorf("only postgres hooks and HTTPS functions are supported at the moment") + } +} + +func validatePostgresPath(u *url.URL) error { + pathParts := strings.Split(u.Path, "/") + if len(pathParts) < 3 { + return fmt.Errorf("URI path does not contain enough parts") + } + + schema := pathParts[1] + table := pathParts[2] + // Validate schema and table names + if !postgresNamesRegexp.MatchString(schema) { + return fmt.Errorf("invalid schema name: %s", schema) + } + if !postgresNamesRegexp.MatchString(table) { + return fmt.Errorf("invalid table name: %s", table) + } + return nil +} + +func isValidSecretFormat(secret string) bool { + return symmetricSecretFormat.MatchString(secret) || asymmetricSecretFormat.MatchString(secret) +} + +func validateHTTPSHookSecrets(secrets []string) error { + for _, secret := range secrets { + if !isValidSecretFormat(secret) { + return fmt.Errorf("invalid secret format") } } return nil @@ -523,6 +557,12 @@ func LoadGlobal(filename string) (*GlobalConfiguration, error) { } } + if config.Hook.CustomSMSProvider.Enabled { + if err := config.Hook.CustomSMSProvider.PopulateExtensibilityPoint(); err != nil { + return nil, err + } + } + if config.Hook.MFAVerificationAttempt.Enabled { if err := config.Hook.MFAVerificationAttempt.PopulateExtensibilityPoint(); err != nil { return nil, err diff --git a/internal/conf/configuration_test.go b/internal/conf/configuration_test.go index 8017dd4da..e802b8f43 100644 --- a/internal/conf/configuration_test.go +++ b/internal/conf/configuration_test.go @@ -98,18 +98,21 @@ func TestPasswordRequiredCharactersDecode(t *testing.T) { } } -func TestValidateExtensibilityPoint(t *testing.T) { +func TestValidateExtensibilityPointURI(t *testing.T) { cases := []struct { desc string uri string expectError bool }{ // Positive test cases - {desc: "Valid URI", uri: "pg-functions://postgres/auth/verification_hook_reject", expectError: false}, + {desc: "Valid HTTPS URI", uri: "https://asdfgggqqwwerty.website.co/functions/v1/custom-sms-sender", expectError: false}, + {desc: "Valid HTTPS URI", uri: "HTTPS://www.asdfgggqqwwerty.website.co/functions/v1/custom-sms-sender", expectError: false}, + {desc: "Valid Postgres URI", uri: "pg-functions://postgres/auth/verification_hook_reject", expectError: false}, {desc: "Another Valid URI", uri: "pg-functions://postgres/user_management/add_user", expectError: false}, {desc: "Another Valid URI", uri: "pg-functions://postgres/MySpeCial/FUNCTION_THAT_YELLS_AT_YOU", expectError: false}, // Negative test cases + {desc: "Invalid HTTPS URI (HTTP)", uri: "http://asdfgggqqwwerty.supabase.co/functions/v1/custom-sms-sender", expectError: true}, {desc: "Invalid Schema Name", uri: "pg-functions://postgres/123auth/verification_hook_reject", expectError: true}, {desc: "Invalid Function Name", uri: "pg-functions://postgres/auth/123verification_hook_reject", expectError: true}, {desc: "Insufficient Path Parts", uri: "pg-functions://postgres/auth", expectError: true}, @@ -125,3 +128,32 @@ func TestValidateExtensibilityPoint(t *testing.T) { } } } + +func TestValidateExtensibilityPointSecrets(t *testing.T) { + validHTTPSURI := "https://asdfgggqqwwerty.website.co/functions/v1/custom-sms-sender" + cases := []struct { + desc string + secret []string + expectError bool + }{ + // Positive test cases + {desc: "Valid Symmetric Secret", secret: []string{"v1,whsec_NDYzODhlNTY0ZGI1OWZjYTU2NjMwN2FhYzM3YzBkMWQ0NzVjNWRkNTJmZDU0MGNhYTAzMjVjNjQzMzE3Mjk2Zg====="}, expectError: false}, + {desc: "Valid Asymmetric Secret", secret: []string{"v1a,whpk_NDYzODhlNTY0ZGI1OWZjYTU2NjMwN2FhYzM3YzBkMWQ0NzVjNWRkNTJmZDU0MGNhYTAzMjVjNjQzMzE3Mjk2Zg==;whsk_abc889a6b1160015025064f108a48d6aba1c7c95fa8e304b4d225e8ae0121511"}, expectError: false}, + {desc: "Valid Mix of Symmetric and asymmetric Secret", secret: []string{"v1,whsec_2b49264c90fd15db3bb0e05f4e1547b9c183eb06d585be8a", "v1a,whpk_46388e564db59fca566307aac37c0d1d475c5dd52fd540caa0325c643317296f;whsk_YWJjODg5YTZiMTE2MDAxNTAyNTA2NGYxMDhhNDhkNmFiYTFjN2M5NWZhOGUzMDRiNGQyMjVlOGFlMDEyMTUxMSI="}, expectError: false}, + + // Negative test cases + {desc: "Invalid Asymmetric Secret", secret: []string{"v1a,john;jill", "jill"}, expectError: true}, + {desc: "Invalid Symmetric Secret", secret: []string{"tommy"}, expectError: true}, + } + for _, tc := range cases { + ep := ExtensibilityPointConfiguration{URI: validHTTPSURI, HTTPHookSecrets: tc.secret} + err := ep.ValidateExtensibilityPoint() + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + } + +}