feat(auth): add OIDC-only member authentication (#686)

* feat(auth): add OIDC member authentication

* docs(auth): document OIDC member login

* fix(auth): address OAuth review feedback

* refactor(auth): move OAuth flow into member provider
This commit is contained in:
@刘羡鱼
2026-09-24 06:15:46 +08:00
committed by GitHub
parent ddf2787c5e
commit dae23a90b5
28 changed files with 1517 additions and 61 deletions
+31 -2
View File
@@ -12,7 +12,8 @@
backgroundColor: Background(seed), backgroundColor: Background(seed),
}" }"
> >
{{ seed.substring(0, 2).toUpperCase() }} <img v-if="imageURL" :src="imageURL" :alt="seed" @error="imageFailed = true" />
<template v-else>{{ seed.substring(0, 2).toUpperCase() }}</template>
</div> </div>
</template> </template>
@@ -25,19 +26,47 @@
display: inline-block; display: inline-block;
overflow: hidden; overflow: hidden;
border-radius: 50%; border-radius: 50%;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator' import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
@Component({ @Component({
name: 'neko-avatar', name: 'neko-avatar',
}) })
export default class extends Vue { export default class extends Vue {
@Prop(String) readonly seed: string | undefined @Prop(String) readonly seed: string | undefined
@Prop(String) readonly avatar: string | undefined
@Prop(Number) readonly size: number | undefined @Prop(Number) readonly size: number | undefined
private imageFailed: boolean = false
get imageURL(): string {
if (this.imageFailed || !this.avatar) {
return ''
}
try {
const avatar = new URL(this.avatar)
return avatar.protocol === 'http:' || avatar.protocol === 'https:' ? avatar.toString() : ''
} catch (_) {
return ''
}
}
@Watch('avatar')
onAvatarChanged() {
this.imageFailed = false
}
Background(seed: string) { Background(seed: string) {
let a = 0, let a = 0,
b = 0, b = 0,
+6 -1
View File
@@ -11,7 +11,12 @@
}" }"
> >
<div class="author" @contextmenu.stop.prevent="onContext($event, { member: member(message.id) })"> <div class="author" @contextmenu.stop.prevent="onContext($event, { member: member(message.id) })">
<neko-avatar class="avatar" :seed="member(message.id).displayname" :size="40" /> <neko-avatar
class="avatar"
:seed="member(message.id).displayname"
:avatar="member(message.id).avatar"
:size="40"
/>
</div> </div>
<div class="content"> <div class="content">
<div class="content-head"> <div class="content-head">
+66 -4
View File
@@ -8,11 +8,30 @@
<form class="message" v-if="!connecting" @submit.stop.prevent="connect"> <form class="message" v-if="!connecting" @submit.stop.prevent="connect">
<span v-if="!autoPassword">{{ $t('connect.login_title') }}</span> <span v-if="!autoPassword">{{ $t('connect.login_title') }}</span>
<span v-else>{{ $t('connect.invitation_title') }}</span> <span v-else>{{ $t('connect.invitation_title') }}</span>
<input type="text" :placeholder="$t('connect.displayname')" v-model="displayname" autofocus /> <input
<input type="password" :placeholder="$t('connect.password')" v-model="password" v-if="!autoPassword" /> v-if="passwordLoginEnabled"
<button type="submit" @click.stop.prevent="login"> type="text"
:placeholder="$t('connect.displayname')"
v-model="displayname"
autofocus
/>
<input
v-if="passwordLoginEnabled && !autoPassword"
type="password"
:placeholder="$t('connect.password')"
v-model="password"
/>
<button v-if="passwordLoginEnabled" type="submit" @click.stop.prevent="login">
{{ $t('connect.connect') }} {{ $t('connect.connect') }}
</button> </button>
<button
v-if="oauthEnabled && !autoPassword"
type="button"
class="oauth-login"
@click.stop.prevent="loginWithOAuth"
>
{{ oauthName }} Login
</button>
</form> </form>
<div class="loader" v-if="connecting"> <div class="loader" v-if="connecting">
<div class="bounce1"></div> <div class="bounce1"></div>
@@ -101,6 +120,11 @@
line-height: 30px; line-height: 30px;
margin: 5px 0; margin: 5px 0;
border: none; border: none;
&.oauth-login {
background: $background-tertiary;
border: 1px solid $style-primary;
}
} }
} }
@@ -155,8 +179,16 @@
private displayname: string = '' private displayname: string = ''
private password: string = '' private password: string = ''
private oauthEnabled: boolean = false
private oauthName: string = 'OAuth'
private oauthLoginURL: string = ''
private passwordLoginEnabled: boolean = true
async mounted() {
if (await this.loadOAuthConfig()) {
return
}
mounted() {
// auto-password fill // auto-password fill
let password = this.$accessor.password let password = this.$accessor.password
if (this.autoPassword !== null) { if (this.autoPassword !== null) {
@@ -182,6 +214,36 @@
return this.$accessor.connecting return this.$accessor.connecting
} }
async loadOAuthConfig(): Promise<boolean> {
try {
const response = await this.$http.get('api/oauth/config')
const config = response.data
this.oauthEnabled = config.enabled === true
this.oauthName = config.name || 'OAuth'
this.oauthLoginURL = config.login_url || ''
this.passwordLoginEnabled = config.password_login_enabled !== false
if (this.oauthEnabled && !this.passwordLoginEnabled) {
try {
await this.$http.get('api/whoami')
this.$accessor.login({ displayname: '', password: '' })
} catch (_) {
// No authenticated session yet; leave the OAuth button visible.
}
return true
}
} catch (_) {
// An unauthenticated OAuth user remains on the OAuth login screen.
}
return false
}
loginWithOAuth() {
if (this.oauthLoginURL !== '') {
window.location.assign(this.oauthLoginURL)
}
}
removeUrlParam(param: string) { removeUrlParam(param: string) {
let url = document.location.href let url = document.location.href
let urlparts = url.split('?') let urlparts = url.split('?')
+6 -1
View File
@@ -3,7 +3,12 @@
<template slot-scope="child" v-if="child.data"> <template slot-scope="child" v-if="child.data">
<li class="header"> <li class="header">
<div class="user"> <div class="user">
<neko-avatar class="avatar" :seed="child.data.member.displayname" :size="25" /> <neko-avatar
class="avatar"
:seed="child.data.member.displayname"
:avatar="child.data.member.avatar"
:size="25"
/>
<strong>{{ child.data.member.displayname }}</strong> <strong>{{ child.data.member.displayname }}</strong>
</div> </div>
</li> </li>
+2 -2
View File
@@ -4,7 +4,7 @@
<ul class="members-list"> <ul class="members-list">
<li v-if="member"> <li v-if="member">
<div :class="[{ host: member.id === host }, 'self', 'member']"> <div :class="[{ host: member.id === host }, 'self', 'member']">
<neko-avatar class="avatar" :seed="member.displayname" :size="50" /> <neko-avatar class="avatar" :seed="member.displayname" :avatar="member.avatar" :size="50" />
</div> </div>
</li> </li>
<template v-for="(member, index) in members"> <template v-for="(member, index) in members">
@@ -17,7 +17,7 @@
:class="[{ host: member.id === host, admin: member.admin }, 'member']" :class="[{ host: member.id === host, admin: member.admin }, 'member']"
@contextmenu.stop.prevent="onContext($event, { member })" @contextmenu.stop.prevent="onContext($event, { member })"
> >
<neko-avatar class="avatar" :seed="member.displayname" :size="50" /> <neko-avatar class="avatar" :seed="member.displayname" :avatar="member.avatar" :size="50" />
</div> </div>
</li> </li>
</template> </template>
+1
View File
@@ -1,6 +1,7 @@
export interface Member { export interface Member {
id: string id: string
displayname: string displayname: string
avatar?: string
admin: boolean admin: boolean
muted: boolean muted: boolean
connected?: boolean connected?: boolean
+3
View File
@@ -180,6 +180,8 @@ func (c *serve) Start(cmd *cobra.Command) {
c.managers.member, c.managers.member,
c.managers.desktop, c.managers.desktop,
c.managers.capture, c.managers.capture,
&c.configs.Member,
&c.configs.Server,
) )
c.managers.plugins = plugins.New( c.managers.plugins = plugins.New(
@@ -201,6 +203,7 @@ func (c *serve) Start(cmd *cobra.Command) {
c.managers.webSocket, c.managers.webSocket,
c.managers.api, c.managers.api,
&c.configs.Server, &c.configs.Server,
&c.configs.Member,
) )
c.managers.http.Start() c.managers.http.Start()
} }
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"errors"
"net/http"
"net/url"
"path"
"strings"
"github.com/m1k1o/neko/server/internal/member/oauth"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
)
type OAuthUIConfig struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
LoginURL string `json:"login_url"`
PasswordLoginEnabled bool `json:"password_login_enabled"`
}
// oauthHandler is deliberately limited to HTTP concerns. The OAuth protocol,
// profile mapping, and session creation are owned by member/oauth.Service.
type oauthHandler struct {
service *oauth.Service
pathPrefix string
trustProxy bool
providerOAuth bool
}
func newOAuthHandler(service *oauth.Service, pathPrefix string, trustProxy, providerOAuth bool) *oauthHandler {
if pathPrefix == "" {
pathPrefix = "/"
}
return &oauthHandler{service: service, pathPrefix: pathPrefix, trustProxy: trustProxy, providerOAuth: providerOAuth}
}
func (api *ApiManagerCtx) OAuthConfig(w http.ResponseWriter, r *http.Request) error {
return utils.HttpSuccess(w, OAuthUIConfig{
Enabled: api.oauth.providerOAuth && api.oauth.service.Enabled(),
Name: api.oauth.service.Name(),
LoginURL: path.Join(api.oauth.pathPrefix, "/api/oauth/login"),
PasswordLoginEnabled: !api.oauth.providerOAuth,
})
}
func (api *ApiManagerCtx) OAuthLogin(w http.ResponseWriter, r *http.Request) error {
if !api.oauth.providerOAuth || !api.oauth.service.Enabled() {
return utils.HttpNotFound()
}
if !api.sessions.CookieEnabled() {
return utils.HttpError(http.StatusServiceUnavailable, "OAuth requires session cookies to be enabled")
}
callbackURL, err := api.oauth.callbackURL(r)
if err != nil {
return utils.HttpInternalServerError("unable to determine OAuth callback URL").WithInternalErr(err)
}
location, err := api.oauth.service.Start(r.Context(), callbackURL)
if err != nil {
return oauthServiceError(err, true)
}
http.Redirect(w, r, location, http.StatusFound)
return nil
}
func (api *ApiManagerCtx) OAuthCallback(w http.ResponseWriter, r *http.Request) error {
if !api.oauth.providerOAuth || !api.oauth.service.Enabled() {
return utils.HttpNotFound()
}
if providerError := r.URL.Query().Get("error"); providerError != "" {
return utils.HttpUnauthorized("OAuth authorization was declined").WithInternalMsg(providerError)
}
_, token, err := api.oauth.service.Complete(r.Context(), r.URL.Query().Get("state"), r.URL.Query().Get("code"))
if err != nil {
return oauthServiceError(err, false)
}
api.sessions.CookieSetToken(w, token)
http.Redirect(w, r, api.oauth.successRedirect(), http.StatusSeeOther)
return nil
}
func oauthServiceError(err error, start bool) error {
if errors.Is(err, types.ErrSessionAlreadyConnected) {
return utils.HttpUnprocessableEntity("session already connected")
}
if errors.Is(err, types.ErrSessionLoginsLocked) {
return utils.HttpForbidden("logins are locked").WithInternalErr(err)
}
if start && strings.HasPrefix(err.Error(), "issuer discovery:") {
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
}
if start {
return utils.HttpError(http.StatusServiceUnavailable, err.Error()).WithInternalErr(err)
}
if strings.Contains(err.Error(), "missing state or code") || strings.Contains(err.Error(), "state is invalid") {
return utils.HttpBadRequest(err.Error()).WithInternalErr(err)
}
if strings.HasPrefix(err.Error(), "issuer discovery:") || strings.Contains(err.Error(), "not fully configured") {
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
}
return utils.HttpUnauthorized("OAuth authorization failed").WithInternalErr(err)
}
func (handler *oauthHandler) callbackURL(r *http.Request) (string, error) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
host := r.Host
if handler.trustProxy {
if value := forwardedHeaderValue(r.Header.Get("X-Forwarded-Proto")); value != "" {
scheme = value
}
if value := forwardedHeaderValue(r.Header.Get("X-Forwarded-Host")); value != "" {
host = value
}
}
if host == "" || (scheme != "http" && scheme != "https") {
return "", errors.New("request has no valid public URL")
}
callback := (&url.URL{Scheme: scheme, Host: host, Path: path.Join(handler.pathPrefix, "/api/oauth/callback")}).String()
if parsed, err := url.Parse(callback); err != nil || parsed.Host == "" {
return "", errors.New("request has no valid public URL")
}
return callback, nil
}
func (handler *oauthHandler) successRedirect() string {
redirect := handler.service.SuccessRedirect()
if strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") {
return path.Join(handler.pathPrefix, redirect)
}
return handler.pathPrefix
}
func forwardedHeaderValue(value string) string {
return strings.TrimSpace(strings.Split(value, ",")[0])
}
+248
View File
@@ -0,0 +1,248 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/member"
"github.com/m1k1o/neko/server/internal/member/oauth"
"github.com/m1k1o/neko/server/internal/session"
"github.com/m1k1o/neko/server/pkg/types"
)
func TestOAuthLoginSynchronizesProfile(t *testing.T) {
var tokenRequest url.Values
idToken := testIDToken(t, map[string]string{
"sub": "user-123",
"displayName": "Ada Lovelace",
"avatar": "https://example.test/ada.png",
"isAdmin": "true",
})
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tokenRequest = r.Form
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "provider-token", "id_token": idToken})
case "/userinfo":
if got := r.Header.Get("Authorization"); got != "Bearer provider-token" {
http.Error(w, "missing provider authorization", http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"sub": "user-123",
"email": "user@example.test",
})
default:
http.NotFound(w, r)
}
}))
defer provider.Close()
memberConfig := &config.Member{
Provider: "oauth",
OAuth: oauth.Config{
Enabled: true,
ClientID: "client-id",
ClientSecret: "client-secret",
AuthorizationURL: provider.URL + "/authorize",
TokenURL: provider.URL + "/token",
UserInfoURL: provider.URL + "/userinfo",
RedirectURL: "https://neko.example.test/api/oauth/callback",
Scopes: []string{"openid", "profile"},
SubjectField: "sub",
UsernameField: "displayName",
AvatarField: "avatar",
AdminEmails: []string{"admin@example.test"},
SuccessRedirect: "/room",
UserProfile: types.MemberProfile{
CanLogin: true,
CanAccessClipboard: true,
},
AdminProfile: types.MemberProfile{
IsAdmin: true,
CanLogin: true,
CanSeeInactiveCursors: true,
},
},
}
sessionManager := session.New(&config.Session{
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
})
members := member.New(sessionManager, memberConfig)
api := New(sessionManager, members, nil, nil, memberConfig, &config.Server{PathPrefix: "/"})
loginRecorder := httptest.NewRecorder()
api.OAuthLogin(loginRecorder, httptest.NewRequest(http.MethodGet, "/api/oauth/login", nil))
if loginRecorder.Code != http.StatusFound {
t.Fatalf("login status = %d", loginRecorder.Code)
}
authorizeURL, err := url.Parse(loginRecorder.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
state := authorizeURL.Query().Get("state")
if state == "" || authorizeURL.Query().Get("code_challenge") == "" {
t.Fatalf("OAuth redirect is missing state or PKCE challenge: %s", authorizeURL.String())
}
callbackRecorder := httptest.NewRecorder()
callbackURL := "/api/oauth/callback?state=" + url.QueryEscape(state) + "&code=authorization-code"
api.OAuthCallback(callbackRecorder, httptest.NewRequest(http.MethodGet, callbackURL, nil))
if callbackRecorder.Code != http.StatusSeeOther {
t.Fatalf("callback status = %d", callbackRecorder.Code)
}
if got := callbackRecorder.Header().Get("Location"); got != "/room" {
t.Fatalf("redirect = %q", got)
}
if tokenRequest.Get("code_verifier") == "" {
t.Fatal("token request is missing PKCE verifier")
}
cookies := callbackRecorder.Result().Cookies()
if len(cookies) != 1 {
t.Fatal("OAuth callback did not set a session cookie")
}
if cookies[0].Path != "/" {
t.Fatalf("OAuth session cookie path = %q, want root path", cookies[0].Path)
}
userSession, ok := sessionManager.Get("oauth:user-123")
if !ok {
t.Fatal("OAuth session was not created")
}
profile := userSession.Profile()
if profile.Name != "Ada Lovelace" || profile.Avatar != "https://example.test/ada.png" || !profile.IsAdmin || profile.CanAccessClipboard {
t.Fatalf("profile = %#v", profile)
}
if extraData := api.oauth.service.ExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" {
t.Fatalf("extra data = %#v", extraData)
}
}
func testIDToken(t *testing.T, claims map[string]string) string {
t.Helper()
payload, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
return "eyJhbGciOiJub25lIn0." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
}
func TestOAuthIssuerDiscoveryTakesPrecedence(t *testing.T) {
var tokenRequest url.Values
var issuerURL string
issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuerURL,
"authorization_endpoint": issuerURL + "/authorize",
"token_endpoint": issuerURL + "/token",
"userinfo_endpoint": issuerURL + "/userinfo",
})
case "/token":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tokenRequest = r.Form
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "provider-token"})
case "/userinfo":
_ = json.NewEncoder(w).Encode(map[string]string{"sub": "user-456", "name": "Grace Hopper"})
default:
http.NotFound(w, r)
}
}))
defer issuer.Close()
issuerURL = issuer.URL
memberConfig := &config.Member{
Provider: "oauth",
OAuth: oauth.Config{
Enabled: true,
ClientID: "client-id",
ClientSecret: "client-secret",
IssuerURL: issuerURL,
AuthorizationURL: "https://ignored.example.test/authorize",
TokenURL: "https://ignored.example.test/token",
UserInfoURL: "https://ignored.example.test/userinfo",
Scopes: []string{"openid", "profile"},
SubjectField: "sub",
UsernameField: "name",
AvatarField: "picture",
UserProfile: types.MemberProfile{CanLogin: true},
},
}
sessionManager := session.New(&config.Session{
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
})
members := member.New(sessionManager, memberConfig)
api := New(sessionManager, members, nil, nil, memberConfig, &config.Server{PathPrefix: "/"})
loginRequest := httptest.NewRequest(http.MethodGet, "https://neko.example.test/api/oauth/login", nil)
loginRecorder := httptest.NewRecorder()
api.OAuthLogin(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusFound {
t.Fatalf("login status = %d", loginRecorder.Code)
}
authorizationURL, err := url.Parse(loginRecorder.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
issuerEndpoint, err := url.Parse(issuerURL)
if err != nil {
t.Fatal(err)
}
if authorizationURL.Host != issuerEndpoint.Host {
t.Fatalf("authorization endpoint = %q", authorizationURL.String())
}
redirectURL := authorizationURL.Query().Get("redirect_uri")
if redirectURL != "https://neko.example.test/api/oauth/callback" {
t.Fatalf("derived redirect URL = %q", redirectURL)
}
callbackRecorder := httptest.NewRecorder()
callbackURL := "/api/oauth/callback?state=" + url.QueryEscape(authorizationURL.Query().Get("state")) + "&code=authorization-code"
api.OAuthCallback(callbackRecorder, httptest.NewRequest(http.MethodGet, callbackURL, nil))
if callbackRecorder.Code != http.StatusSeeOther {
t.Fatalf("callback status = %d", callbackRecorder.Code)
}
if tokenRequest.Get("redirect_uri") != redirectURL {
t.Fatalf("token redirect URL = %q, want %q", tokenRequest.Get("redirect_uri"), redirectURL)
}
}
func TestOAuthConfigUsesProviderAndName(t *testing.T) {
memberConfig := &config.Member{
Provider: "oauth",
OAuth: oauth.Config{
Enabled: true,
Name: "Team SSO",
},
}
sessionManager := session.New(&config.Session{})
api := New(sessionManager, member.New(sessionManager, memberConfig), nil, nil, memberConfig, &config.Server{})
recorder := httptest.NewRecorder()
if err := api.OAuthConfig(recorder, httptest.NewRequest(http.MethodGet, "/api/oauth/config", nil)); err != nil {
t.Fatal(err)
}
var payload OAuthUIConfig
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if !payload.Enabled || payload.PasswordLoginEnabled || payload.Name != "Team SSO" || payload.LoginURL != "/api/oauth/login" {
t.Fatalf("OAuth UI config = %#v", payload)
}
}
+18
View File
@@ -8,6 +8,8 @@ import (
"github.com/m1k1o/neko/server/internal/api/members" "github.com/m1k1o/neko/server/internal/api/members"
"github.com/m1k1o/neko/server/internal/api/room" "github.com/m1k1o/neko/server/internal/api/room"
"github.com/m1k1o/neko/server/internal/api/sessions" "github.com/m1k1o/neko/server/internal/api/sessions"
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/member/oauth"
"github.com/m1k1o/neko/server/pkg/auth" "github.com/m1k1o/neko/server/pkg/auth"
"github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils" "github.com/m1k1o/neko/server/pkg/utils"
@@ -19,6 +21,7 @@ type ApiManagerCtx struct {
desktop types.DesktopManager desktop types.DesktopManager
capture types.CaptureManager capture types.CaptureManager
routers map[string]func(types.Router) routers map[string]func(types.Router)
oauth *oauthHandler
} }
func New( func New(
@@ -26,7 +29,13 @@ func New(
members types.MemberManager, members types.MemberManager,
desktop types.DesktopManager, desktop types.DesktopManager,
capture types.CaptureManager, capture types.CaptureManager,
memberConfig *config.Member,
serverConfig *config.Server,
) *ApiManagerCtx { ) *ApiManagerCtx {
pathPrefix := "/"
if serverConfig != nil {
pathPrefix = serverConfig.PathPrefix
}
return &ApiManagerCtx{ return &ApiManagerCtx{
sessions: sessions, sessions: sessions,
@@ -34,11 +43,15 @@ func New(
desktop: desktop, desktop: desktop,
capture: capture, capture: capture,
routers: make(map[string]func(types.Router)), routers: make(map[string]func(types.Router)),
oauth: newOAuthHandler(oauth.NewService(memberConfig.OAuth, sessions), pathPrefix, serverConfig != nil && serverConfig.Proxy, memberConfig.Provider == "oauth"),
} }
} }
func (api *ApiManagerCtx) Route(r types.Router) { func (api *ApiManagerCtx) Route(r types.Router) {
r.Post("/login", api.Login) r.Post("/login", api.Login)
r.Get("/oauth/config", api.OAuthConfig)
r.Get("/oauth/login", api.OAuthLogin)
r.Get("/oauth/callback", api.OAuthCallback)
// Authenticated area // Authenticated area
r.Group(func(r types.Router) { r.Group(func(r types.Router) {
@@ -65,6 +78,11 @@ func (api *ApiManagerCtx) Route(r types.Router) {
}) })
} }
func (api *ApiManagerCtx) IsAuthenticated(r *http.Request) bool {
_, err := api.sessions.Authenticate(r)
return err == nil
}
func (api *ApiManagerCtx) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) { func (api *ApiManagerCtx) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) {
session, err := api.sessions.Authenticate(r) session, err := api.sessions.Authenticate(r)
if err != nil { if err != nil {
+9 -8
View File
@@ -15,10 +15,11 @@ type SessionLoginPayload struct {
} }
type SessionDataPayload struct { type SessionDataPayload struct {
ID string `json:"id"` ID string `json:"id"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`
Profile types.MemberProfile `json:"profile"` Profile types.MemberProfile `json:"profile"`
State types.SessionState `json:"state"` State types.SessionState `json:"state"`
ExtraData map[string]any `json:"extra_data,omitempty"`
} }
func (api *ApiManagerCtx) Login(w http.ResponseWriter, r *http.Request) error { func (api *ApiManagerCtx) Login(w http.ResponseWriter, r *http.Request) error {
@@ -66,7 +67,6 @@ func (api *ApiManagerCtx) Logout(w http.ResponseWriter, r *http.Request) error {
return utils.HttpInternalServerError().WithInternalErr(err) return utils.HttpInternalServerError().WithInternalErr(err)
} }
} }
if api.sessions.CookieEnabled() { if api.sessions.CookieEnabled() {
api.sessions.CookieClearToken(w, r) api.sessions.CookieClearToken(w, r)
} }
@@ -78,9 +78,10 @@ func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r) session, _ := auth.GetSession(r)
return utils.HttpSuccess(w, SessionDataPayload{ return utils.HttpSuccess(w, SessionDataPayload{
ID: session.ID(), ID: session.ID(),
Profile: session.Profile(), Profile: session.Profile(),
State: session.State(), State: session.State(),
ExtraData: api.oauth.service.ExtraData(session.ID()),
}) })
} }
+9
View File
@@ -7,6 +7,7 @@ import (
"github.com/m1k1o/neko/server/internal/member/file" "github.com/m1k1o/neko/server/internal/member/file"
"github.com/m1k1o/neko/server/internal/member/multiuser" "github.com/m1k1o/neko/server/internal/member/multiuser"
"github.com/m1k1o/neko/server/internal/member/oauth"
"github.com/m1k1o/neko/server/internal/member/object" "github.com/m1k1o/neko/server/internal/member/object"
"github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils" "github.com/m1k1o/neko/server/pkg/utils"
@@ -19,6 +20,7 @@ type Member struct {
File file.Config File file.Config
Object object.Config Object object.Config
Multiuser multiuser.Config Multiuser multiuser.Config
OAuth oauth.Config
} }
func (Member) Init(cmd *cobra.Command) error { func (Member) Init(cmd *cobra.Command) error {
@@ -65,6 +67,10 @@ func (Member) Init(cmd *cobra.Command) error {
return err return err
} }
if err := oauth.Init(cmd); err != nil {
return err
}
return nil return nil
} }
@@ -139,6 +145,9 @@ 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")
} }
if err := s.OAuth.Set(s.Multiuser.UserProfile, s.Multiuser.AdminProfile); err != nil {
log.Warn().Err(err).Msg("unable to configure member OAuth provider")
}
} }
func (s *Member) SetV2() { func (s *Member) SetV2() {
+8 -1
View File
@@ -105,7 +105,14 @@ func (h *LegacyHandler) Route(r types.Router) {
defer s.destroy() defer s.destroy()
// dial to the remote backend // dial to the remote backend
connBackend, _, err := h.wsDialer.Dial("ws://"+h.serverAddr+path.Join(s.pathPrefix, "/api/ws")+"?token="+url.QueryEscape(s.token), nil) backendURL := "ws://" + h.serverAddr + path.Join(s.pathPrefix, "/api/ws")
backendHeaders := make(http.Header)
if s.token != "" {
backendURL += "?token=" + url.QueryEscape(s.token)
} else {
backendHeaders.Set("Cookie", s.cookie)
}
connBackend, _, err := h.wsDialer.Dial(backendURL, backendHeaders)
if err != nil { if err != nil {
h.logger.Error().Err(err).Msg("couldn't dial to the remote backend") h.logger.Error().Err(err).Msg("couldn't dial to the remote backend")
+30 -9
View File
@@ -40,6 +40,7 @@ type session struct {
id, ip string id, ip string
token string token string
cookie string
name string name string
isAdmin bool isAdmin bool
client *http.Client client *http.Client
@@ -85,6 +86,8 @@ func (s *session) req(method, reqPath string, headers http.Header, request io.Re
if s.token != "" { if s.token != "" {
req.Header.Set("Authorization", "Bearer "+s.token) req.Header.Set("Authorization", "Bearer "+s.token)
} else if s.cookie != "" {
req.Header.Set("Cookie", s.cookie)
} }
res, err := s.client.Do(req) res, err := s.client.Do(req)
@@ -111,14 +114,19 @@ func (s *session) req(method, reqPath string, headers http.Header, request io.Re
} }
func (s *session) apiReq(method, path string, request, response any) error { func (s *session) apiReq(method, path string, request, response any) error {
return s.apiReqWithHeaders(method, path, request, response, nil)
}
func (s *session) apiReqWithHeaders(method, path string, request, response any, headers http.Header) error {
reqBody, err := json.Marshal(request) reqBody, err := json.Marshal(request)
if err != nil { if err != nil {
return err return err
} }
headers := http.Header{ if headers == nil {
"Content-Type": []string{"application/json"}, headers = make(http.Header)
} }
headers.Set("Content-Type", "application/json")
resBody, _, err := s.req(method, path, headers, bytes.NewReader(reqBody)) resBody, _, err := s.req(method, path, headers, bytes.NewReader(reqBody))
if err != nil { if err != nil {
@@ -174,6 +182,16 @@ func (s *session) toBackend(event string, payload any) error {
func (s *session) create(username, password string) error { func (s *session) create(username, password string) error {
data := api.SessionDataPayload{} data := api.SessionDataPayload{}
if cookie := s.r.Header.Get("Cookie"); cookie != "" {
err := s.apiReqWithHeaders(http.MethodGet, "/api/whoami", nil, &data, http.Header{
"Cookie": []string{cookie},
})
if err == nil {
s.cookie = cookie
s.setSessionData(data)
return nil
}
}
err := s.apiReq(http.MethodPost, "/api/login", api.SessionLoginPayload{ err := s.apiReq(http.MethodPost, "/api/login", api.SessionLoginPayload{
Username: username, Username: username,
@@ -183,18 +201,21 @@ func (s *session) create(username, password string) error {
return err return err
} }
s.setSessionData(data)
if s.token == "" {
return fmt.Errorf("token not found")
}
return nil
}
func (s *session) setSessionData(data api.SessionDataPayload) {
s.id, s.ip = data.ID, getIp(s.r) s.id, s.ip = data.ID, getIp(s.r)
s.h.sessionIPs[s.id] = s.ip // save session ip by id s.h.sessionIPs[s.id] = s.ip // save session ip by id
s.token = data.Token s.token = data.Token
s.name = data.Profile.Name s.name = data.Profile.Name
s.isAdmin = data.Profile.IsAdmin s.isAdmin = data.Profile.IsAdmin
// if Cookie auth, the token will be empty
if s.token == "" {
return fmt.Errorf("token not found - make sure you are not using Cookie auth on the server")
}
return nil
} }
func (s *session) destroy() { func (s *session) destroy() {
@@ -0,0 +1,63 @@
package legacy
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/m1k1o/neko/server/pkg/types"
)
func TestCreateUsesExistingSessionCookie(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/whoami":
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if got := r.Header.Get("Cookie"); got != "NEKO_SESSION=session-token" {
t.Fatalf("cookie = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "oauth:user-123",
"profile": types.MemberProfile{
Name: "Ada Lovelace",
IsAdmin: true,
},
})
case "/api/protected":
if got := r.Header.Get("Cookie"); got != "NEKO_SESSION=session-token" {
t.Fatalf("authenticated request cookie = %q", got)
}
_ = json.NewEncoder(w).Encode(true)
default:
http.NotFound(w, r)
}
}))
defer backend.Close()
request := httptest.NewRequest(http.MethodGet, "http://neko.test/ws", nil)
request.Header.Set("Cookie", "NEKO_SESSION=session-token")
handler := &LegacyHandler{sessionIPs: make(map[string]string)}
session := &session{
r: request,
h: handler,
serverAddr: strings.TrimPrefix(backend.URL, "http://"),
client: backend.Client(),
}
if err := session.create("", ""); err != nil {
t.Fatal(err)
}
if session.id != "oauth:user-123" || session.name != "Ada Lovelace" || !session.isAdmin {
t.Fatalf("session = %#v", session)
}
if session.cookie != "NEKO_SESSION=session-token" || session.token != "" {
t.Fatalf("authentication state = cookie %q token %q", session.cookie, session.token)
}
if err := session.apiReq(http.MethodGet, "/api/protected", nil, nil); err != nil {
t.Fatal(err)
}
}
+5 -4
View File
@@ -19,10 +19,11 @@ type Stats struct {
} }
type Member struct { type Member struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"displayname"` Name string `json:"displayname"`
Admin bool `json:"admin"` Avatar string `json:"avatar,omitempty"`
Muted bool `json:"muted"` Admin bool `json:"admin"`
Muted bool `json:"muted"`
} }
type FileListItem struct { type FileListItem struct {
+11 -11
View File
@@ -32,10 +32,11 @@ func profileToMember(id string, profile types.MemberProfile) (*oldTypes.Member,
} }
return &oldTypes.Member{ return &oldTypes.Member{
ID: id, ID: id,
Name: profile.Name, Name: profile.Name,
Admin: profile.IsAdmin, Avatar: profile.Avatar,
Muted: !settings.CanSend, Admin: profile.IsAdmin,
Muted: !settings.CanSend,
}, nil }, nil
} }
@@ -618,18 +619,17 @@ func (s *session) wsToClient(msg []byte) error {
// Open In App Events // Open In App Events
case openinapp.OPENINAPP_INIT: case openinapp.OPENINAPP_INIT:
request := &openinapp.Init{} request := &openinapp.Init{}
if err := json.Unmarshal(data.Payload, request); err != nil { if err := json.Unmarshal(data.Payload, request); err != nil {
return err return err
} }
return s.toClient(&struct { return s.toClient(&struct {
Event string `json:"event"` Event string `json:"event"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
}{ }{
Event: openinapp.OPENINAPP_INIT, Event: openinapp.OPENINAPP_INIT,
Enabled: request.Enabled, Enabled: request.Enabled,
}) })
// Screen Events // Screen Events
case event.SCREEN_UPDATED: case event.SCREEN_UPDATED:
@@ -0,0 +1,20 @@
package legacy
import (
"testing"
"github.com/m1k1o/neko/server/pkg/types"
)
func TestProfileToMemberPreservesAvatar(t *testing.T) {
member, err := profileToMember("oauth:user-123", types.MemberProfile{
Name: "Ada Lovelace",
Avatar: "https://example.test/ada.png",
})
if err != nil {
t.Fatal(err)
}
if member.Name != "Ada Lovelace" || member.Avatar != "https://example.test/ada.png" {
t.Fatalf("member = %#v", member)
}
}
+35 -15
View File
@@ -5,6 +5,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"path"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -23,7 +24,7 @@ type HttpManagerCtx struct {
http *http.Server http *http.Server
} }
func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, config *config.Server) *HttpManagerCtx { func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, config *config.Server, memberConfig *config.Member) *HttpManagerCtx {
logger := log.With().Str("module", "http").Logger() logger := log.With().Str("module", "http").Logger()
opts := []RouterOption{ opts := []RouterOption{
@@ -67,6 +68,37 @@ func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, c
} }
router.Post("/api/batch", batch.Handle) router.Post("/api/batch", batch.Handle)
var staticHandler types.RouterHandler
if config.Static != "" {
fs := http.FileServer(http.Dir(config.Static))
staticHandler = func(w http.ResponseWriter, r *http.Request) error {
_, err := os.Stat(config.Static + r.URL.Path)
if err == nil {
fs.ServeHTTP(w, r)
return nil
}
if os.IsNotExist(err) {
http.NotFound(w, r)
return nil
}
return err
}
}
if memberConfig.Provider == "oauth" && memberConfig.OAuth.Enabled && memberConfig.OAuth.AutoRedirect {
router.Get("/", func(w http.ResponseWriter, r *http.Request) error {
if ApiManager.IsAuthenticated(r) {
if staticHandler != nil {
return staticHandler(w, r)
}
http.NotFound(w, r)
return nil
}
http.Redirect(w, r, path.Join(config.PathPrefix, "/api/oauth/login"), http.StatusFound)
return nil
})
}
router.Get("/health", func(w http.ResponseWriter, r *http.Request) error { router.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
_, err := w.Write([]byte("true")) _, err := w.Write([]byte("true"))
return err return err
@@ -79,20 +111,8 @@ func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, c
}) })
} }
if config.Static != "" { if staticHandler != nil {
fs := http.FileServer(http.Dir(config.Static)) router.Get("/*", staticHandler)
router.Get("/*", func(w http.ResponseWriter, r *http.Request) error {
_, err := os.Stat(config.Static + r.URL.Path)
if err == nil {
fs.ServeHTTP(w, r)
return nil
}
if os.IsNotExist(err) {
http.NotFound(w, r)
return nil
}
return err
})
} }
if config.PProf { if config.PProf {
+69
View File
@@ -0,0 +1,69 @@
package http
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/m1k1o/neko/server/internal/api"
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/member"
"github.com/m1k1o/neko/server/internal/member/oauth"
"github.com/m1k1o/neko/server/internal/session"
"github.com/m1k1o/neko/server/pkg/types"
)
type testWebSocketManager struct{}
func (testWebSocketManager) Start() {}
func (testWebSocketManager) Shutdown() error { return nil }
func (testWebSocketManager) AddHandler(types.WebSocketHandler) {}
func (testWebSocketManager) Upgrade(types.CheckOrigin) types.RouterHandler {
return func(w http.ResponseWriter, r *http.Request) error {
http.NotFound(w, r)
return nil
}
}
func TestOAuthAutoRedirectSkipsAuthenticatedSessions(t *testing.T) {
staticDir := t.TempDir()
if err := os.WriteFile(staticDir+"/index.html", []byte("neko client"), 0o644); err != nil {
t.Fatal(err)
}
memberConfig := &config.Member{Provider: "oauth", OAuth: oauth.Config{Enabled: true, AutoRedirect: true}}
sessionManager := session.New(&config.Session{
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
})
apiManager := api.New(sessionManager, member.New(sessionManager, memberConfig), nil, nil, memberConfig, &config.Server{})
manager := New(testWebSocketManager{}, apiManager, &config.Server{Static: staticDir}, memberConfig)
unauthenticated := httptest.NewRecorder()
manager.router.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/", nil))
if unauthenticated.Code != http.StatusFound {
t.Fatalf("unauthenticated status = %d", unauthenticated.Code)
}
if location := unauthenticated.Header().Get("Location"); location != "/api/oauth/login" {
t.Fatalf("unauthenticated location = %q", location)
}
_, token, err := sessionManager.Create("oauth:user-123", types.MemberProfile{CanLogin: true})
if err != nil {
t.Fatal(err)
}
authenticatedRequest := httptest.NewRequest(http.MethodGet, "/", nil)
authenticatedRequest.AddCookie(&http.Cookie{Name: "NEKO_SESSION", Value: token})
authenticated := httptest.NewRecorder()
manager.router.ServeHTTP(authenticated, authenticatedRequest)
if authenticated.Code != http.StatusOK {
t.Fatalf("authenticated status = %d", authenticated.Code)
}
if body := authenticated.Body.String(); body != "neko client" {
t.Fatalf("authenticated body = %q", body)
}
}
+5 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/m1k1o/neko/server/internal/member/file" "github.com/m1k1o/neko/server/internal/member/file"
"github.com/m1k1o/neko/server/internal/member/multiuser" "github.com/m1k1o/neko/server/internal/member/multiuser"
"github.com/m1k1o/neko/server/internal/member/noauth" "github.com/m1k1o/neko/server/internal/member/noauth"
"github.com/m1k1o/neko/server/internal/member/oauth"
"github.com/m1k1o/neko/server/internal/member/object" "github.com/m1k1o/neko/server/internal/member/object"
"github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/types"
) )
@@ -29,6 +30,8 @@ func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx
manager.provider = object.New(config.Object) manager.provider = object.New(config.Object)
case "multiuser": case "multiuser":
manager.provider = multiuser.New(config.Multiuser) manager.provider = multiuser.New(config.Multiuser)
case "oauth":
manager.provider = oauth.New()
case "noauth": case "noauth":
fallthrough fallthrough
default: default:
@@ -164,5 +167,6 @@ func (manager *MemberManagerCtx) Logout(id string) error {
manager.loginMu.Lock() manager.loginMu.Lock()
defer manager.loginMu.Unlock() defer manager.loginMu.Unlock()
return manager.sessions.Delete(id) err := manager.sessions.Delete(id)
return err
} }
+130
View File
@@ -0,0 +1,130 @@
package oauth
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
)
// Config contains the OAuth member provider settings.
type Config struct {
Enabled bool
AutoRedirect bool
Name string
AdminEmails []string
ClientID string
ClientSecret string
IssuerURL string
AuthorizationURL string
TokenURL string
UserInfoURL string
RedirectURL string
Scopes []string
SubjectField string
UsernameField string
AvatarField string
SuccessRedirect string
AdminProfile types.MemberProfile
UserProfile types.MemberProfile
}
func Init(cmd *cobra.Command) error {
flags := cmd.PersistentFlags()
flags.Bool("member.oauth.enabled", false, "enable OAuth 2.0 login")
if err := viper.BindPFlag("member.oauth.enabled", flags.Lookup("member.oauth.enabled")); err != nil {
return err
}
flags.Bool("member.oauth.auto_redirect", false, "redirect the Neko root page to OAuth login automatically")
if err := viper.BindPFlag("member.oauth.auto_redirect", flags.Lookup("member.oauth.auto_redirect")); err != nil {
return err
}
flags.String("member.oauth.name", "OAuth", "display name for the OAuth login option")
if err := viper.BindPFlag("member.oauth.name", flags.Lookup("member.oauth.name")); err != nil {
return err
}
flags.StringSlice("member.oauth.admin_emails", []string{}, "OAuth user-info email addresses granted administrator access")
if err := viper.BindPFlag("member.oauth.admin_emails", flags.Lookup("member.oauth.admin_emails")); err != nil {
return err
}
flags.String("member.oauth.client_id", "", "OAuth 2.0 client ID")
if err := viper.BindPFlag("member.oauth.client_id", flags.Lookup("member.oauth.client_id")); err != nil {
return err
}
flags.String("member.oauth.client_secret", "", "OAuth 2.0 client secret")
if err := viper.BindPFlag("member.oauth.client_secret", flags.Lookup("member.oauth.client_secret")); err != nil {
return err
}
flags.String("member.oauth.issuer_url", "", "OpenID Connect issuer URL used to discover OAuth endpoints")
if err := viper.BindPFlag("member.oauth.issuer_url", flags.Lookup("member.oauth.issuer_url")); err != nil {
return err
}
flags.String("member.oauth.authorization_url", "", "OAuth 2.0 authorization endpoint")
if err := viper.BindPFlag("member.oauth.authorization_url", flags.Lookup("member.oauth.authorization_url")); err != nil {
return err
}
flags.String("member.oauth.token_url", "", "OAuth 2.0 token endpoint")
if err := viper.BindPFlag("member.oauth.token_url", flags.Lookup("member.oauth.token_url")); err != nil {
return err
}
flags.String("member.oauth.userinfo_url", "", "OAuth 2.0 user-info endpoint")
if err := viper.BindPFlag("member.oauth.userinfo_url", flags.Lookup("member.oauth.userinfo_url")); err != nil {
return err
}
flags.String("member.oauth.redirect_url", "", "OAuth 2.0 callback URL registered with the provider")
if err := viper.BindPFlag("member.oauth.redirect_url", flags.Lookup("member.oauth.redirect_url")); err != nil {
return err
}
flags.StringSlice("member.oauth.scopes", []string{"openid", "profile"}, "OAuth 2.0 scopes")
if err := viper.BindPFlag("member.oauth.scopes", flags.Lookup("member.oauth.scopes")); err != nil {
return err
}
flags.String("member.oauth.subject_field", "sub", "field in user-info response used as the stable user identifier")
if err := viper.BindPFlag("member.oauth.subject_field", flags.Lookup("member.oauth.subject_field")); err != nil {
return err
}
flags.String("member.oauth.username_field", "name", "field in user-info response used as the display name")
if err := viper.BindPFlag("member.oauth.username_field", flags.Lookup("member.oauth.username_field")); err != nil {
return err
}
flags.String("member.oauth.avatar_field", "picture", "field in user-info response used as the avatar URL")
if err := viper.BindPFlag("member.oauth.avatar_field", flags.Lookup("member.oauth.avatar_field")); err != nil {
return err
}
flags.String("member.oauth.success_redirect", "/", "relative URL to redirect to after OAuth login")
if err := viper.BindPFlag("member.oauth.success_redirect", flags.Lookup("member.oauth.success_redirect")); err != nil {
return err
}
flags.String("member.oauth.user_profile", "{}", "OAuth user permission profile")
if err := viper.BindPFlag("member.oauth.user_profile", flags.Lookup("member.oauth.user_profile")); err != nil {
return err
}
flags.String("member.oauth.admin_profile", "{}", "OAuth administrator permission profile")
return viper.BindPFlag("member.oauth.admin_profile", flags.Lookup("member.oauth.admin_profile"))
}
func (c *Config) Set(userProfile, adminProfile types.MemberProfile) error {
c.Enabled = viper.GetBool("member.oauth.enabled")
c.AutoRedirect = viper.GetBool("member.oauth.auto_redirect")
c.Name = viper.GetString("member.oauth.name")
c.AdminEmails = viper.GetStringSlice("member.oauth.admin_emails")
c.ClientID = viper.GetString("member.oauth.client_id")
c.ClientSecret = viper.GetString("member.oauth.client_secret")
c.IssuerURL = viper.GetString("member.oauth.issuer_url")
c.AuthorizationURL = viper.GetString("member.oauth.authorization_url")
c.TokenURL = viper.GetString("member.oauth.token_url")
c.UserInfoURL = viper.GetString("member.oauth.userinfo_url")
c.RedirectURL = viper.GetString("member.oauth.redirect_url")
c.Scopes = viper.GetStringSlice("member.oauth.scopes")
c.SubjectField = viper.GetString("member.oauth.subject_field")
c.UsernameField = viper.GetString("member.oauth.username_field")
c.AvatarField = viper.GetString("member.oauth.avatar_field")
c.SuccessRedirect = viper.GetString("member.oauth.success_redirect")
c.UserProfile = userProfile
c.AdminProfile = adminProfile
if err := viper.UnmarshalKey("member.oauth.user_profile", &c.UserProfile, viper.DecodeHook(utils.JsonStringAutoDecode(c.UserProfile))); err != nil {
return err
}
return viper.UnmarshalKey("member.oauth.admin_profile", &c.AdminProfile, viper.DecodeHook(utils.JsonStringAutoDecode(c.AdminProfile)))
}
+428
View File
@@ -0,0 +1,428 @@
package oauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/m1k1o/neko/server/pkg/types"
)
// MemberProviderCtx disables password authentication. OAuth sessions are
// created by Service after the OAuth callback succeeds.
type MemberProviderCtx struct{}
func New() types.MemberProvider { return &MemberProviderCtx{} }
func (provider *MemberProviderCtx) Connect() error { return nil }
func (provider *MemberProviderCtx) Disconnect() error { return nil }
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
}
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
return "", errors.New("OAuth members are created by OAuth login")
}
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
return errors.New("OAuth profile is managed by the identity provider")
}
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
return errors.New("OAuth provider does not have passwords")
}
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
return types.MemberProfile{}, errors.New("OAuth members are stored in active sessions")
}
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
return map[string]types.MemberProfile{}, nil
}
func (provider *MemberProviderCtx) Delete(id string) error {
return errors.New("OAuth members are managed by the identity provider")
}
const stateLifetime = 10 * time.Minute
type state struct {
verifier, redirectURL string
expires time.Time
}
// Service owns the OAuth authorization-code flow and creates sessions for the
// OAuth member provider. HTTP packages only translate requests and responses.
type Service struct {
config Config
sessions types.SessionManager
client *http.Client
mu sync.Mutex
issuerResolved bool
states map[string]state
extra map[string]map[string]any
}
func NewService(config Config, sessions types.SessionManager) *Service {
service := &Service{
config: config,
sessions: sessions,
client: &http.Client{Timeout: 15 * time.Second},
states: make(map[string]state),
extra: make(map[string]map[string]any),
}
sessions.OnDeleted(func(session types.Session) {
service.DeleteExtraData(session.ID())
})
return service
}
func (service *Service) Enabled() bool { return service.config.Enabled }
func (service *Service) Name() string {
if name := strings.TrimSpace(service.config.Name); name != "" {
return name
}
return "OAuth"
}
func (service *Service) SuccessRedirect() string { return service.config.SuccessRedirect }
func (service *Service) Start(ctx context.Context, callbackURL string) (string, error) {
config, err := service.resolveConfig(ctx)
if err != nil {
return "", fmt.Errorf("issuer discovery: %w", err)
}
if !configured(config) {
return "", errors.New("OAuth is not fully configured")
}
if config.RedirectURL != "" {
callbackURL = config.RedirectURL
}
if callbackURL == "" {
return "", errors.New("OAuth callback URL is not configured")
}
stateToken, err := token(32)
if err != nil {
return "", err
}
verifier, err := token(64)
if err != nil {
return "", err
}
service.storeState(stateToken, verifier, callbackURL)
authorizationURL, err := url.Parse(config.AuthorizationURL)
if err != nil {
return "", err
}
query := authorizationURL.Query()
query.Set("response_type", "code")
query.Set("client_id", config.ClientID)
query.Set("redirect_uri", callbackURL)
query.Set("scope", strings.Join(config.Scopes, " "))
query.Set("state", stateToken)
query.Set("code_challenge", challenge(verifier))
query.Set("code_challenge_method", "S256")
authorizationURL.RawQuery = query.Encode()
return authorizationURL.String(), nil
}
func (service *Service) Complete(ctx context.Context, stateToken, code string) (types.Session, string, error) {
if !service.config.Enabled {
return nil, "", types.ErrMemberInvalidPassword
}
if stateToken == "" || code == "" {
return nil, "", errors.New("OAuth callback is missing state or code")
}
stored, ok := service.popState(stateToken)
if !ok {
return nil, "", errors.New("OAuth state is invalid or expired")
}
config, err := service.resolveConfig(ctx)
if err != nil {
return nil, "", fmt.Errorf("issuer discovery: %w", err)
}
if !configured(config) {
return nil, "", errors.New("OAuth is not fully configured")
}
accessToken, idClaims, err := service.exchangeCode(ctx, config, code, stored.verifier, stored.redirectURL)
if err != nil {
return nil, "", fmt.Errorf("token exchange: %w", err)
}
claims, err := service.userInfo(ctx, config, accessToken)
if err != nil {
return nil, "", fmt.Errorf("user-info: %w", err)
}
subject := claim(claims, config.SubjectField)
if subject == "" {
return nil, "", errors.New("OAuth user-info response is missing the subject field")
}
if idSubject := claim(idClaims, config.SubjectField); idSubject != "" && idSubject != subject {
return nil, "", errors.New("OAuth ID token subject does not match user-info response")
}
name := claim(claims, config.UsernameField)
if name == "" {
name = claim(idClaims, config.UsernameField)
}
if name == "" {
name = subject
}
avatar := claim(claims, config.AvatarField)
if avatar == "" {
avatar = claim(idClaims, config.AvatarField)
}
email := claim(claims, "email")
if email == "" {
email = claim(idClaims, "email")
}
profile := config.UserProfile
if administrator(email, boolClaim(claims, "isAdmin") || boolClaim(idClaims, "isAdmin"), config.AdminEmails) {
profile = config.AdminProfile
}
profile.Name = name
profile.Avatar = avatar
if !profile.IsAdmin && service.sessions.Settings().LockedLogins {
return nil, "", types.ErrSessionLoginsLocked
}
id := "oauth:" + subject
if session, ok := service.sessions.Get(id); ok {
if session.State().IsConnected {
return nil, "", types.ErrSessionAlreadyConnected
}
if err := service.sessions.Delete(id); err != nil {
return nil, "", err
}
}
session, sessionToken, err := service.sessions.Create(id, profile)
if err == nil {
service.mu.Lock()
service.extra[id] = mergeClaims(claims, idClaims)
service.mu.Unlock()
}
return session, sessionToken, err
}
func (service *Service) ExtraData(id string) map[string]any {
service.mu.Lock()
defer service.mu.Unlock()
return cloneClaims(service.extra[id])
}
func (service *Service) DeleteExtraData(id string) {
service.mu.Lock()
defer service.mu.Unlock()
delete(service.extra, id)
}
type discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
}
func (service *Service) resolveConfig(ctx context.Context) (Config, error) {
service.mu.Lock()
defer service.mu.Unlock()
if service.config.IssuerURL == "" || service.issuerResolved {
return service.config, nil
}
issuer, err := url.Parse(service.config.IssuerURL)
if err != nil || !absoluteURL(service.config.IssuerURL) {
return Config{}, errors.New("OAuth issuer URL must be an absolute HTTP(S) URL")
}
issuer.Path = strings.TrimSuffix(issuer.Path, "/") + "/.well-known/openid-configuration"
issuer.RawQuery = ""
issuer.Fragment = ""
req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer.String(), nil)
if err != nil {
return Config{}, err
}
req.Header.Set("Accept", "application/json")
response, err := service.client.Do(req)
if err != nil {
return Config{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return Config{}, fmt.Errorf("issuer discovery endpoint returned %s", response.Status)
}
var metadata discovery
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
return Config{}, err
}
if metadata.Issuer != service.config.IssuerURL {
return Config{}, errors.New("issuer discovery response issuer does not match configured issuer")
}
for field, value := range map[string]string{"authorization_endpoint": metadata.AuthorizationEndpoint, "token_endpoint": metadata.TokenEndpoint, "userinfo_endpoint": metadata.UserInfoEndpoint} {
if !absoluteURL(value) {
return Config{}, fmt.Errorf("issuer discovery response has invalid %s", field)
}
}
service.config.AuthorizationURL = metadata.AuthorizationEndpoint
service.config.TokenURL = metadata.TokenEndpoint
service.config.UserInfoURL = metadata.UserInfoEndpoint
service.issuerResolved = true
return service.config, nil
}
func configured(config Config) bool {
return config.Enabled && config.ClientID != "" && config.ClientSecret != "" && config.AuthorizationURL != "" && config.TokenURL != "" && config.UserInfoURL != ""
}
func absoluteURL(value string) bool {
parsed, err := url.Parse(value)
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}
func (service *Service) storeState(value, verifier, redirectURL string) {
service.mu.Lock()
defer service.mu.Unlock()
now := time.Now()
for key, value := range service.states {
if now.After(value.expires) {
delete(service.states, key)
}
}
service.states[value] = state{verifier, redirectURL, now.Add(stateLifetime)}
}
func (service *Service) popState(value string) (state, bool) {
service.mu.Lock()
defer service.mu.Unlock()
result, ok := service.states[value]
delete(service.states, value)
return result, ok && time.Now().Before(result.expires)
}
func (service *Service) exchangeCode(ctx context.Context, config Config, code, verifier, redirectURL string) (string, map[string]any, error) {
values := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURL}, "client_id": {config.ClientID}, "client_secret": {config.ClientSecret}, "code_verifier": {verifier}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(values.Encode()))
if err != nil {
return "", nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
response, err := service.client.Do(req)
if err != nil {
return "", nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", nil, fmt.Errorf("token endpoint returned %s", response.Status)
}
var payload struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&payload); err != nil {
return "", nil, err
}
if payload.AccessToken == "" {
return "", nil, errors.New("token response has no access_token")
}
return payload.AccessToken, idTokenClaims(payload.IDToken), nil
}
func (service *Service) userInfo(ctx context.Context, config Config, accessToken string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, config.UserInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
response, err := service.client.Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("user-info endpoint returned %s", response.Status)
}
claims := map[string]any{}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&claims); err != nil {
return nil, err
}
return claims, nil
}
func token(bytes int) (string, error) {
buffer := make([]byte, bytes)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func challenge(verifier string) string {
digest := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(digest[:])
}
func idTokenClaims(value string) map[string]any {
parts := strings.Split(value, ".")
if len(parts) != 3 {
return nil
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil
}
claims := map[string]any{}
if json.Unmarshal(payload, &claims) != nil {
return nil
}
return claims
}
func claim(claims map[string]any, field string) string {
if value, ok := claims[field]; ok {
return fmt.Sprint(value)
}
return ""
}
func boolClaim(claims map[string]any, field string) bool {
value, ok := claims[field]
if !ok {
return false
}
switch value := value.(type) {
case bool:
return value
case string:
return strings.EqualFold(value, "true")
case float64:
return value != 0
}
return false
}
func administrator(email string, isAdmin bool, emails []string) bool {
if isAdmin {
return true
}
for _, candidate := range emails {
if email != "" && strings.EqualFold(strings.TrimSpace(email), strings.TrimSpace(candidate)) {
return true
}
}
return false
}
func mergeClaims(userInfo, idToken map[string]any) map[string]any {
result := make(map[string]any, len(userInfo)+len(idToken))
for key, value := range idToken {
result[key] = value
}
for key, value := range userInfo {
result[key] = value
}
return result
}
func cloneClaims(claims map[string]any) map[string]any {
if len(claims) == 0 {
return nil
}
result := make(map[string]any, len(claims))
for key, value := range claims {
result[key] = value
}
return result
}
+10 -1
View File
@@ -14,6 +14,7 @@ func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token st
if manager.config.Cookie.Secure { if manager.config.Cookie.Secure {
sameSite = http.SameSiteNoneMode sameSite = http.SameSiteNoneMode
} }
path := manager.cookiePath()
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: manager.config.Cookie.Name, Name: manager.config.Cookie.Name,
@@ -23,7 +24,7 @@ func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token st
SameSite: sameSite, SameSite: sameSite,
HttpOnly: manager.config.Cookie.HTTPOnly, HttpOnly: manager.config.Cookie.HTTPOnly,
Domain: manager.config.Cookie.Domain, Domain: manager.config.Cookie.Domain,
Path: manager.config.Cookie.Path, Path: path,
}) })
} }
@@ -35,9 +36,17 @@ func (manager *SessionManagerCtx) CookieClearToken(w http.ResponseWriter, r *htt
cookie.Value = "" cookie.Value = ""
cookie.Expires = time.Unix(0, 0) cookie.Expires = time.Unix(0, 0)
cookie.Path = manager.cookiePath()
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
} }
func (manager *SessionManagerCtx) cookiePath() string {
if manager.config.Cookie.Path == "" {
return "/"
}
return manager.config.Cookie.Path
}
func (manager *SessionManagerCtx) Authenticate(r *http.Request) (types.Session, error) { func (manager *SessionManagerCtx) Authenticate(r *http.Request) (types.Session, error) {
token, ok := manager.getToken(r) token, ok := manager.getToken(r)
if !ok { if !ok {
+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:
+3
View File
@@ -1,6 +1,9 @@
package types package types
import "net/http"
type ApiManager interface { type ApiManager interface {
Route(r Router) Route(r Router)
AddRouter(path string, router func(Router)) AddRouter(path string, router func(Router))
IsAuthenticated(r *http.Request) bool
} }
+2 -1
View File
@@ -9,7 +9,8 @@ var (
) )
type MemberProfile struct { type MemberProfile struct {
Name string `json:"name"` Name string `json:"name"`
Avatar string `json:"avatar"`
// permissions // permissions
IsAdmin bool `json:"is_admin" mapstructure:"is_admin"` IsAdmin bool `json:"is_admin" mapstructure:"is_admin"`
@@ -25,6 +25,7 @@ A member profile is a structure that describes the user and what the user is all
| Field | Description | Type | | Field | Description | Type |
|----------------------------|-------------|------| |----------------------------|-------------|------|
| <Def id="profile.name" /> | User's name as shown in the UI, must not be unique within the system (not used as an identifier). | string | | <Def id="profile.name" /> | User's name as shown in the UI, must not be unique within the system (not used as an identifier). | string |
| `avatar` | Avatar URL shown in clients that support member avatars. OAuth logins synchronize this value from the configured user-info field. | string |
| <Def id="profile.is_admin" /> | Whether the user can perform administrative tasks that include managing users, sessions, and settings. | boolean | | <Def id="profile.is_admin" /> | Whether the user can perform administrative tasks that include managing users, sessions, and settings. | boolean |
| <Def id="profile.can_login" /> | Whether the user can log in to the system and use the HTTP API. | boolean | | <Def id="profile.can_login" /> | Whether the user can log in to the system and use the HTTP API. | boolean |
| <Def id="profile.can_connect" /> | Whether the user can connect to the room using the WebSocket API (needs <Opt id="profile.can_login" /> to be enabled). | boolean | | <Def id="profile.can_connect" /> | Whether the user can connect to the room using the WebSocket API (needs <Opt id="profile.can_login" /> to be enabled). | boolean |
@@ -44,6 +45,7 @@ import TabItem from '@theme/TabItem';
```yaml title="Example member profile in YAML" ```yaml title="Example member profile in YAML"
name: User Name name: User Name
avatar: https://example.com/avatar.png
is_admin: false is_admin: false
can_login: true can_login: true
can_connect: true can_connect: true
@@ -63,6 +65,7 @@ import TabItem from '@theme/TabItem';
```json title="Example member profile in JSON" ```json title="Example member profile in JSON"
{ {
"name": "User Name", "name": "User Name",
"avatar": "https://example.com/avatar.png",
"is_admin": false, "is_admin": false,
"can_login": true, "can_login": true,
"can_connect": true, "can_connect": true,
@@ -172,6 +175,62 @@ environment:
``` ```
::: :::
### OAuth 2.0 Provider {#member.oauth}
Neko can use an OAuth 2.0 authorization-code provider. It exchanges the authorization code server-side and fetches the configured user-info endpoint. Provider access tokens, refresh tokens, and raw ID tokens are never persisted or returned to the browser.
For OpenID Connect providers, set `issuer_url`. Neko retrieves `/.well-known/openid-configuration` from that issuer and uses its authorization, token, and user-info endpoints. Issuer discovery takes precedence over explicitly configured endpoints, so it can be introduced without removing older endpoint settings.
When `redirect_url` is omitted, Neko derives the callback URL from the browser request as `https://<neko-host>/api/oauth/callback` (including `server.path_prefix`). Register that exact URL with the provider. Behind a TLS-terminating reverse proxy, set `server.proxy: true` so Neko trusts `X-Forwarded-Proto` and `X-Forwarded-Host`. The user-info endpoint must return JSON. `success_redirect` is relative to `server.path_prefix`.
```yaml title="config.yaml"
member:
provider: "oauth"
oauth:
enabled: true
# When true, visiting the Neko root page immediately starts OAuth login.
auto_redirect: true
name: "Example SSO"
admin_emails: ["platform-admin@example.com", "security@example.com"]
client_id: "<client-id>"
client_secret: "<client-secret>"
issuer_url: "https://id.example.com"
scopes: ["openid", "profile"]
# Configure these to match the JSON returned by userinfo_url.
subject_field: "sub"
username_field: "name"
avatar_field: "picture"
success_redirect: "/"
user_profile:
is_admin: false
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: 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.
`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 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`.
### File Provider {#member.file} ### File Provider {#member.file}
This provider reads the user's credentials from a file. It is useful for small deployments where you don't want to set up a database or LDAP server and still want to have persistent users. This provider reads the user's credentials from a file. It is useful for small deployments where you don't want to set up a database or LDAP server and still want to have persistent users.