chatdesk-ui/auth_v2.169.0/internal/conf/configuration.go

1145 lines
36 KiB
Go

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"
}