fix(auth): address OAuth review feedback

This commit is contained in:
evlic
2026-08-12 17:29:04 +08:00
parent 43c165ac34
commit 277abc8963
6 changed files with 142 additions and 26 deletions
+8 -2
View File
@@ -64,7 +64,13 @@ func TestOAuthLoginSynchronizesProfile(t *testing.T) {
AdminEmails: []string{"admin@example.test"}, AdminEmails: []string{"admin@example.test"},
SuccessRedirect: "/room", SuccessRedirect: "/room",
UserProfile: types.MemberProfile{ UserProfile: types.MemberProfile{
CanLogin: true, CanLogin: true,
CanAccessClipboard: true,
},
AdminProfile: types.MemberProfile{
IsAdmin: true,
CanLogin: true,
CanSeeInactiveCursors: true,
}, },
}, },
} }
@@ -113,7 +119,7 @@ func TestOAuthLoginSynchronizesProfile(t *testing.T) {
t.Fatal("OAuth session was not created") t.Fatal("OAuth session was not created")
} }
profile := userSession.Profile() profile := userSession.Profile()
if profile.Name != "Ada Lovelace" || profile.Avatar != "https://example.test/ada.png" || !profile.IsAdmin { if profile.Name != "Ada Lovelace" || profile.Avatar != "https://example.test/ada.png" || !profile.IsAdmin || profile.CanAccessClipboard {
t.Fatalf("profile = %#v", profile) t.Fatalf("profile = %#v", profile)
} }
if extraData := members.OAuthExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" { if extraData := members.OAuthExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" {
+14 -15
View File
@@ -1,8 +1,6 @@
package config package config
import ( import (
"strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -43,6 +41,7 @@ type OAuth struct {
UsernameField string UsernameField string
AvatarField string AvatarField string
SuccessRedirect string SuccessRedirect string
AdminProfile types.MemberProfile
UserProfile types.MemberProfile UserProfile types.MemberProfile
} }
@@ -106,8 +105,8 @@ func (Member) Init(cmd *cobra.Command) error {
return err return err
} }
cmd.PersistentFlags().String("member.oauth.admin_email", "", "comma-separated OAuth user-info email addresses granted administrator access") cmd.PersistentFlags().StringSlice("member.oauth.admin_emails", []string{}, "OAuth user-info email addresses granted administrator access")
if err := viper.BindPFlag("member.oauth.admin_email", cmd.PersistentFlags().Lookup("member.oauth.admin_email")); err != nil { if err := viper.BindPFlag("member.oauth.admin_emails", cmd.PersistentFlags().Lookup("member.oauth.admin_emails")); err != nil {
return err return err
} }
@@ -176,6 +175,11 @@ func (Member) Init(cmd *cobra.Command) error {
return err return err
} }
cmd.PersistentFlags().String("member.oauth.admin_profile", "{}", "OAuth administrator permission profile")
if err := viper.BindPFlag("member.oauth.admin_profile", cmd.PersistentFlags().Lookup("member.oauth.admin_profile")); err != nil {
return err
}
return nil return nil
} }
@@ -215,7 +219,7 @@ func (s *Member) Set() {
s.OAuth.Enabled = viper.GetBool("member.oauth.enabled") s.OAuth.Enabled = viper.GetBool("member.oauth.enabled")
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 = commaSeparatedValues(viper.GetString("member.oauth.admin_email")) s.OAuth.AdminEmails = viper.GetStringSlice("member.oauth.admin_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")
@@ -274,17 +278,12 @@ func (s *Member) Set() {
)); err != nil { )); err != nil {
log.Warn().Err(err).Msgf("unable to parse member multiuser admin profile") log.Warn().Err(err).Msgf("unable to parse member multiuser admin profile")
} }
} s.OAuth.AdminProfile = s.Multiuser.AdminProfile
if err := viper.UnmarshalKey("member.oauth.admin_profile", &s.OAuth.AdminProfile, viper.DecodeHook(
func commaSeparatedValues(value string) []string { utils.JsonStringAutoDecode(s.OAuth.AdminProfile),
values := strings.Split(value, ",") )); err != nil {
result := make([]string, 0, len(values)) log.Warn().Err(err).Msgf("unable to parse member OAuth admin profile")
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
result = append(result, value)
}
} }
return result
} }
func (s *Member) SetV2() { func (s *Member) SetV2() {
+1 -3
View File
@@ -203,10 +203,8 @@ func (s *session) create(username, password string) error {
s.setSessionData(data) s.setSessionData(data)
// Cookie-based authentication is supported when the browser presents an
// existing session above. Password login still requires an API token.
if s.token == "" { if s.token == "" {
return fmt.Errorf("token not found - make sure you are not using Cookie auth on the server") return fmt.Errorf("token not found")
} }
return nil return nil
+3 -4
View File
@@ -182,14 +182,13 @@ func (manager *MemberManagerCtx) LoginOAuth(subject, name, avatar, email string,
} }
profile := manager.config.OAuth.UserProfile profile := manager.config.OAuth.UserProfile
if isOAuthAdministrator(email, isAdmin, manager.config.OAuth.AdminEmails) {
profile = manager.config.OAuth.AdminProfile
}
if name != "" { if name != "" {
profile.Name = name profile.Name = name
} }
profile.Avatar = avatar profile.Avatar = avatar
if isOAuthAdministrator(email, isAdmin, manager.config.OAuth.AdminEmails) {
profile.IsAdmin = true
profile.CanSeeInactiveCursors = true
}
if !profile.IsAdmin && manager.sessions.Settings().LockedLogins { if !profile.IsAdmin && manager.sessions.Settings().LockedLogins {
return nil, "", types.ErrSessionLoginsLocked return nil, "", types.ErrSessionLoginsLocked
+104
View File
@@ -168,6 +168,94 @@ paths:
schema: schema:
$ref: '#/components/schemas/SessionLoginRequest' $ref: '#/components/schemas/SessionLoginRequest'
required: true required: true
/api/oauth/config:
get:
tags:
- current-session
summary: Get OAuth Login Configuration
description: Retrieve the OAuth login option shown by the client. This endpoint does not expose OAuth client credentials.
operationId: oauthConfig
security: []
responses:
'200':
description: OAuth login configuration retrieved successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthLoginConfig'
/api/oauth/login:
get:
tags:
- current-session
summary: Start OAuth Login
description: Start the OAuth authorization-code flow and redirect to the configured identity provider.
operationId: oauthLogin
security: []
responses:
'302':
description: Redirect to the identity provider authorization endpoint.
'404':
$ref: '#/components/responses/NotFound'
'503':
description: OAuth is not configured, issuer discovery failed, or session cookies are disabled.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/oauth/callback:
get:
tags:
- current-session
summary: Complete OAuth Login
description: Complete the OAuth authorization-code flow, create a session cookie, and redirect to the configured success URL.
operationId: oauthCallback
security: []
parameters:
- in: query
name: state
description: State value returned by the identity provider.
required: false
schema:
type: string
- in: query
name: code
description: Authorization code returned by the identity provider.
required: false
schema:
type: string
- in: query
name: error
description: Authorization error returned by the identity provider.
required: false
schema:
type: string
responses:
'303':
description: OAuth login succeeded and the response sets the session cookie before redirecting.
'400':
description: OAuth callback parameters or state are invalid.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'401':
description: OAuth authorization, token exchange, or user-info request failed.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'422':
description: The OAuth user already has a connected session.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'503':
description: OAuth is not fully configured or issuer discovery failed.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/logout: /api/logout:
post: post:
tags: tags:
@@ -1302,6 +1390,22 @@ components:
type: string type: string
description: The session token, only if cookie authentication is disabled. description: The session token, only if cookie authentication is disabled.
OAuthLoginConfig:
type: object
properties:
enabled:
type: boolean
description: Indicates whether OAuth login is enabled for the selected member provider.
name:
type: string
description: Display name for the OAuth login option.
login_url:
type: string
description: Relative URL that starts the OAuth login flow.
password_login_enabled:
type: boolean
description: Indicates whether password login remains available.
SessionData: SessionData:
type: object type: object
properties: properties:
+12 -2
View File
@@ -191,7 +191,7 @@ member:
# When true, visiting the Neko root page immediately starts OAuth login. # When true, visiting the Neko root page immediately starts OAuth login.
auto_redirect: true auto_redirect: true
name: "Example SSO" name: "Example SSO"
admin_email: "platform-admin@example.com,security@example.com" admin_emails: ["platform-admin@example.com", "security@example.com"]
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"
@@ -211,11 +211,21 @@ member:
can_access_clipboard: true can_access_clipboard: true
sends_inactive_cursor: true sends_inactive_cursor: true
can_see_inactive_cursors: false can_see_inactive_cursors: false
admin_profile:
is_admin: true
can_login: true
can_connect: true
can_watch: true
can_host: true
can_share_media: true
can_access_clipboard: true
sends_inactive_cursor: true
can_see_inactive_cursors: true
``` ```
The sign-in endpoint is `GET /api/oauth/login`; the callback endpoint is `GET /api/oauth/callback`. OAuth login requires `session.cookie.enabled: true`, which is the default. Set `member.provider: oauth` to make OAuth the only member provider. The sign-in endpoint is `GET /api/oauth/login`; the callback endpoint is `GET /api/oauth/callback`. OAuth login requires `session.cookie.enabled: true`, which is the default. Set `member.provider: oauth` to make OAuth the only member provider.
`admin_email` is a comma-separated 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`. 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.
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.