mirror of
https://github.com/m1k1o/neko.git
synced 2026-11-03 00:11:48 -06:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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('?')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type SessionDataPayload struct {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,7 @@ func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error {
|
|||||||
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()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ 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"`
|
||||||
|
Avatar string `json:"avatar,omitempty"`
|
||||||
Admin bool `json:"admin"`
|
Admin bool `json:"admin"`
|
||||||
Muted bool `json:"muted"`
|
Muted bool `json:"muted"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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,
|
||||||
|
Avatar: profile.Avatar,
|
||||||
Admin: profile.IsAdmin,
|
Admin: profile.IsAdmin,
|
||||||
Muted: !settings.CanSend,
|
Muted: !settings.CanSend,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -630,7 +631,6 @@ func (s *session) wsToClient(msg []byte) error {
|
|||||||
Enabled: request.Enabled,
|
Enabled: request.Enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// Screen Events
|
// Screen Events
|
||||||
case event.SCREEN_UPDATED:
|
case event.SCREEN_UPDATED:
|
||||||
request := &message.ScreenSizeUpdate{}
|
request := &message.ScreenSizeUpdate{}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user