feat: implement user email restrictions for OAuth login

This commit is contained in:
Miroslav Šedivý
2026-09-23 23:52:47 +02:00
parent d991fec3c7
commit 3f3ae10fb9
7 changed files with 34 additions and 1 deletions
+4
View File
@@ -101,6 +101,10 @@ func oauthServiceError(err error, start bool) error {
return utils.HttpForbidden("logins are locked").WithInternalErr(err) return utils.HttpForbidden("logins are locked").WithInternalErr(err)
} }
if errors.Is(err, types.ErrSessionEmailNotAllowed) {
return utils.HttpForbidden("email address is not allowed to log in").WithInternalErr(err)
}
if start && strings.HasPrefix(err.Error(), "issuer discovery:") { if start && strings.HasPrefix(err.Error(), "issuer discovery:") {
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err) return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
} }
+6
View File
@@ -112,6 +112,11 @@ func (Member) Init(cmd *cobra.Command) error {
return err return err
} }
cmd.PersistentFlags().StringSlice("member.oauth.user_emails", []string{}, "OAuth user-info email addresses allowed to log in, if empty all are allowed")
if err := viper.BindPFlag("member.oauth.user_emails", cmd.PersistentFlags().Lookup("member.oauth.user_emails")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.client_id", "", "OAuth 2.0 client ID") cmd.PersistentFlags().String("member.oauth.client_id", "", "OAuth 2.0 client ID")
if err := viper.BindPFlag("member.oauth.client_id", cmd.PersistentFlags().Lookup("member.oauth.client_id")); err != nil { if err := viper.BindPFlag("member.oauth.client_id", cmd.PersistentFlags().Lookup("member.oauth.client_id")); err != nil {
return err return err
@@ -231,6 +236,7 @@ func (s *Member) Set() {
s.OAuth.AutoRedirect = viper.GetBool("member.oauth.auto_redirect") s.OAuth.AutoRedirect = viper.GetBool("member.oauth.auto_redirect")
s.OAuth.Name = viper.GetString("member.oauth.name") s.OAuth.Name = viper.GetString("member.oauth.name")
s.OAuth.AdminEmails = viper.GetStringSlice("member.oauth.admin_emails") s.OAuth.AdminEmails = viper.GetStringSlice("member.oauth.admin_emails")
s.OAuth.UserEmails = viper.GetStringSlice("member.oauth.user_emails")
s.OAuth.ClientID = viper.GetString("member.oauth.client_id") s.OAuth.ClientID = viper.GetString("member.oauth.client_id")
s.OAuth.ClientSecret = viper.GetString("member.oauth.client_secret") s.OAuth.ClientSecret = viper.GetString("member.oauth.client_secret")
s.OAuth.IssuerURL = viper.GetString("member.oauth.issuer_url") s.OAuth.IssuerURL = viper.GetString("member.oauth.issuer_url")
+1
View File
@@ -9,6 +9,7 @@ type Config struct {
AutoRedirect bool AutoRedirect bool
Name string Name string
AdminEmails []string AdminEmails []string
UserEmails []string
ClientID string ClientID string
ClientSecret string ClientSecret string
IssuerURL string IssuerURL string
+6 -1
View File
@@ -167,9 +167,14 @@ func (service *Service) Complete(ctx context.Context, stateToken, code string) (
} }
isAdmin := boolClaim(claims, "isAdmin") || boolClaim(idClaims, "isAdmin") isAdmin := boolClaim(claims, "isAdmin") || boolClaim(idClaims, "isAdmin")
admin := administrator(email, isAdmin, config.AdminEmails)
if !admin && !emailAllowed(email, config.UserEmails) {
return nil, "", types.ErrSessionEmailNotAllowed
}
var profile types.MemberProfile var profile types.MemberProfile
if administrator(email, isAdmin, config.AdminEmails) { if admin {
profile = config.AdminProfile profile = config.AdminProfile
} else { } else {
profile = config.UserProfile profile = config.UserProfile
+13
View File
@@ -74,6 +74,19 @@ func administrator(email string, isAdmin bool, emails []string) bool {
if isAdmin { if isAdmin {
return true return true
} }
return emailMatches(email, emails)
}
// emailAllowed reports whether email is permitted to log in given a user
// allowlist. An empty allowlist permits every email.
func emailAllowed(email string, emails []string) bool {
if len(emails) == 0 {
return true
}
return emailMatches(email, emails)
}
func emailMatches(email string, emails []string) bool {
for _, candidate := range emails { for _, candidate := range emails {
if email != "" && strings.EqualFold(strings.TrimSpace(email), strings.TrimSpace(candidate)) { if email != "" && strings.EqualFold(strings.TrimSpace(email), strings.TrimSpace(candidate)) {
return true return true
+1
View File
@@ -12,6 +12,7 @@ var (
ErrSessionAlreadyConnected = errors.New("session is already connected") ErrSessionAlreadyConnected = errors.New("session is already connected")
ErrSessionLoginDisabled = errors.New("session login disabled") ErrSessionLoginDisabled = errors.New("session login disabled")
ErrSessionLoginsLocked = errors.New("session logins locked") ErrSessionLoginsLocked = errors.New("session logins locked")
ErrSessionEmailNotAllowed = errors.New("email address is not allowed to log in")
) )
type Cursor struct { type Cursor struct {
@@ -192,6 +192,7 @@ member:
auto_redirect: true auto_redirect: true
name: "Example SSO" name: "Example SSO"
admin_emails: ["platform-admin@example.com", "security@example.com"] admin_emails: ["platform-admin@example.com", "security@example.com"]
user_emails: [] # if empty, any authenticated user is allowed to log in
client_id: "<client-id>" client_id: "<client-id>"
client_secret: "<client-secret>" client_secret: "<client-secret>"
issuer_url: "https://id.example.com" issuer_url: "https://id.example.com"
@@ -227,6 +228,8 @@ The sign-in endpoint is `GET /api/oauth/login`; the callback endpoint is `GET /a
`admin_emails` is a list matched against the standard `email` field, case-insensitively. An OAuth user is an administrator when either their email matches that list or their user-info/ID-token claims include `isAdmin: true`; their permissions then use `admin_profile`. The configured name and avatar fields are read from userinfo first, then fall back to the ID token when userinfo omits them. Avatar URLs are rendered by clients that support member avatars. `admin_emails` is a list matched against the standard `email` field, case-insensitively. An OAuth user is an administrator when either their email matches that list or their user-info/ID-token claims include `isAdmin: true`; their permissions then use `admin_profile`. The configured name and avatar fields are read from userinfo first, then fall back to the ID token when userinfo omits them. Avatar URLs are rendered by clients that support member avatars.
`user_emails` restricts non-admin login to a list of allowed emails, matched the same way as `admin_emails`. If `user_emails` is empty (the default), any authenticated user is allowed to log in. Administrators (matched by `admin_emails` or `isAdmin: true`) can always log in regardless of `user_emails`. Users whose email is not allowed receive an HTTP 403 response and no session is created.
For OAuth sessions, `GET /api/whoami` includes `extra_data`: the merged userinfo and ID-token claims, with userinfo values taking precedence. It is returned only to the authenticated session owner and is intended for profile-field troubleshooting. Do not place secrets in provider profile claims. For OAuth sessions, `GET /api/whoami` includes `extra_data`: the merged userinfo and ID-token claims, with userinfo values taking precedence. It is returned only to the authenticated session owner and is intended for profile-field troubleshooting. Do not place secrets in provider profile claims.
For non-OIDC providers, set `authorization_url`, `token_url`, `userinfo_url`, and optionally `redirect_url` directly. For GitHub, for example, use `read:user` as a scope and set `subject_field: id`, `username_field: login`, and `avatar_field: avatar_url`. For non-OIDC providers, set `authorization_url`, `token_url`, `userinfo_url`, and optionally `redirect_url` directly. For GitHub, for example, use `read:user` as a scope and set `subject_field: id`, `username_field: login`, and `avatar_field: avatar_url`.