package core import ( "strconv" "strings" "time" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/pocketbase/pocketbase/tools/auth" "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/types" "github.com/spf13/cast" ) func (m *Collection) unsetMissingOAuth2MappedFields() { if !m.IsAuth() { return } if m.OAuth2.MappedFields.Id != "" { if m.Fields.GetByName(m.OAuth2.MappedFields.Id) == nil { m.OAuth2.MappedFields.Id = "" } } if m.OAuth2.MappedFields.Name != "" { if m.Fields.GetByName(m.OAuth2.MappedFields.Name) == nil { m.OAuth2.MappedFields.Name = "" } } if m.OAuth2.MappedFields.Username != "" { if m.Fields.GetByName(m.OAuth2.MappedFields.Username) == nil { m.OAuth2.MappedFields.Username = "" } } if m.OAuth2.MappedFields.AvatarURL != "" { if m.Fields.GetByName(m.OAuth2.MappedFields.AvatarURL) == nil { m.OAuth2.MappedFields.AvatarURL = "" } } } func (m *Collection) setDefaultAuthOptions() { m.collectionAuthOptions = collectionAuthOptions{ VerificationTemplate: defaultVerificationTemplate, ResetPasswordTemplate: defaultResetPasswordTemplate, ConfirmEmailChangeTemplate: defaultConfirmEmailChangeTemplate, AuthRule: types.Pointer(""), AuthAlert: AuthAlertConfig{ Enabled: true, EmailTemplate: defaultAuthAlertTemplate, }, PasswordAuth: PasswordAuthConfig{ Enabled: true, IdentityFields: []string{FieldNameEmail}, }, MFA: MFAConfig{ Enabled: false, Duration: 1800, // 30min }, OTP: OTPConfig{ Enabled: false, Duration: 180, // 3min Length: 8, EmailTemplate: defaultOTPTemplate, }, AuthToken: TokenConfig{ Secret: security.RandomString(50), Duration: 604800, // 7 days }, PasswordResetToken: TokenConfig{ Secret: security.RandomString(50), Duration: 1800, // 30min }, EmailChangeToken: TokenConfig{ Secret: security.RandomString(50), Duration: 1800, // 30min }, VerificationToken: TokenConfig{ Secret: security.RandomString(50), Duration: 259200, // 3days }, FileToken: TokenConfig{ Secret: security.RandomString(50), Duration: 180, // 3min }, } } var _ optionsValidator = (*collectionAuthOptions)(nil) // collectionAuthOptions defines the options for the "auth" type collection. type collectionAuthOptions struct { // AuthRule could be used to specify additional record constraints // applied after record authentication and right before returning the // auth token response to the client. // // For example, to allow only verified users you could set it to // "verified = true". // // Set it to empty string to allow any Auth collection record to authenticate. // // Set it to nil to disallow authentication altogether for the collection // (that includes password, OAuth2, etc.). AuthRule *string `form:"authRule" json:"authRule"` // ManageRule gives admin-like permissions to allow fully managing // the auth record(s), eg. changing the password without requiring // to enter the old one, directly updating the verified state and email, etc. // // This rule is executed in addition to the Create and Update API rules. ManageRule *string `form:"manageRule" json:"manageRule"` // AuthAlert defines options related to the auth alerts on new device login. AuthAlert AuthAlertConfig `form:"authAlert" json:"authAlert"` // OAuth2 specifies whether OAuth2 auth is enabled for the collection // and which OAuth2 providers are allowed. OAuth2 OAuth2Config `form:"oauth2" json:"oauth2"` PasswordAuth PasswordAuthConfig `form:"passwordAuth" json:"passwordAuth"` MFA MFAConfig `form:"mfa" json:"mfa"` OTP OTPConfig `form:"otp" json:"otp"` // Various token configurations // --- AuthToken TokenConfig `form:"authToken" json:"authToken"` PasswordResetToken TokenConfig `form:"passwordResetToken" json:"passwordResetToken"` EmailChangeToken TokenConfig `form:"emailChangeToken" json:"emailChangeToken"` VerificationToken TokenConfig `form:"verificationToken" json:"verificationToken"` FileToken TokenConfig `form:"fileToken" json:"fileToken"` // default email templates // --- VerificationTemplate EmailTemplate `form:"verificationTemplate" json:"verificationTemplate"` ResetPasswordTemplate EmailTemplate `form:"resetPasswordTemplate" json:"resetPasswordTemplate"` ConfirmEmailChangeTemplate EmailTemplate `form:"confirmEmailChangeTemplate" json:"confirmEmailChangeTemplate"` } func (o *collectionAuthOptions) validate(cv *collectionValidator) error { err := validation.ValidateStruct(o, validation.Field( &o.AuthRule, validation.By(cv.checkRule), validation.By(cv.ensureNoSystemRuleChange(cv.original.AuthRule)), ), validation.Field( &o.ManageRule, validation.NilOrNotEmpty, validation.By(cv.checkRule), validation.By(cv.ensureNoSystemRuleChange(cv.original.ManageRule)), ), validation.Field(&o.AuthAlert), validation.Field(&o.PasswordAuth), validation.Field(&o.OAuth2), validation.Field(&o.OTP), validation.Field(&o.MFA), validation.Field(&o.AuthToken), validation.Field(&o.PasswordResetToken), validation.Field(&o.EmailChangeToken), validation.Field(&o.VerificationToken), validation.Field(&o.FileToken), validation.Field(&o.VerificationTemplate, validation.Required), validation.Field(&o.ResetPasswordTemplate, validation.Required), validation.Field(&o.ConfirmEmailChangeTemplate, validation.Required), ) if err != nil { return err } if o.MFA.Enabled { // if MFA is enabled require at least 2 auth methods // // @todo maybe consider disabling the check because if custom auth methods // are registered it may fail since we don't have mechanism to detect them at the moment authsEnabled := 0 if o.PasswordAuth.Enabled { authsEnabled++ } if o.OAuth2.Enabled { authsEnabled++ } if o.OTP.Enabled { authsEnabled++ } if authsEnabled < 2 { return validation.Errors{ "mfa": validation.Errors{ "enabled": validation.NewError("validation_mfa_not_enough_auths", "MFA requires at least 2 auth methods to be enabled."), }, } } if o.MFA.Rule != "" { mfaRuleValidators := []validation.RuleFunc{ cv.checkRule, cv.ensureNoSystemRuleChange(&cv.original.MFA.Rule), } for _, validator := range mfaRuleValidators { err := validator(&o.MFA.Rule) if err != nil { return validation.Errors{ "mfa": validation.Errors{ "rule": err, }, } } } } } // extra check to ensure that only unique identity fields are used if o.PasswordAuth.Enabled { err = validation.Validate(o.PasswordAuth.IdentityFields, validation.By(cv.checkFieldsForUniqueIndex)) if err != nil { return validation.Errors{ "passwordAuth": validation.Errors{ "identityFields": err, }, } } } return nil } // ------------------------------------------------------------------- type EmailTemplate struct { Subject string `form:"subject" json:"subject"` Body string `form:"body" json:"body"` } // Validate makes EmailTemplate validatable by implementing [validation.Validatable] interface. func (t EmailTemplate) Validate() error { return validation.ValidateStruct(&t, validation.Field(&t.Subject, validation.Required), validation.Field(&t.Body, validation.Required), ) } // Resolve replaces the placeholder parameters in the current email // template and returns its components as ready-to-use strings. func (t EmailTemplate) Resolve(placeholders map[string]any) (subject, body string) { body = t.Body subject = t.Subject for k, v := range placeholders { vStr := cast.ToString(v) // replace subject placeholder params (if any) subject = strings.ReplaceAll(subject, k, vStr) // replace body placeholder params (if any) body = strings.ReplaceAll(body, k, vStr) } return subject, body } // ------------------------------------------------------------------- type AuthAlertConfig struct { Enabled bool `form:"enabled" json:"enabled"` EmailTemplate EmailTemplate `form:"emailTemplate" json:"emailTemplate"` } // Validate makes AuthAlertConfig validatable by implementing [validation.Validatable] interface. func (c AuthAlertConfig) Validate() error { return validation.ValidateStruct(&c, // note: for now always run the email template validations even // if not enabled since it could be used separately validation.Field(&c.EmailTemplate), ) } // ------------------------------------------------------------------- type TokenConfig struct { Secret string `form:"secret" json:"secret,omitempty"` // Duration specifies how long an issued token to be valid (in seconds) Duration int64 `form:"duration" json:"duration"` } // Validate makes TokenConfig validatable by implementing [validation.Validatable] interface. func (c TokenConfig) Validate() error { return validation.ValidateStruct(&c, validation.Field(&c.Secret, validation.Required, validation.Length(30, 255)), validation.Field(&c.Duration, validation.Required, validation.Min(10), validation.Max(94670856)), // ~3y max ) } // DurationTime returns the current Duration as [time.Duration]. func (c TokenConfig) DurationTime() time.Duration { return time.Duration(c.Duration) * time.Second } // ------------------------------------------------------------------- type OTPConfig struct { Enabled bool `form:"enabled" json:"enabled"` // Duration specifies how long the OTP to be valid (in seconds) Duration int64 `form:"duration" json:"duration"` // Length specifies the auto generated password length. Length int `form:"length" json:"length"` // EmailTemplate is the default OTP email template that will be send to the auth record. // // In addition to the system placeholders you can also make use of // [core.EmailPlaceholderOTPId] and [core.EmailPlaceholderOTP]. EmailTemplate EmailTemplate `form:"emailTemplate" json:"emailTemplate"` } // Validate makes OTPConfig validatable by implementing [validation.Validatable] interface. func (c OTPConfig) Validate() error { return validation.ValidateStruct(&c, validation.Field(&c.Duration, validation.When(c.Enabled, validation.Required, validation.Min(10), validation.Max(86400))), validation.Field(&c.Length, validation.When(c.Enabled, validation.Required, validation.Min(4))), // note: for now always run the email template validations even // if not enabled since it could be used separately validation.Field(&c.EmailTemplate), ) } // DurationTime returns the current Duration as [time.Duration]. func (c OTPConfig) DurationTime() time.Duration { return time.Duration(c.Duration) * time.Second } // ------------------------------------------------------------------- type MFAConfig struct { Enabled bool `form:"enabled" json:"enabled"` // Duration specifies how long an issued MFA to be valid (in seconds) Duration int64 `form:"duration" json:"duration"` // Rule is an optional field to restrict MFA only for the records that satisfy the rule. // // Leave it empty to enable MFA for everyone. Rule string `form:"rule" json:"rule"` } // Validate makes MFAConfig validatable by implementing [validation.Validatable] interface. func (c MFAConfig) Validate() error { return validation.ValidateStruct(&c, validation.Field(&c.Duration, validation.When(c.Enabled, validation.Required, validation.Min(10), validation.Max(86400))), ) } // DurationTime returns the current Duration as [time.Duration]. func (c MFAConfig) DurationTime() time.Duration { return time.Duration(c.Duration) * time.Second } // ------------------------------------------------------------------- type PasswordAuthConfig struct { Enabled bool `form:"enabled" json:"enabled"` // IdentityFields is a list of field names that could be used as // identity during password authentication. // // Usually only fields that has single column UNIQUE index are accepted as values. IdentityFields []string `form:"identityFields" json:"identityFields"` } // Validate makes PasswordAuthConfig validatable by implementing [validation.Validatable] interface. func (c PasswordAuthConfig) Validate() error { // strip duplicated values c.IdentityFields = list.ToUniqueStringSlice(c.IdentityFields) if !c.Enabled { return nil // no need to validate } return validation.ValidateStruct(&c, validation.Field(&c.IdentityFields, validation.Required), ) } // ------------------------------------------------------------------- type OAuth2KnownFields struct { Id string `form:"id" json:"id"` Name string `form:"name" json:"name"` Username string `form:"username" json:"username"` AvatarURL string `form:"avatarURL" json:"avatarURL"` } type OAuth2Config struct { Providers []OAuth2ProviderConfig `form:"providers" json:"providers"` MappedFields OAuth2KnownFields `form:"mappedFields" json:"mappedFields"` Enabled bool `form:"enabled" json:"enabled"` } // GetProviderConfig returns the first OAuth2ProviderConfig that matches the specified name. // // Returns false and zero config if no such provider is available in c.Providers. func (c OAuth2Config) GetProviderConfig(name string) (config OAuth2ProviderConfig, exists bool) { for _, p := range c.Providers { if p.Name == name { return p, true } } return } // Validate makes OAuth2Config validatable by implementing [validation.Validatable] interface. func (c OAuth2Config) Validate() error { if !c.Enabled { return nil // no need to validate } return validation.ValidateStruct(&c, // note: don't require providers for now as they could be externally registered/removed validation.Field(&c.Providers, validation.By(checkForDuplicatedProviders)), ) } func checkForDuplicatedProviders(value any) error { configs, _ := value.([]OAuth2ProviderConfig) existing := map[string]struct{}{} for i, c := range configs { if c.Name == "" { continue // the name nonempty state is validated separately } if _, ok := existing[c.Name]; ok { return validation.Errors{ strconv.Itoa(i): validation.Errors{ "name": validation.NewError("validation_duplicated_provider", "The provider "+c.Name+" is already registered."). SetParams(map[string]any{"name": c.Name}), }, } } existing[c.Name] = struct{}{} } return nil } type OAuth2ProviderConfig struct { // PKCE overwrites the default provider PKCE config option. // // This usually shouldn't be needed but some OAuth2 vendors, like the LinkedIn OIDC, // may require manual adjustment due to returning error if extra parameters are added to the request // (https://github.com/pocketbase/pocketbase/discussions/3799#discussioncomment-7640312) PKCE *bool `form:"pkce" json:"pkce"` Name string `form:"name" json:"name"` ClientId string `form:"clientId" json:"clientId"` ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"` AuthURL string `form:"authURL" json:"authURL"` TokenURL string `form:"tokenURL" json:"tokenURL"` UserInfoURL string `form:"userInfoURL" json:"userInfoURL"` DisplayName string `form:"displayName" json:"displayName"` Extra map[string]any `form:"extra" json:"extra"` } // Validate makes OAuth2ProviderConfig validatable by implementing [validation.Validatable] interface. func (c OAuth2ProviderConfig) Validate() error { return validation.ValidateStruct(&c, validation.Field(&c.Name, validation.Required, validation.By(checkProviderName)), validation.Field(&c.ClientId, validation.Required), validation.Field(&c.ClientSecret, validation.Required), validation.Field(&c.AuthURL, is.URL), validation.Field(&c.TokenURL, is.URL), validation.Field(&c.UserInfoURL, is.URL), ) } func checkProviderName(value any) error { name, _ := value.(string) if name == "" { return nil // nothing to check } if _, err := auth.NewProviderByName(name); err != nil { return validation.NewError("validation_missing_provider", "Invalid or missing provider with name "+name+"."). SetParams(map[string]any{"name": name}) } return nil } // InitProvider returns a new auth.Provider instance loaded with the current OAuth2ProviderConfig options. func (c OAuth2ProviderConfig) InitProvider() (auth.Provider, error) { provider, err := auth.NewProviderByName(c.Name) if err != nil { return nil, err } if c.ClientId != "" { provider.SetClientId(c.ClientId) } if c.ClientSecret != "" { provider.SetClientSecret(c.ClientSecret) } if c.AuthURL != "" { provider.SetAuthURL(c.AuthURL) } if c.UserInfoURL != "" { provider.SetUserInfoURL(c.UserInfoURL) } if c.TokenURL != "" { provider.SetTokenURL(c.TokenURL) } if c.DisplayName != "" { provider.SetDisplayName(c.DisplayName) } if c.PKCE != nil { provider.SetPKCE(*c.PKCE) } if c.Extra != nil { provider.SetExtra(c.Extra) } return provider, nil }