package conf import ( "bytes" "encoding/base64" "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "regexp" "strings" "text/template" "time" "github.com/gobwas/glob" "github.com/golang-jwt/jwt/v5" "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" "github.com/lestrrat-go/jwx/v2/jwk" "gopkg.in/gomail.v2" ) const defaultMinPasswordLength int = 6 const defaultChallengeExpiryDuration float64 = 300 const defaultFactorExpiryDuration time.Duration = 300 * time.Second 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 { time.Time } func (t *Time) UnmarshalText(text []byte) error { trimed := bytes.TrimSpace(text) if len(trimed) < 1 { t.Time = time.Time{} } else { if err := t.Time.UnmarshalText(trimed); err != nil { return err } } return nil } // OAuthProviderConfiguration holds all config related to external account providers. type OAuthProviderConfiguration struct { ClientID []string `json:"client_id" split_words:"true"` Secret string `json:"secret"` RedirectURI string `json:"redirect_uri" split_words:"true"` URL string `json:"url"` ApiURL string `json:"api_url" split_words:"true"` Enabled bool `json:"enabled"` SkipNonceCheck bool `json:"skip_nonce_check" split_words:"true"` } type AnonymousProviderConfiguration struct { Enabled bool `json:"enabled" default:"false"` } type EmailProviderConfiguration struct { Enabled bool `json:"enabled" default:"true"` AuthorizedAddresses []string `json:"authorized_addresses" split_words:"true"` MagicLinkEnabled bool `json:"magic_link_enabled" default:"true" split_words:"true"` } // DBConfiguration holds all the database related configuration. type DBConfiguration struct { Driver string `json:"driver" required:"true"` URL string `json:"url" envconfig:"DATABASE_URL" required:"true"` Namespace string `json:"namespace" envconfig:"DB_NAMESPACE" default:"auth"` // MaxPoolSize defaults to 0 (unlimited). MaxPoolSize int `json:"max_pool_size" split_words:"true"` MaxIdlePoolSize int `json:"max_idle_pool_size" split_words:"true"` ConnMaxLifetime time.Duration `json:"conn_max_lifetime,omitempty" split_words:"true"` ConnMaxIdleTime time.Duration `json:"conn_max_idle_time,omitempty" split_words:"true"` HealthCheckPeriod time.Duration `json:"health_check_period" split_words:"true"` MigrationsPath string `json:"migrations_path" split_words:"true" default:"./migrations"` CleanupEnabled bool `json:"cleanup_enabled" split_words:"true" default:"false"` } func (c *DBConfiguration) Validate() error { return nil } // JWTConfiguration holds all the JWT related configuration. type JWTConfiguration struct { Secret string `json:"secret" required:"true"` Exp int `json:"exp"` Aud string `json:"aud"` AdminGroupName string `json:"admin_group_name" split_words:"true"` AdminRoles []string `json:"admin_roles" split_words:"true"` DefaultGroupName string `json:"default_group_name" split_words:"true"` Issuer string `json:"issuer"` KeyID string `json:"key_id" split_words:"true"` Keys JwtKeysDecoder `json:"keys"` ValidMethods []string `json:"-"` } type MFAFactorTypeConfiguration struct { EnrollEnabled bool `json:"enroll_enabled" split_words:"true" default:"false"` VerifyEnabled bool `json:"verify_enabled" split_words:"true" default:"false"` } type TOTPFactorTypeConfiguration struct { EnrollEnabled bool `json:"enroll_enabled" split_words:"true" default:"true"` VerifyEnabled bool `json:"verify_enabled" split_words:"true" default:"true"` } type PhoneFactorTypeConfiguration struct { // Default to false in order to ensure Phone MFA is opt-in MFAFactorTypeConfiguration OtpLength int `json:"otp_length" split_words:"true"` SMSTemplate *template.Template `json:"-"` MaxFrequency time.Duration `json:"max_frequency" split_words:"true"` Template string `json:"template"` } // MFAConfiguration holds all the MFA related Configuration type MFAConfiguration struct { ChallengeExpiryDuration float64 `json:"challenge_expiry_duration" default:"300" split_words:"true"` FactorExpiryDuration time.Duration `json:"factor_expiry_duration" default:"300s" split_words:"true"` RateLimitChallengeAndVerify float64 `split_words:"true" default:"15"` MaxEnrolledFactors float64 `split_words:"true" default:"10"` MaxVerifiedFactors int `split_words:"true" default:"10"` Phone PhoneFactorTypeConfiguration `split_words:"true"` TOTP TOTPFactorTypeConfiguration `split_words:"true"` WebAuthn MFAFactorTypeConfiguration `split_words:"true"` } type APIConfiguration struct { Host string Port string `envconfig:"PORT" default:"8081"` Endpoint string RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"` ExternalURL string `json:"external_url" envconfig:"API_EXTERNAL_URL" required:"true"` MaxRequestDuration time.Duration `json:"max_request_duration" split_words:"true" default:"10s"` } func (a *APIConfiguration) Validate() error { _, err := url.ParseRequestURI(a.ExternalURL) if err != nil { return err } return nil } type SessionsConfiguration struct { Timebox *time.Duration `json:"timebox"` InactivityTimeout *time.Duration `json:"inactivity_timeout,omitempty" split_words:"true"` SinglePerUser bool `json:"single_per_user" split_words:"true"` Tags []string `json:"tags,omitempty"` } func (c *SessionsConfiguration) Validate() error { if c.Timebox == nil { return nil } if *c.Timebox <= time.Duration(0) { return fmt.Errorf("conf: session timebox duration must be positive when set, was %v", (*c.Timebox).String()) } return nil } type PasswordRequiredCharacters []string func (v *PasswordRequiredCharacters) Decode(value string) error { parts := strings.Split(value, ":") for i := 0; i < len(parts)-1; i += 1 { part := parts[i] if part == "" { continue } // part ended in escape character, so it should be joined with the next one if part[len(part)-1] == '\\' { parts[i] = part[0:len(part)-1] + ":" + parts[i+1] parts[i+1] = "" continue } } for _, part := range parts { if part != "" { *v = append(*v, part) } } return nil } // HIBPBloomConfiguration configures a bloom cache for pwned passwords. Use // this tool to gauge the Items and FalsePositives values: // https://hur.st/bloomfilter type HIBPBloomConfiguration struct { Enabled bool `json:"enabled"` Items uint `json:"items" default:"100000"` FalsePositives float64 `json:"false_positives" split_words:"true" default:"0.0000099"` } type HIBPConfiguration struct { Enabled bool `json:"enabled"` FailClosed bool `json:"fail_closed" split_words:"true"` UserAgent string `json:"user_agent" split_words:"true" default:"https://github.com/supabase/gotrue"` Bloom HIBPBloomConfiguration `json:"bloom"` } type PasswordConfiguration struct { MinLength int `json:"min_length" split_words:"true"` RequiredCharacters PasswordRequiredCharacters `json:"required_characters" split_words:"true"` HIBP HIBPConfiguration `json:"hibp"` } // GlobalConfiguration holds all the configuration that applies to all instances. type GlobalConfiguration struct { API APIConfiguration DB DBConfiguration External ProviderConfiguration Logging LoggingConfig `envconfig:"LOG"` Profiler ProfilerConfig `envconfig:"PROFILER"` OperatorToken string `split_words:"true" required:"false"` Tracing TracingConfig Metrics MetricsConfig SMTP SMTPConfiguration RateLimitHeader string `split_words:"true"` RateLimitEmailSent Rate `split_words:"true" default:"30"` RateLimitSmsSent Rate `split_words:"true" default:"30"` RateLimitVerify float64 `split_words:"true" default:"30"` RateLimitTokenRefresh float64 `split_words:"true" default:"150"` RateLimitSso float64 `split_words:"true" default:"30"` RateLimitAnonymousUsers float64 `split_words:"true" default:"30"` RateLimitOtp float64 `split_words:"true" default:"30"` SiteURL string `json:"site_url" split_words:"true" required:"true"` URIAllowList []string `json:"uri_allow_list" split_words:"true"` URIAllowListMap map[string]glob.Glob Password PasswordConfiguration `json:"password"` JWT JWTConfiguration `json:"jwt"` Mailer MailerConfiguration `json:"mailer"` Sms SmsProviderConfiguration `json:"sms"` DisableSignup bool `json:"disable_signup" split_words:"true"` Hook HookConfiguration `json:"hook" split_words:"true"` Security SecurityConfiguration `json:"security"` Sessions SessionsConfiguration `json:"sessions"` MFA MFAConfiguration `json:"MFA"` SAML SAMLConfiguration `json:"saml"` CORS CORSConfiguration `json:"cors"` } type CORSConfiguration struct { AllowedHeaders []string `json:"allowed_headers" split_words:"true"` } func (c *CORSConfiguration) AllAllowedHeaders(defaults []string) []string { set := make(map[string]bool) for _, header := range defaults { set[header] = true } var result []string result = append(result, defaults...) for _, header := range c.AllowedHeaders { if !set[header] { result = append(result, header) } set[header] = true } return result } // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs. type EmailContentConfiguration struct { Invite string `json:"invite"` Confirmation string `json:"confirmation"` Recovery string `json:"recovery"` EmailChange string `json:"email_change" split_words:"true"` MagicLink string `json:"magic_link" split_words:"true"` Reauthentication string `json:"reauthentication"` } type ProviderConfiguration struct { AnonymousUsers AnonymousProviderConfiguration `json:"anonymous_users" split_words:"true"` Apple OAuthProviderConfiguration `json:"apple"` Azure OAuthProviderConfiguration `json:"azure"` Bitbucket OAuthProviderConfiguration `json:"bitbucket"` Discord OAuthProviderConfiguration `json:"discord"` Facebook OAuthProviderConfiguration `json:"facebook"` Figma OAuthProviderConfiguration `json:"figma"` Fly OAuthProviderConfiguration `json:"fly"` Github OAuthProviderConfiguration `json:"github"` Gitlab OAuthProviderConfiguration `json:"gitlab"` Google OAuthProviderConfiguration `json:"google"` Kakao OAuthProviderConfiguration `json:"kakao"` Notion OAuthProviderConfiguration `json:"notion"` Keycloak OAuthProviderConfiguration `json:"keycloak"` Linkedin OAuthProviderConfiguration `json:"linkedin"` LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` Spotify OAuthProviderConfiguration `json:"spotify"` Slack OAuthProviderConfiguration `json:"slack"` SlackOIDC OAuthProviderConfiguration `json:"slack_oidc" envconfig:"SLACK_OIDC"` Twitter OAuthProviderConfiguration `json:"twitter"` Twitch OAuthProviderConfiguration `json:"twitch"` VercelMarketplace OAuthProviderConfiguration `json:"vercel_marketplace" split_words:"true"` WorkOS OAuthProviderConfiguration `json:"workos"` Email EmailProviderConfiguration `json:"email"` Phone PhoneProviderConfiguration `json:"phone"` Zoom OAuthProviderConfiguration `json:"zoom"` IosBundleId string `json:"ios_bundle_id" split_words:"true"` RedirectURL string `json:"redirect_url"` AllowedIdTokenIssuers []string `json:"allowed_id_token_issuers" split_words:"true"` FlowStateExpiryDuration time.Duration `json:"flow_state_expiry_duration" split_words:"true"` } type SMTPConfiguration struct { MaxFrequency time.Duration `json:"max_frequency" split_words:"true"` Host string `json:"host"` Port int `json:"port,omitempty" default:"587"` User string `json:"user"` Pass string `json:"pass,omitempty"` AdminEmail string `json:"admin_email" split_words:"true"` SenderName string `json:"sender_name" split_words:"true"` Headers string `json:"headers"` LoggingEnabled bool `json:"logging_enabled" split_words:"true" default:"false"` fromAddress string `json:"-"` normalizedHeaders map[string][]string `json:"-"` } func (c *SMTPConfiguration) Validate() error { headers := make(map[string][]string) if c.Headers != "" { err := json.Unmarshal([]byte(c.Headers), &headers) if err != nil { return fmt.Errorf("conf: SMTP headers not a map[string][]string format: %w", err) } } if len(headers) > 0 { c.normalizedHeaders = headers } mail := gomail.NewMessage() c.fromAddress = mail.FormatAddress(c.AdminEmail, c.SenderName) return nil } func (c *SMTPConfiguration) FromAddress() string { return c.fromAddress } func (c *SMTPConfiguration) NormalizedHeaders() map[string][]string { return c.normalizedHeaders } type MailerConfiguration struct { Autoconfirm bool `json:"autoconfirm"` AllowUnverifiedEmailSignIns bool `json:"allow_unverified_email_sign_ins" split_words:"true" default:"false"` Subjects EmailContentConfiguration `json:"subjects"` Templates EmailContentConfiguration `json:"templates"` URLPaths EmailContentConfiguration `json:"url_paths"` SecureEmailChangeEnabled bool `json:"secure_email_change_enabled" split_words:"true" default:"true"` OtpExp uint `json:"otp_exp" split_words:"true"` OtpLength int `json:"otp_length" split_words:"true"` ExternalHosts []string `json:"external_hosts" split_words:"true"` // EXPERIMENTAL: May be removed in a future release. EmailValidationExtended bool `json:"email_validation_extended" split_words:"true" default:"false"` EmailValidationServiceURL string `json:"email_validation_service_url" split_words:"true"` EmailValidationServiceHeaders string `json:"email_validation_service_headers" split_words:"true"` serviceHeaders map[string][]string `json:"-"` } func (c *MailerConfiguration) Validate() error { headers := make(map[string][]string) if c.EmailValidationServiceHeaders != "" { err := json.Unmarshal([]byte(c.EmailValidationServiceHeaders), &headers) if err != nil { return fmt.Errorf("conf: mailer validation headers not a map[string][]string format: %w", err) } } if len(headers) > 0 { c.serviceHeaders = headers } return nil } func (c *MailerConfiguration) GetEmailValidationServiceHeaders() map[string][]string { return c.serviceHeaders } type PhoneProviderConfiguration struct { Enabled bool `json:"enabled" default:"false"` } type SmsProviderConfiguration struct { Autoconfirm bool `json:"autoconfirm"` MaxFrequency time.Duration `json:"max_frequency" split_words:"true"` OtpExp uint `json:"otp_exp" split_words:"true"` OtpLength int `json:"otp_length" split_words:"true"` Provider string `json:"provider"` Template string `json:"template"` TestOTP map[string]string `json:"test_otp" split_words:"true"` TestOTPValidUntil Time `json:"test_otp_valid_until" split_words:"true"` SMSTemplate *template.Template `json:"-"` Twilio TwilioProviderConfiguration `json:"twilio"` TwilioVerify TwilioVerifyProviderConfiguration `json:"twilio_verify" split_words:"true"` Messagebird MessagebirdProviderConfiguration `json:"messagebird"` Textlocal TextlocalProviderConfiguration `json:"textlocal"` Vonage VonageProviderConfiguration `json:"vonage"` } func (c *SmsProviderConfiguration) GetTestOTP(phone string, now time.Time) (string, bool) { if c.TestOTP != nil && (c.TestOTPValidUntil.Time.IsZero() || now.Before(c.TestOTPValidUntil.Time)) { testOTP, ok := c.TestOTP[phone] return testOTP, ok } return "", false } type TwilioProviderConfiguration struct { AccountSid string `json:"account_sid" split_words:"true"` AuthToken string `json:"auth_token" split_words:"true"` MessageServiceSid string `json:"message_service_sid" split_words:"true"` ContentSid string `json:"content_sid" split_words:"true"` } type TwilioVerifyProviderConfiguration struct { AccountSid string `json:"account_sid" split_words:"true"` AuthToken string `json:"auth_token" split_words:"true"` MessageServiceSid string `json:"message_service_sid" split_words:"true"` } type MessagebirdProviderConfiguration struct { AccessKey string `json:"access_key" split_words:"true"` Originator string `json:"originator" split_words:"true"` } type TextlocalProviderConfiguration struct { ApiKey string `json:"api_key" split_words:"true"` Sender string `json:"sender" split_words:"true"` } type VonageProviderConfiguration struct { ApiKey string `json:"api_key" split_words:"true"` ApiSecret string `json:"api_secret" split_words:"true"` From string `json:"from" split_words:"true"` } type CaptchaConfiguration struct { Enabled bool `json:"enabled" default:"false"` Provider string `json:"provider" default:"hcaptcha"` Secret string `json:"provider_secret"` } func (c *CaptchaConfiguration) Validate() error { if !c.Enabled { return nil } if c.Provider != "hcaptcha" && c.Provider != "turnstile" { return fmt.Errorf("unsupported captcha provider: %s", c.Provider) } c.Secret = strings.TrimSpace(c.Secret) if c.Secret == "" { return errors.New("captcha provider secret is empty") } return nil } // DatabaseEncryptionConfiguration configures Auth to encrypt certain columns. // Once Encrypt is set to true, data will start getting encrypted with the // provided encryption key. Setting it to false just stops encryption from // going on further, but DecryptionKeys would have to contain the same key so // the encrypted data remains accessible. type DatabaseEncryptionConfiguration struct { Encrypt bool `json:"encrypt"` EncryptionKeyID string `json:"encryption_key_id" split_words:"true"` EncryptionKey string `json:"-" split_words:"true"` DecryptionKeys map[string]string `json:"-" split_words:"true"` } func (c *DatabaseEncryptionConfiguration) Validate() error { if c.Encrypt { if c.EncryptionKeyID == "" { return errors.New("conf: encryption key ID must be specified") } decodedKey, err := base64.RawURLEncoding.DecodeString(c.EncryptionKey) if err != nil { return err } if len(decodedKey) != 256/8 { return errors.New("conf: encryption key is not 256 bits") } if c.DecryptionKeys == nil || c.DecryptionKeys[c.EncryptionKeyID] == "" { return errors.New("conf: encryption key must also be present in decryption keys") } } for id, key := range c.DecryptionKeys { decodedKey, err := base64.RawURLEncoding.DecodeString(key) if err != nil { return err } if len(decodedKey) != 256/8 { return fmt.Errorf("conf: decryption key with ID %q must be 256 bits", id) } } return nil } type SecurityConfiguration struct { Captcha CaptchaConfiguration `json:"captcha"` RefreshTokenRotationEnabled bool `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"` RefreshTokenReuseInterval int `json:"refresh_token_reuse_interval" split_words:"true"` UpdatePasswordRequireReauthentication bool `json:"update_password_require_reauthentication" split_words:"true"` ManualLinkingEnabled bool `json:"manual_linking_enabled" split_words:"true" default:"false"` DBEncryption DatabaseEncryptionConfiguration `json:"database_encryption" split_words:"true"` } func (c *SecurityConfiguration) Validate() error { if err := c.Captcha.Validate(); err != nil { return err } if err := c.DBEncryption.Validate(); err != nil { return err } return nil } func loadEnvironment(filename string) error { var err error if filename != "" { err = godotenv.Overload(filename) } else { err = godotenv.Load() // handle if .env file does not exist, this is OK if os.IsNotExist(err) { return nil } } return err } // Moving away from the existing HookConfig so we can get a fresh start. 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"` SendEmail ExtensibilityPointConfiguration `json:"send_email" split_words:"true"` SendSMS ExtensibilityPointConfiguration `json:"send_sms" split_words:"true"` } type HTTPHookSecrets []string func (h *HTTPHookSecrets) Decode(value string) error { parts := strings.Split(value, "|") for _, part := range parts { if part != "" { *h = append(*h, part) } } return nil } type ExtensibilityPointConfiguration struct { URI string `json:"uri"` Enabled bool `json:"enabled"` // For internal use together with Postgres Hook. Not publicly exposed. HookName string `json:"-"` // We use | as a separator for keys and : as a separator for keys within a keypair. For instance: v1,whsec_test|v1a,whpk_myother:v1a,whsk_testkey|v1,whsec_secret3 HTTPHookSecrets HTTPHookSecrets `json:"secrets" envconfig:"secrets"` } func (h *HookConfiguration) Validate() error { points := []ExtensibilityPointConfiguration{ h.MFAVerificationAttempt, h.PasswordVerificationAttempt, h.CustomAccessToken, h.SendSMS, h.SendEmail, } for _, point := range points { if err := point.ValidateExtensibilityPoint(); err != nil { return err } } return nil } func (e *ExtensibilityPointConfiguration) ValidateExtensibilityPoint() error { 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 "http": hostname := u.Hostname() if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" || hostname == "host.docker.internal" { return validateHTTPHookSecrets(e.HTTPHookSecrets) } return fmt.Errorf("only localhost, 127.0.0.1, and ::1 are supported with http") case "https": return validateHTTPHookSecrets(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 validateHTTPHookSecrets(secrets []string) error { for _, secret := range secrets { if !isValidSecretFormat(secret) { return fmt.Errorf("invalid secret format") } } return nil } func (e *ExtensibilityPointConfiguration) PopulateExtensibilityPoint() error { u, err := url.Parse(e.URI) if err != nil { return err } if u.Scheme == "pg-functions" { pathParts := strings.Split(u.Path, "/") e.HookName = fmt.Sprintf("%q.%q", pathParts[1], pathParts[2]) } return nil } // LoadFile calls godotenv.Load() when the given filename is empty ignoring any // errors loading, otherwise it calls godotenv.Overload(filename). // // godotenv.Load: preserves env, ".env" path is optional // godotenv.Overload: overrides env, "filename" path must exist func LoadFile(filename string) error { var err error if filename != "" { err = godotenv.Overload(filename) } else { err = godotenv.Load() // handle if .env file does not exist, this is OK if os.IsNotExist(err) { return nil } } return err } // LoadDirectory does nothing when configDir is empty, otherwise it will attempt // to load a list of configuration files located in configDir by using ReadDir // to obtain a sorted list of files containing a .env suffix. // // When the list is empty it will do nothing, otherwise it passes the file list // to godotenv.Overload to pull them into the current environment. func LoadDirectory(configDir string) error { if configDir == "" { return nil } // Returns entries sorted by filename ents, err := os.ReadDir(configDir) if err != nil { // We mimic the behavior of LoadGlobal here, if an explicit path is // provided we return an error. return err } var paths []string for _, ent := range ents { if ent.IsDir() { continue // ignore directories } // We only read files ending in .env name := ent.Name() if !strings.HasSuffix(name, ".env") { continue } // ent.Name() does not include the watch dir. paths = append(paths, filepath.Join(configDir, name)) } // If at least one path was found we load the configuration files in the // directory. We don't call override without config files because it will // override the env vars previously set with a ".env", if one exists. if len(paths) > 0 { if err := godotenv.Overload(paths...); err != nil { return err } } return nil } // LoadGlobalFromEnv will return a new *GlobalConfiguration value from the // currently configured environment. func LoadGlobalFromEnv() (*GlobalConfiguration, error) { config := new(GlobalConfiguration) if err := loadGlobal(config); err != nil { return nil, err } return config, nil } func LoadGlobal(filename string) (*GlobalConfiguration, error) { if err := loadEnvironment(filename); err != nil { return nil, err } config := new(GlobalConfiguration) if err := loadGlobal(config); err != nil { return nil, err } return config, nil } func loadGlobal(config *GlobalConfiguration) error { // although the package is called "auth" it used to be called "gotrue" // so environment configs will remain to be called "GOTRUE" if err := envconfig.Process("gotrue", config); err != nil { return err } if err := config.ApplyDefaults(); err != nil { return err } if err := config.Validate(); err != nil { return err } if config.Hook.PasswordVerificationAttempt.Enabled { if err := config.Hook.PasswordVerificationAttempt.PopulateExtensibilityPoint(); err != nil { return err } } if config.Hook.SendSMS.Enabled { if err := config.Hook.SendSMS.PopulateExtensibilityPoint(); err != nil { return err } } if config.Hook.SendEmail.Enabled { if err := config.Hook.SendEmail.PopulateExtensibilityPoint(); err != nil { return err } } if config.Hook.MFAVerificationAttempt.Enabled { if err := config.Hook.MFAVerificationAttempt.PopulateExtensibilityPoint(); err != nil { return err } } if config.Hook.CustomAccessToken.Enabled { if err := config.Hook.CustomAccessToken.PopulateExtensibilityPoint(); err != nil { return err } } if config.SAML.Enabled { if err := config.SAML.PopulateFields(config.API.ExternalURL); err != nil { return err } } else { config.SAML.PrivateKey = "" } if config.Sms.Provider != "" { SMSTemplate := config.Sms.Template if SMSTemplate == "" { SMSTemplate = "Your code is {{ .Code }}" } template, err := template.New("").Parse(SMSTemplate) if err != nil { return err } config.Sms.SMSTemplate = template } if config.MFA.Phone.EnrollEnabled || config.MFA.Phone.VerifyEnabled { smsTemplate := config.MFA.Phone.Template if smsTemplate == "" { smsTemplate = "Your code is {{ .Code }}" } template, err := template.New("").Parse(smsTemplate) if err != nil { return err } config.MFA.Phone.SMSTemplate = template } return nil } // ApplyDefaults sets defaults for a GlobalConfiguration func (config *GlobalConfiguration) ApplyDefaults() error { if config.JWT.AdminGroupName == "" { config.JWT.AdminGroupName = "admin" } if len(config.JWT.AdminRoles) == 0 { config.JWT.AdminRoles = []string{"service_role", "supabase_admin"} } if config.JWT.Exp == 0 { config.JWT.Exp = 3600 } if len(config.JWT.Keys) == 0 { // transform the secret into a JWK for consistency privKey, err := jwk.FromRaw([]byte(config.JWT.Secret)) if err != nil { return err } if config.JWT.KeyID != "" { if err := privKey.Set(jwk.KeyIDKey, config.JWT.KeyID); err != nil { return err } } if privKey.Algorithm().String() == "" { if err := privKey.Set(jwk.AlgorithmKey, jwt.SigningMethodHS256.Name); err != nil { return err } } if err := privKey.Set(jwk.KeyUsageKey, "sig"); err != nil { return err } if len(privKey.KeyOps()) == 0 { if err := privKey.Set(jwk.KeyOpsKey, jwk.KeyOperationList{jwk.KeyOpSign, jwk.KeyOpVerify}); err != nil { return err } } pubKey, err := privKey.PublicKey() if err != nil { return err } config.JWT.Keys = make(JwtKeysDecoder) config.JWT.Keys[config.JWT.KeyID] = JwkInfo{ PublicKey: pubKey, PrivateKey: privKey, } } if config.JWT.ValidMethods == nil { config.JWT.ValidMethods = []string{} for _, key := range config.JWT.Keys { alg := GetSigningAlg(key.PublicKey) config.JWT.ValidMethods = append(config.JWT.ValidMethods, alg.Alg()) } } if config.Mailer.Autoconfirm && config.Mailer.AllowUnverifiedEmailSignIns { return errors.New("cannot enable both GOTRUE_MAILER_AUTOCONFIRM and GOTRUE_MAILER_ALLOW_UNVERIFIED_EMAIL_SIGN_INS") } if config.Mailer.URLPaths.Invite == "" { config.Mailer.URLPaths.Invite = "/verify" } if config.Mailer.URLPaths.Confirmation == "" { config.Mailer.URLPaths.Confirmation = "/verify" } if config.Mailer.URLPaths.Recovery == "" { config.Mailer.URLPaths.Recovery = "/verify" } if config.Mailer.URLPaths.EmailChange == "" { config.Mailer.URLPaths.EmailChange = "/verify" } if config.Mailer.OtpExp == 0 { config.Mailer.OtpExp = 86400 // 1 day } if config.Mailer.OtpLength == 0 || config.Mailer.OtpLength < 6 || config.Mailer.OtpLength > 10 { // 6-digit otp by default config.Mailer.OtpLength = 6 } if config.SMTP.MaxFrequency == 0 { config.SMTP.MaxFrequency = 1 * time.Minute } if config.Sms.MaxFrequency == 0 { config.Sms.MaxFrequency = 1 * time.Minute } if config.Sms.OtpExp == 0 { config.Sms.OtpExp = 60 } if config.Sms.OtpLength == 0 || config.Sms.OtpLength < 6 || config.Sms.OtpLength > 10 { // 6-digit otp by default config.Sms.OtpLength = 6 } if config.Sms.TestOTP != nil { formatTestOtps := make(map[string]string) for phone, otp := range config.Sms.TestOTP { phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), " ", "") formatTestOtps[phone] = otp } config.Sms.TestOTP = formatTestOtps } if len(config.Sms.Template) == 0 { config.Sms.Template = "" } if config.URIAllowList == nil { config.URIAllowList = []string{} } if config.URIAllowList != nil { config.URIAllowListMap = make(map[string]glob.Glob) for _, uri := range config.URIAllowList { g := glob.MustCompile(uri, '.', '/') config.URIAllowListMap[uri] = g } } if config.Password.MinLength < defaultMinPasswordLength { config.Password.MinLength = defaultMinPasswordLength } if config.MFA.ChallengeExpiryDuration < defaultChallengeExpiryDuration { config.MFA.ChallengeExpiryDuration = defaultChallengeExpiryDuration } if config.MFA.FactorExpiryDuration < defaultFactorExpiryDuration { config.MFA.FactorExpiryDuration = defaultFactorExpiryDuration } if config.MFA.Phone.MaxFrequency == 0 { config.MFA.Phone.MaxFrequency = 1 * time.Minute } if config.MFA.Phone.OtpLength < 6 || config.MFA.Phone.OtpLength > 10 { // 6-digit otp by default config.MFA.Phone.OtpLength = 6 } if config.External.FlowStateExpiryDuration < defaultFlowStateExpiryDuration { config.External.FlowStateExpiryDuration = defaultFlowStateExpiryDuration } if len(config.External.AllowedIdTokenIssuers) == 0 { config.External.AllowedIdTokenIssuers = append(config.External.AllowedIdTokenIssuers, "https://appleid.apple.com", "https://accounts.google.com") } return nil } // Validate validates all of configuration. func (c *GlobalConfiguration) Validate() error { validatables := []interface { Validate() error }{ &c.API, &c.DB, &c.Tracing, &c.Metrics, &c.SMTP, &c.Mailer, &c.SAML, &c.Security, &c.Sessions, &c.Hook, &c.JWT.Keys, } for _, validatable := range validatables { if err := validatable.Validate(); err != nil { return err } } return nil } func (o *OAuthProviderConfiguration) ValidateOAuth() error { if !o.Enabled { return errors.New("provider is not enabled") } if len(o.ClientID) == 0 { return errors.New("missing OAuth client ID") } if o.Secret == "" { return errors.New("missing OAuth secret") } if o.RedirectURI == "" { return errors.New("missing redirect URI") } return nil } func (t *TwilioProviderConfiguration) Validate() error { if t.AccountSid == "" { return errors.New("missing Twilio account SID") } if t.AuthToken == "" { return errors.New("missing Twilio auth token") } if t.MessageServiceSid == "" { return errors.New("missing Twilio message service SID or Twilio phone number") } return nil } func (t *TwilioVerifyProviderConfiguration) Validate() error { if t.AccountSid == "" { return errors.New("missing Twilio account SID") } if t.AuthToken == "" { return errors.New("missing Twilio auth token") } if t.MessageServiceSid == "" { return errors.New("missing Twilio message service SID or Twilio phone number") } return nil } func (t *MessagebirdProviderConfiguration) Validate() error { if t.AccessKey == "" { return errors.New("missing Messagebird access key") } if t.Originator == "" { return errors.New("missing Messagebird originator") } return nil } func (t *TextlocalProviderConfiguration) Validate() error { if t.ApiKey == "" { return errors.New("missing Textlocal API key") } if t.Sender == "" { return errors.New("missing Textlocal sender") } return nil } func (t *VonageProviderConfiguration) Validate() error { if t.ApiKey == "" { return errors.New("missing Vonage API key") } if t.ApiSecret == "" { return errors.New("missing Vonage API secret") } if t.From == "" { return errors.New("missing Vonage 'from' parameter") } return nil } func (t *SmsProviderConfiguration) IsTwilioVerifyProvider() bool { return t.Provider == "twilio_verify" }