diff --git a/server/internal/api/oauth.go b/server/internal/api/oauth.go index d056abd5..16449df0 100644 --- a/server/internal/api/oauth.go +++ b/server/internal/api/oauth.go @@ -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) } diff --git a/server/internal/config/member.go b/server/internal/config/member.go index 7d252763..c37c5f2e 100644 --- a/server/internal/config/member.go +++ b/server/internal/config/member.go @@ -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") diff --git a/server/internal/member/oauth/config.go b/server/internal/member/oauth/config.go index 9a077b14..e2ad193e 100644 --- a/server/internal/member/oauth/config.go +++ b/server/internal/member/oauth/config.go @@ -9,6 +9,7 @@ type Config struct { AutoRedirect bool Name string AdminEmails []string + UserEmails []string ClientID string ClientSecret string IssuerURL string diff --git a/server/internal/member/oauth/service.go b/server/internal/member/oauth/service.go index 7f283154..2c7b5e2a 100644 --- a/server/internal/member/oauth/service.go +++ b/server/internal/member/oauth/service.go @@ -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 diff --git a/server/internal/member/oauth/utils.go b/server/internal/member/oauth/utils.go index c0a9e2e6..b60809cf 100644 --- a/server/internal/member/oauth/utils.go +++ b/server/internal/member/oauth/utils.go @@ -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 diff --git a/server/pkg/types/session.go b/server/pkg/types/session.go index 25aa399a..cb2b3ab3 100644 --- a/server/pkg/types/session.go +++ b/server/pkg/types/session.go @@ -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 { diff --git a/webpage/docs/configuration/authentication.md b/webpage/docs/configuration/authentication.md index f853f9f5..addc85c5 100644 --- a/webpage/docs/configuration/authentication.md +++ b/webpage/docs/configuration/authentication.md @@ -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_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`.