Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add configuration for custom sms sender hook #1428

Merged
merged 1 commit into from
Feb 19, 2024
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
12 changes: 12 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,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="<phone-1>:<otp-1>, <phone-2>:<otp-2>..."
GOTRUE_SMS_TEST_OTP_VALID_UNTIL="<ISO date time>" # (e.g. 2023-09-29T08:14:06Z)
86 changes: 63 additions & 23 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -444,19 +452,22 @@ 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 {
points := []ExtensibilityPointConfiguration{
h.MFAVerificationAttempt,
h.PasswordVerificationAttempt,
h.CustomAccessToken,
h.CustomSMSProvider,
}
for _, point := range points {
if err := point.ValidateExtensibilityPoint(); err != nil {
Expand All @@ -467,26 +478,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
Expand Down Expand Up @@ -541,6 +575,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
Expand Down
36 changes: 34 additions & 2 deletions internal/conf/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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)
}

}

}
Loading