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)
}
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:") {
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
}
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")
if err := viper.BindPFlag("member.oauth.client_id", cmd.PersistentFlags().Lookup("member.oauth.client_id")); err != nil {
return err
@@ -231,6 +236,7 @@ func (s *Member) Set() {
s.OAuth.AutoRedirect = viper.GetBool("member.oauth.auto_redirect")
s.OAuth.Name = viper.GetString("member.oauth.name")
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.ClientSecret = viper.GetString("member.oauth.client_secret")
s.OAuth.IssuerURL = viper.GetString("member.oauth.issuer_url")
+1
View File
@@ -9,6 +9,7 @@ type Config struct {
AutoRedirect bool
Name string
AdminEmails []string
UserEmails []string
ClientID string
ClientSecret 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")
admin := administrator(email, isAdmin, config.AdminEmails)
if !admin && !emailAllowed(email, config.UserEmails) {
return nil, "", types.ErrSessionEmailNotAllowed
}
var profile types.MemberProfile
if administrator(email, isAdmin, config.AdminEmails) {
if admin {
profile = config.AdminProfile
} else {
profile = config.UserProfile
+13
View File
@@ -74,6 +74,19 @@ func administrator(email string, isAdmin bool, emails []string) bool {
if isAdmin {
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 {
if email != "" && strings.EqualFold(strings.TrimSpace(email), strings.TrimSpace(candidate)) {
return true
+1
View File
@@ -12,6 +12,7 @@ var (
ErrSessionAlreadyConnected = errors.New("session is already connected")
ErrSessionLoginDisabled = errors.New("session login disabled")
ErrSessionLoginsLocked = errors.New("session logins locked")
ErrSessionEmailNotAllowed = errors.New("email address is not allowed to log in")
)
type Cursor struct {
@@ -192,6 +192,7 @@ member:
auto_redirect: true
name: "Example SSO"
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_secret: "<client-secret>"
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.
`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 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`.