Compare commits

...

11 Commits

Author SHA1 Message Date
Miroslav Šedivý 1fec7b66d1 feat: add email scope to OAuth configuration and update documentation 2026-09-24 00:13:28 +02:00
Miroslav Šedivý b5fa27324c feat: implement user email restrictions for OAuth login 2026-09-23 23:52:47 +02:00
Miroslav Šedivý 51cde86c4d lint. 2026-08-30 17:02:15 +02:00
Miroslav Šedivý b181393f06 fix docs. 2026-08-30 16:59:52 +02:00
Miroslav Šedivý efc8b578ad lint. 2026-08-13 11:12:19 +02:00
Miroslav Šedivý d3cb3c9a30 oauth: separate provider & service. 2026-08-13 10:57:26 +02:00
Miroslav Šedivý 89e4efcf07 consistency across member provider configs.
in the furure we should refactor all configs to be part of the providers with init and set functions.
2026-08-13 10:27:28 +02:00
evlic 9160ba8355 refactor(auth): move OAuth flow into member provider 2026-08-13 11:54:43 +08:00
evlic 277abc8963 fix(auth): address OAuth review feedback 2026-08-12 17:29:04 +08:00
evlic 43c165ac34 docs(auth): document OIDC member login 2026-08-12 06:22:32 +08:00
evlic d0834f2b20 feat(auth): add OIDC member authentication 2026-08-12 06:22:28 +08:00
31 changed files with 1726 additions and 88 deletions
+31 -2
View File
@@ -12,7 +12,8 @@
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>
</template>
@@ -25,19 +26,47 @@
display: inline-block;
overflow: hidden;
border-radius: 50%;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
}
</style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
@Component({
name: 'neko-avatar',
})
export default class extends Vue {
@Prop(String) readonly seed: string | undefined
@Prop(String) readonly avatar: string | 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) {
let a = 0,
b = 0,
+6 -1
View File
@@ -11,7 +11,12 @@
}"
>
<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 class="content">
<div class="content-head">
+66 -4
View File
@@ -8,11 +8,30 @@
<form class="message" v-if="!connecting" @submit.stop.prevent="connect">
<span v-if="!autoPassword">{{ $t('connect.login_title') }}</span>
<span v-else>{{ $t('connect.invitation_title') }}</span>
<input type="text" :placeholder="$t('connect.displayname')" v-model="displayname" autofocus />
<input type="password" :placeholder="$t('connect.password')" v-model="password" v-if="!autoPassword" />
<button type="submit" @click.stop.prevent="login">
<input
v-if="passwordLoginEnabled"
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') }}
</button>
<button
v-if="oauthEnabled && !autoPassword"
type="button"
class="oauth-login"
@click.stop.prevent="loginWithOAuth"
>
{{ oauthName }} Login
</button>
</form>
<div class="loader" v-if="connecting">
<div class="bounce1"></div>
@@ -101,6 +120,11 @@
line-height: 30px;
margin: 5px 0;
border: none;
&.oauth-login {
background: $background-tertiary;
border: 1px solid $style-primary;
}
}
}
@@ -155,8 +179,16 @@
private displayname: 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
let password = this.$accessor.password
if (this.autoPassword !== null) {
@@ -182,6 +214,36 @@
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) {
let url = document.location.href
let urlparts = url.split('?')
+6 -1
View File
@@ -3,7 +3,12 @@
<template slot-scope="child" v-if="child.data">
<li class="header">
<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>
</div>
</li>
+2 -2
View File
@@ -4,7 +4,7 @@
<ul class="members-list">
<li v-if="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>
</li>
<template v-for="(member, index) in members">
@@ -17,7 +17,7 @@
:class="[{ host: member.id === host, admin: member.admin }, '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>
</li>
</template>
+1
View File
@@ -1,6 +1,7 @@
export interface Member {
id: string
displayname: string
avatar?: string
admin: boolean
muted: boolean
connected?: boolean
+3
View File
@@ -180,6 +180,8 @@ func (c *serve) Start(cmd *cobra.Command) {
c.managers.member,
c.managers.desktop,
c.managers.capture,
&c.configs.Member,
&c.configs.Server,
)
c.managers.plugins = plugins.New(
@@ -201,6 +203,7 @@ func (c *serve) Start(cmd *cobra.Command) {
c.managers.webSocket,
c.managers.api,
&c.configs.Server,
&c.configs.Member,
)
c.managers.http.Start()
}
+170
View File
@@ -0,0 +1,170 @@
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 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)
}
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)
}
}
+23
View File
@@ -8,6 +8,8 @@ import (
"github.com/m1k1o/neko/server/internal/api/members"
"github.com/m1k1o/neko/server/internal/api/room"
"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/types"
"github.com/m1k1o/neko/server/pkg/utils"
@@ -19,6 +21,7 @@ type ApiManagerCtx struct {
desktop types.DesktopManager
capture types.CaptureManager
routers map[string]func(types.Router)
oauth *oauthHandler
}
func New(
@@ -26,7 +29,13 @@ func New(
members types.MemberManager,
desktop types.DesktopManager,
capture types.CaptureManager,
memberConfig *config.Member,
serverConfig *config.Server,
) *ApiManagerCtx {
pathPrefix := "/"
if serverConfig != nil {
pathPrefix = serverConfig.PathPrefix
}
return &ApiManagerCtx{
sessions: sessions,
@@ -34,11 +43,20 @@ func New(
desktop: desktop,
capture: capture,
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) {
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
r.Group(func(r types.Router) {
@@ -65,6 +83,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) {
session, err := api.sessions.Authenticate(r)
if err != nil {
+9 -7
View File
@@ -15,10 +15,11 @@ type SessionLoginPayload struct {
}
type SessionDataPayload struct {
ID string `json:"id"`
Token string `json:"token,omitempty"`
Profile types.MemberProfile `json:"profile"`
State types.SessionState `json:"state"`
ID string `json:"id"`
Token string `json:"token,omitempty"`
Profile types.MemberProfile `json:"profile"`
State types.SessionState `json:"state"`
ExtraData map[string]any `json:"extra_data,omitempty"`
}
func (api *ApiManagerCtx) Login(w http.ResponseWriter, r *http.Request) error {
@@ -78,9 +79,10 @@ func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
return utils.HttpSuccess(w, SessionDataPayload{
ID: session.ID(),
Profile: session.Profile(),
State: session.State(),
ID: session.ID(),
Profile: session.Profile(),
State: session.State(),
ExtraData: api.oauth.service.ExtraData(session.ID()),
})
}
+152 -28
View File
@@ -7,11 +7,36 @@ import (
"github.com/m1k1o/neko/server/internal/member/file"
"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/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
)
var DefaultUserProfile = types.MemberProfile{
IsAdmin: false,
CanLogin: true,
CanConnect: true,
CanWatch: true,
CanHost: true,
CanShareMedia: true,
CanAccessClipboard: true,
SendsInactiveCursor: true,
CanSeeInactiveCursors: false,
}
var DefaultAdminProfile = types.MemberProfile{
IsAdmin: true,
CanLogin: true,
CanConnect: true,
CanWatch: true,
CanHost: true,
CanShareMedia: true,
CanAccessClipboard: true,
SendsInactiveCursor: true,
CanSeeInactiveCursors: true,
}
type Member struct {
Provider string
@@ -19,6 +44,7 @@ type Member struct {
File file.Config
Object object.Config
Multiuser multiuser.Config
OAuth oauth.Config
}
func (Member) Init(cmd *cobra.Command) error {
@@ -65,6 +91,97 @@ func (Member) Init(cmd *cobra.Command) error {
return err
}
// oauth provider
cmd.PersistentFlags().Bool("member.oauth.enabled", false, "enable OAuth 2.0 login")
if err := viper.BindPFlag("member.oauth.enabled", cmd.PersistentFlags().Lookup("member.oauth.enabled")); err != nil {
return err
}
cmd.PersistentFlags().Bool("member.oauth.auto_redirect", false, "redirect the Neko root page to OAuth login automatically")
if err := viper.BindPFlag("member.oauth.auto_redirect", cmd.PersistentFlags().Lookup("member.oauth.auto_redirect")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.name", "OAuth", "display name for the OAuth login option")
if err := viper.BindPFlag("member.oauth.name", cmd.PersistentFlags().Lookup("member.oauth.name")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("member.oauth.admin_emails", []string{}, "OAuth user-info email addresses granted administrator access")
if err := viper.BindPFlag("member.oauth.admin_emails", cmd.PersistentFlags().Lookup("member.oauth.admin_emails")); err != nil {
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
}
cmd.PersistentFlags().String("member.oauth.client_secret", "", "OAuth 2.0 client secret")
if err := viper.BindPFlag("member.oauth.client_secret", cmd.PersistentFlags().Lookup("member.oauth.client_secret")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.issuer_url", "", "OpenID Connect issuer URL used to discover OAuth endpoints")
if err := viper.BindPFlag("member.oauth.issuer_url", cmd.PersistentFlags().Lookup("member.oauth.issuer_url")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.authorization_url", "", "OAuth 2.0 authorization endpoint")
if err := viper.BindPFlag("member.oauth.authorization_url", cmd.PersistentFlags().Lookup("member.oauth.authorization_url")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.token_url", "", "OAuth 2.0 token endpoint")
if err := viper.BindPFlag("member.oauth.token_url", cmd.PersistentFlags().Lookup("member.oauth.token_url")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.userinfo_url", "", "OAuth 2.0 user-info endpoint")
if err := viper.BindPFlag("member.oauth.userinfo_url", cmd.PersistentFlags().Lookup("member.oauth.userinfo_url")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.redirect_url", "", "OAuth 2.0 callback URL registered with the provider")
if err := viper.BindPFlag("member.oauth.redirect_url", cmd.PersistentFlags().Lookup("member.oauth.redirect_url")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("member.oauth.scopes", []string{"openid", "profile", "email"}, "OAuth 2.0 scopes")
if err := viper.BindPFlag("member.oauth.scopes", cmd.PersistentFlags().Lookup("member.oauth.scopes")); err != nil {
return err
}
cmd.PersistentFlags().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", cmd.PersistentFlags().Lookup("member.oauth.subject_field")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.username_field", "name", "field in user-info response used as the display name")
if err := viper.BindPFlag("member.oauth.username_field", cmd.PersistentFlags().Lookup("member.oauth.username_field")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.avatar_field", "picture", "field in user-info response used as the avatar URL")
if err := viper.BindPFlag("member.oauth.avatar_field", cmd.PersistentFlags().Lookup("member.oauth.avatar_field")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.success_redirect", "/", "relative URL to redirect to after OAuth login")
if err := viper.BindPFlag("member.oauth.success_redirect", cmd.PersistentFlags().Lookup("member.oauth.success_redirect")); err != nil {
return err
}
cmd.PersistentFlags().String("member.oauth.user_profile", "{}", "OAuth user permission profile")
if err := viper.BindPFlag("member.oauth.user_profile", cmd.PersistentFlags().Lookup("member.oauth.user_profile")); err != nil {
return err
}
return nil
}
@@ -100,45 +217,52 @@ func (s *Member) Set() {
s.Multiuser.UserPassword = viper.GetString("member.multiuser.user_password")
s.Multiuser.AdminPassword = viper.GetString("member.multiuser.admin_password")
// default user profile
s.Multiuser.UserProfile = types.MemberProfile{
IsAdmin: false,
CanLogin: true,
CanConnect: true,
CanWatch: true,
CanHost: true,
CanShareMedia: true,
CanAccessClipboard: true,
SendsInactiveCursor: true,
CanSeeInactiveCursors: false,
}
// override user profile
s.Multiuser.UserProfile = DefaultUserProfile
if err := viper.UnmarshalKey("member.multiuser.user_profile", &s.Multiuser.UserProfile, viper.DecodeHook(
utils.JsonStringAutoDecode(s.Multiuser.UserProfile),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse member multiuser user profile")
}
// default admin profile
s.Multiuser.AdminProfile = types.MemberProfile{
IsAdmin: true,
CanLogin: true,
CanConnect: true,
CanWatch: true,
CanHost: true,
CanShareMedia: true,
CanAccessClipboard: true,
SendsInactiveCursor: true,
CanSeeInactiveCursors: true,
}
// override admin profile
s.Multiuser.AdminProfile = DefaultAdminProfile
if err := viper.UnmarshalKey("member.multiuser.admin_profile", &s.Multiuser.AdminProfile, viper.DecodeHook(
utils.JsonStringAutoDecode(s.Multiuser.AdminProfile),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse member multiuser admin profile")
}
// oauth provider
s.OAuth.Enabled = viper.GetBool("member.oauth.enabled")
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")
s.OAuth.AuthorizationURL = viper.GetString("member.oauth.authorization_url")
s.OAuth.TokenURL = viper.GetString("member.oauth.token_url")
s.OAuth.UserInfoURL = viper.GetString("member.oauth.userinfo_url")
s.OAuth.RedirectURL = viper.GetString("member.oauth.redirect_url")
s.OAuth.Scopes = viper.GetStringSlice("member.oauth.scopes")
s.OAuth.SubjectField = viper.GetString("member.oauth.subject_field")
s.OAuth.UsernameField = viper.GetString("member.oauth.username_field")
s.OAuth.AvatarField = viper.GetString("member.oauth.avatar_field")
s.OAuth.SuccessRedirect = viper.GetString("member.oauth.success_redirect")
s.OAuth.UserProfile = DefaultUserProfile
if err := viper.UnmarshalKey("member.oauth.user_profile", &s.OAuth.UserProfile, viper.DecodeHook(
utils.JsonStringAutoDecode(s.OAuth.UserProfile),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse member OAuth user profile")
}
s.OAuth.AdminProfile = DefaultAdminProfile
if err := viper.UnmarshalKey("member.oauth.admin_profile", &s.OAuth.AdminProfile, viper.DecodeHook(
utils.JsonStringAutoDecode(s.OAuth.AdminProfile),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse member OAuth admin profile")
}
}
func (s *Member) SetV2() {
+8 -1
View File
@@ -105,7 +105,14 @@ func (h *LegacyHandler) Route(r types.Router) {
defer s.destroy()
// 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 {
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
token string
cookie string
name string
isAdmin bool
client *http.Client
@@ -85,6 +86,8 @@ func (s *session) req(method, reqPath string, headers http.Header, request io.Re
if 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)
@@ -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 {
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)
if err != nil {
return err
}
headers := http.Header{
"Content-Type": []string{"application/json"},
if headers == nil {
headers = make(http.Header)
}
headers.Set("Content-Type", "application/json")
resBody, _, err := s.req(method, path, headers, bytes.NewReader(reqBody))
if err != nil {
@@ -174,6 +182,16 @@ func (s *session) toBackend(event string, payload any) error {
func (s *session) create(username, password string) error {
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{
Username: username,
@@ -183,18 +201,21 @@ func (s *session) create(username, password string) error {
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.h.sessionIPs[s.id] = s.ip // save session ip by id
s.token = data.Token
s.name = data.Profile.Name
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() {
@@ -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 {
ID string `json:"id"`
Name string `json:"displayname"`
Admin bool `json:"admin"`
Muted bool `json:"muted"`
ID string `json:"id"`
Name string `json:"displayname"`
Avatar string `json:"avatar,omitempty"`
Admin bool `json:"admin"`
Muted bool `json:"muted"`
}
type FileListItem struct {
+11 -11
View File
@@ -32,10 +32,11 @@ func profileToMember(id string, profile types.MemberProfile) (*oldTypes.Member,
}
return &oldTypes.Member{
ID: id,
Name: profile.Name,
Admin: profile.IsAdmin,
Muted: !settings.CanSend,
ID: id,
Name: profile.Name,
Avatar: profile.Avatar,
Admin: profile.IsAdmin,
Muted: !settings.CanSend,
}, nil
}
@@ -618,18 +619,17 @@ func (s *session) wsToClient(msg []byte) error {
// Open In App Events
case openinapp.OPENINAPP_INIT:
request := &openinapp.Init{}
if err := json.Unmarshal(data.Payload, request); err != nil {
request := &openinapp.Init{}
if err := json.Unmarshal(data.Payload, request); err != nil {
return err
}
return s.toClient(&struct {
}
return s.toClient(&struct {
Event string `json:"event"`
Enabled bool `json:"enabled"`
}{
}{
Event: openinapp.OPENINAPP_INIT,
Enabled: request.Enabled,
})
})
// Screen Events
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/http"
"os"
"path"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
@@ -23,7 +24,7 @@ type HttpManagerCtx struct {
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()
opts := []RouterOption{
@@ -67,6 +68,37 @@ func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, c
}
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 {
_, err := w.Write([]byte("true"))
return err
@@ -79,20 +111,8 @@ func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, c
})
}
if config.Static != "" {
fs := http.FileServer(http.Dir(config.Static))
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 staticHandler != nil {
router.Get("/*", staticHandler)
}
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/multiuser"
"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/pkg/types"
)
@@ -29,6 +30,8 @@ func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx
manager.provider = object.New(config.Object)
case "multiuser":
manager.provider = multiuser.New(config.Multiuser)
case "oauth":
manager.provider = oauth.New()
case "noauth":
fallthrough
default:
@@ -164,5 +167,6 @@ func (manager *MemberManagerCtx) Logout(id string) error {
manager.loginMu.Lock()
defer manager.loginMu.Unlock()
return manager.sessions.Delete(id)
err := manager.sessions.Delete(id)
return err
}
+35
View File
@@ -0,0 +1,35 @@
package oauth
import (
"github.com/m1k1o/neko/server/pkg/types"
)
type Config struct {
Enabled bool
AutoRedirect bool
Name string
AdminEmails []string
UserEmails []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 (c *Config) IsConfigured() bool {
return c.Enabled && c.ClientID != "" &&
c.ClientSecret != "" &&
c.AuthorizationURL != "" &&
c.TokenURL != "" &&
c.UserInfoURL != ""
}
+45
View File
@@ -0,0 +1,45 @@
package oauth
import (
"errors"
"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")
}
+401
View File
@@ -0,0 +1,401 @@
package oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/rs/zerolog/log"
"github.com/m1k1o/neko/server/pkg/types"
)
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 !config.IsConfigured() {
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 !config.IsConfigured() {
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")
}
if email == "" && (len(config.AdminEmails) > 0 || len(config.UserEmails) > 0) {
log.Warn().Msg("OAuth admin_emails/user_emails are configured but the provider did not return an 'email' claim; request the 'email' scope")
}
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 admin {
profile = config.AdminProfile
} else {
profile = config.UserProfile
}
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.setExtraData(id, claims, idClaims)
}
return session, sessionToken, err
}
func (service *Service) ExtraData(id string) map[string]any {
service.mu.Lock()
defer service.mu.Unlock()
return maps.Clone(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
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
}
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 tokenResponse
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 (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) setExtraData(id string, data ...map[string]any) {
service.mu.Lock()
defer service.mu.Unlock()
totalLen := 0
for _, d := range data {
totalLen += len(d)
}
result := make(map[string]any, totalLen)
for _, d := range data {
maps.Copy(result, d)
}
service.extra[id] = result
}
func (service *Service) deleteExtraData(id string) {
service.mu.Lock()
defer service.mu.Unlock()
delete(service.extra, id)
}
+96
View File
@@ -0,0 +1,96 @@
package oauth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
)
func absoluteURL(value string) bool {
parsed, err := url.Parse(value)
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}
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
}
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
}
}
return false
}
+10 -1
View File
@@ -14,6 +14,7 @@ func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token st
if manager.config.Cookie.Secure {
sameSite = http.SameSiteNoneMode
}
path := manager.cookiePath()
http.SetCookie(w, &http.Cookie{
Name: manager.config.Cookie.Name,
@@ -23,7 +24,7 @@ func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token st
SameSite: sameSite,
HttpOnly: manager.config.Cookie.HTTPOnly,
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.Expires = time.Unix(0, 0)
cookie.Path = manager.cookiePath()
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) {
token, ok := manager.getToken(r)
if !ok {
+104
View File
@@ -168,6 +168,94 @@ paths:
schema:
$ref: '#/components/schemas/SessionLoginRequest'
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:
post:
tags:
@@ -1302,6 +1390,22 @@ components:
type: string
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:
type: object
properties:
+3
View File
@@ -1,6 +1,9 @@
package types
import "net/http"
type ApiManager interface {
Route(r Router)
AddRouter(path string, router func(Router))
IsAuthenticated(r *http.Request) bool
}
+2 -1
View File
@@ -9,7 +9,8 @@ var (
)
type MemberProfile struct {
Name string `json:"name"`
Name string `json:"name"`
Avatar string `json:"avatar"`
// permissions
IsAdmin bool `json:"is_admin" mapstructure:"is_admin"`
+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 {
@@ -25,6 +25,7 @@ A member profile is a structure that describes the user and what the user is all
| 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.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.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 |
@@ -44,6 +45,7 @@ import TabItem from '@theme/TabItem';
```yaml title="Example member profile in YAML"
name: User Name
avatar: https://example.com/avatar.png
is_admin: false
can_login: true
can_connect: true
@@ -63,6 +65,7 @@ import TabItem from '@theme/TabItem';
```json title="Example member profile in JSON"
{
"name": "User Name",
"avatar": "https://example.com/avatar.png",
"is_admin": false,
"can_login": true,
"can_connect": true,
@@ -172,6 +175,69 @@ 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"]
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"
scopes: ["openid", "profile", "email"]
# 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.
`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.
:::warning
`admin_emails` and `user_emails` are matched against the `email` claim. Most providers only include that claim if the `email` scope is requested, so `scopes` must include `"email"` (in addition to `"openid"`) or the claim will be empty and these lists will never match.
:::
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}
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.