Skip to content

Commit

Permalink
feat: add configuration for custom sms sender hook (#1428)
Browse files Browse the repository at this point in the history
## What kind of change does this PR introduce?

Adds configuration for HTTPS hooks as well as the validation checks for
configuration.

- Follow up to SQL Hooks like in #1328 
- Successor to #1246

Relevant section of the specification [is
here](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md#signature-scheme).

Event names will be introduced closer to completion

Co-authored-by: joel <joel@joels-MacBook-Pro.local>
  • Loading branch information
J0 and joel committed Feb 19, 2024
1 parent 269895f commit 1ea56b6
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 25 deletions.
12 changes: 12 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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="<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 @@ -435,19 +443,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 @@ -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
Expand Down Expand Up @@ -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
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)
}

}

}

0 comments on commit 1ea56b6

Please sign in to comment.