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

* feat(auth): add OIDC member authentication

* docs(auth): document OIDC member login

* fix(auth): address OAuth review feedback

* refactor(auth): move OAuth flow into member provider
This commit is contained in:
@刘羡鱼
2026-09-24 06:15:46 +08:00
committed by GitHub
parent ddf2787c5e
commit dae23a90b5
28 changed files with 1517 additions and 61 deletions
+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()
}
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"errors"
"net/http"
"net/url"
"path"
"strings"
"github.com/m1k1o/neko/server/internal/member/oauth"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
)
type OAuthUIConfig struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
LoginURL string `json:"login_url"`
PasswordLoginEnabled bool `json:"password_login_enabled"`
}
// oauthHandler is deliberately limited to HTTP concerns. The OAuth protocol,
// profile mapping, and session creation are owned by member/oauth.Service.
type oauthHandler struct {
service *oauth.Service
pathPrefix string
trustProxy bool
providerOAuth bool
}
func newOAuthHandler(service *oauth.Service, pathPrefix string, trustProxy, providerOAuth bool) *oauthHandler {
if pathPrefix == "" {
pathPrefix = "/"
}
return &oauthHandler{service: service, pathPrefix: pathPrefix, trustProxy: trustProxy, providerOAuth: providerOAuth}
}
func (api *ApiManagerCtx) OAuthConfig(w http.ResponseWriter, r *http.Request) error {
return utils.HttpSuccess(w, OAuthUIConfig{
Enabled: api.oauth.providerOAuth && api.oauth.service.Enabled(),
Name: api.oauth.service.Name(),
LoginURL: path.Join(api.oauth.pathPrefix, "/api/oauth/login"),
PasswordLoginEnabled: !api.oauth.providerOAuth,
})
}
func (api *ApiManagerCtx) OAuthLogin(w http.ResponseWriter, r *http.Request) error {
if !api.oauth.providerOAuth || !api.oauth.service.Enabled() {
return utils.HttpNotFound()
}
if !api.sessions.CookieEnabled() {
return utils.HttpError(http.StatusServiceUnavailable, "OAuth requires session cookies to be enabled")
}
callbackURL, err := api.oauth.callbackURL(r)
if err != nil {
return utils.HttpInternalServerError("unable to determine OAuth callback URL").WithInternalErr(err)
}
location, err := api.oauth.service.Start(r.Context(), callbackURL)
if err != nil {
return oauthServiceError(err, true)
}
http.Redirect(w, r, location, http.StatusFound)
return nil
}
func (api *ApiManagerCtx) OAuthCallback(w http.ResponseWriter, r *http.Request) error {
if !api.oauth.providerOAuth || !api.oauth.service.Enabled() {
return utils.HttpNotFound()
}
if providerError := r.URL.Query().Get("error"); providerError != "" {
return utils.HttpUnauthorized("OAuth authorization was declined").WithInternalMsg(providerError)
}
_, token, err := api.oauth.service.Complete(r.Context(), r.URL.Query().Get("state"), r.URL.Query().Get("code"))
if err != nil {
return oauthServiceError(err, false)
}
api.sessions.CookieSetToken(w, token)
http.Redirect(w, r, api.oauth.successRedirect(), http.StatusSeeOther)
return nil
}
func oauthServiceError(err error, start bool) error {
if errors.Is(err, types.ErrSessionAlreadyConnected) {
return utils.HttpUnprocessableEntity("session already connected")
}
if errors.Is(err, types.ErrSessionLoginsLocked) {
return utils.HttpForbidden("logins are locked").WithInternalErr(err)
}
if start && strings.HasPrefix(err.Error(), "issuer discovery:") {
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
}
if start {
return utils.HttpError(http.StatusServiceUnavailable, err.Error()).WithInternalErr(err)
}
if strings.Contains(err.Error(), "missing state or code") || strings.Contains(err.Error(), "state is invalid") {
return utils.HttpBadRequest(err.Error()).WithInternalErr(err)
}
if strings.HasPrefix(err.Error(), "issuer discovery:") || strings.Contains(err.Error(), "not fully configured") {
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
}
return utils.HttpUnauthorized("OAuth authorization failed").WithInternalErr(err)
}
func (handler *oauthHandler) callbackURL(r *http.Request) (string, error) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
host := r.Host
if handler.trustProxy {
if value := forwardedHeaderValue(r.Header.Get("X-Forwarded-Proto")); value != "" {
scheme = value
}
if value := forwardedHeaderValue(r.Header.Get("X-Forwarded-Host")); value != "" {
host = value
}
}
if host == "" || (scheme != "http" && scheme != "https") {
return "", errors.New("request has no valid public URL")
}
callback := (&url.URL{Scheme: scheme, Host: host, Path: path.Join(handler.pathPrefix, "/api/oauth/callback")}).String()
if parsed, err := url.Parse(callback); err != nil || parsed.Host == "" {
return "", errors.New("request has no valid public URL")
}
return callback, nil
}
func (handler *oauthHandler) successRedirect() string {
redirect := handler.service.SuccessRedirect()
if strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") {
return path.Join(handler.pathPrefix, redirect)
}
return handler.pathPrefix
}
func forwardedHeaderValue(value string) string {
return strings.TrimSpace(strings.Split(value, ",")[0])
}
+248
View File
@@ -0,0 +1,248 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/member"
"github.com/m1k1o/neko/server/internal/member/oauth"
"github.com/m1k1o/neko/server/internal/session"
"github.com/m1k1o/neko/server/pkg/types"
)
func TestOAuthLoginSynchronizesProfile(t *testing.T) {
var tokenRequest url.Values
idToken := testIDToken(t, map[string]string{
"sub": "user-123",
"displayName": "Ada Lovelace",
"avatar": "https://example.test/ada.png",
"isAdmin": "true",
})
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tokenRequest = r.Form
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "provider-token", "id_token": idToken})
case "/userinfo":
if got := r.Header.Get("Authorization"); got != "Bearer provider-token" {
http.Error(w, "missing provider authorization", http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"sub": "user-123",
"email": "user@example.test",
})
default:
http.NotFound(w, r)
}
}))
defer provider.Close()
memberConfig := &config.Member{
Provider: "oauth",
OAuth: oauth.Config{
Enabled: true,
ClientID: "client-id",
ClientSecret: "client-secret",
AuthorizationURL: provider.URL + "/authorize",
TokenURL: provider.URL + "/token",
UserInfoURL: provider.URL + "/userinfo",
RedirectURL: "https://neko.example.test/api/oauth/callback",
Scopes: []string{"openid", "profile"},
SubjectField: "sub",
UsernameField: "displayName",
AvatarField: "avatar",
AdminEmails: []string{"admin@example.test"},
SuccessRedirect: "/room",
UserProfile: types.MemberProfile{
CanLogin: true,
CanAccessClipboard: true,
},
AdminProfile: types.MemberProfile{
IsAdmin: true,
CanLogin: true,
CanSeeInactiveCursors: true,
},
},
}
sessionManager := session.New(&config.Session{
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
})
members := member.New(sessionManager, memberConfig)
api := New(sessionManager, members, nil, nil, memberConfig, &config.Server{PathPrefix: "/"})
loginRecorder := httptest.NewRecorder()
api.OAuthLogin(loginRecorder, httptest.NewRequest(http.MethodGet, "/api/oauth/login", nil))
if loginRecorder.Code != http.StatusFound {
t.Fatalf("login status = %d", loginRecorder.Code)
}
authorizeURL, err := url.Parse(loginRecorder.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
state := authorizeURL.Query().Get("state")
if state == "" || authorizeURL.Query().Get("code_challenge") == "" {
t.Fatalf("OAuth redirect is missing state or PKCE challenge: %s", authorizeURL.String())
}
callbackRecorder := httptest.NewRecorder()
callbackURL := "/api/oauth/callback?state=" + url.QueryEscape(state) + "&code=authorization-code"
api.OAuthCallback(callbackRecorder, httptest.NewRequest(http.MethodGet, callbackURL, nil))
if callbackRecorder.Code != http.StatusSeeOther {
t.Fatalf("callback status = %d", callbackRecorder.Code)
}
if got := callbackRecorder.Header().Get("Location"); got != "/room" {
t.Fatalf("redirect = %q", got)
}
if tokenRequest.Get("code_verifier") == "" {
t.Fatal("token request is missing PKCE verifier")
}
cookies := callbackRecorder.Result().Cookies()
if len(cookies) != 1 {
t.Fatal("OAuth callback did not set a session cookie")
}
if cookies[0].Path != "/" {
t.Fatalf("OAuth session cookie path = %q, want root path", cookies[0].Path)
}
userSession, ok := sessionManager.Get("oauth:user-123")
if !ok {
t.Fatal("OAuth session was not created")
}
profile := userSession.Profile()
if profile.Name != "Ada Lovelace" || profile.Avatar != "https://example.test/ada.png" || !profile.IsAdmin || profile.CanAccessClipboard {
t.Fatalf("profile = %#v", profile)
}
if extraData := api.oauth.service.ExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" {
t.Fatalf("extra data = %#v", extraData)
}
}
func testIDToken(t *testing.T, claims map[string]string) string {
t.Helper()
payload, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
return "eyJhbGciOiJub25lIn0." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
}
func TestOAuthIssuerDiscoveryTakesPrecedence(t *testing.T) {
var tokenRequest url.Values
var issuerURL string
issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuerURL,
"authorization_endpoint": issuerURL + "/authorize",
"token_endpoint": issuerURL + "/token",
"userinfo_endpoint": issuerURL + "/userinfo",
})
case "/token":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tokenRequest = r.Form
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "provider-token"})
case "/userinfo":
_ = json.NewEncoder(w).Encode(map[string]string{"sub": "user-456", "name": "Grace Hopper"})
default:
http.NotFound(w, r)
}
}))
defer issuer.Close()
issuerURL = issuer.URL
memberConfig := &config.Member{
Provider: "oauth",
OAuth: oauth.Config{
Enabled: true,
ClientID: "client-id",
ClientSecret: "client-secret",
IssuerURL: issuerURL,
AuthorizationURL: "https://ignored.example.test/authorize",
TokenURL: "https://ignored.example.test/token",
UserInfoURL: "https://ignored.example.test/userinfo",
Scopes: []string{"openid", "profile"},
SubjectField: "sub",
UsernameField: "name",
AvatarField: "picture",
UserProfile: types.MemberProfile{CanLogin: true},
},
}
sessionManager := session.New(&config.Session{
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
})
members := member.New(sessionManager, memberConfig)
api := New(sessionManager, members, nil, nil, memberConfig, &config.Server{PathPrefix: "/"})
loginRequest := httptest.NewRequest(http.MethodGet, "https://neko.example.test/api/oauth/login", nil)
loginRecorder := httptest.NewRecorder()
api.OAuthLogin(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusFound {
t.Fatalf("login status = %d", loginRecorder.Code)
}
authorizationURL, err := url.Parse(loginRecorder.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
issuerEndpoint, err := url.Parse(issuerURL)
if err != nil {
t.Fatal(err)
}
if authorizationURL.Host != issuerEndpoint.Host {
t.Fatalf("authorization endpoint = %q", authorizationURL.String())
}
redirectURL := authorizationURL.Query().Get("redirect_uri")
if redirectURL != "https://neko.example.test/api/oauth/callback" {
t.Fatalf("derived redirect URL = %q", redirectURL)
}
callbackRecorder := httptest.NewRecorder()
callbackURL := "/api/oauth/callback?state=" + url.QueryEscape(authorizationURL.Query().Get("state")) + "&code=authorization-code"
api.OAuthCallback(callbackRecorder, httptest.NewRequest(http.MethodGet, callbackURL, nil))
if callbackRecorder.Code != http.StatusSeeOther {
t.Fatalf("callback status = %d", callbackRecorder.Code)
}
if tokenRequest.Get("redirect_uri") != redirectURL {
t.Fatalf("token redirect URL = %q, want %q", tokenRequest.Get("redirect_uri"), redirectURL)
}
}
func TestOAuthConfigUsesProviderAndName(t *testing.T) {
memberConfig := &config.Member{
Provider: "oauth",
OAuth: oauth.Config{
Enabled: true,
Name: "Team SSO",
},
}
sessionManager := session.New(&config.Session{})
api := New(sessionManager, member.New(sessionManager, memberConfig), nil, nil, memberConfig, &config.Server{})
recorder := httptest.NewRecorder()
if err := api.OAuthConfig(recorder, httptest.NewRequest(http.MethodGet, "/api/oauth/config", nil)); err != nil {
t.Fatal(err)
}
var payload OAuthUIConfig
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if !payload.Enabled || payload.PasswordLoginEnabled || payload.Name != "Team SSO" || payload.LoginURL != "/api/oauth/login" {
t.Fatalf("OAuth UI config = %#v", payload)
}
}
+18
View File
@@ -8,6 +8,8 @@ import (
"github.com/m1k1o/neko/server/internal/api/members"
"github.com/m1k1o/neko/server/internal/api/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,15 @@ 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 +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) {
session, err := api.sessions.Authenticate(r)
if err != nil {
+9 -8
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 {
@@ -66,7 +67,6 @@ func (api *ApiManagerCtx) Logout(w http.ResponseWriter, r *http.Request) error {
return utils.HttpInternalServerError().WithInternalErr(err)
}
}
if api.sessions.CookieEnabled() {
api.sessions.CookieClearToken(w, r)
}
@@ -78,9 +78,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()),
})
}
+9
View File
@@ -7,6 +7,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/oauth"
"github.com/m1k1o/neko/server/internal/member/object"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
@@ -19,6 +20,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 +67,10 @@ func (Member) Init(cmd *cobra.Command) error {
return err
}
if err := oauth.Init(cmd); err != nil {
return err
}
return nil
}
@@ -139,6 +145,9 @@ func (s *Member) Set() {
)); err != nil {
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() {
+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
}
+130
View File
@@ -0,0 +1,130 @@
package oauth
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
)
// Config contains the OAuth member provider settings.
type Config struct {
Enabled bool
AutoRedirect bool
Name string
AdminEmails []string
ClientID string
ClientSecret string
IssuerURL string
AuthorizationURL string
TokenURL string
UserInfoURL string
RedirectURL string
Scopes []string
SubjectField string
UsernameField string
AvatarField string
SuccessRedirect string
AdminProfile types.MemberProfile
UserProfile types.MemberProfile
}
func Init(cmd *cobra.Command) error {
flags := cmd.PersistentFlags()
flags.Bool("member.oauth.enabled", false, "enable OAuth 2.0 login")
if err := viper.BindPFlag("member.oauth.enabled", flags.Lookup("member.oauth.enabled")); err != nil {
return err
}
flags.Bool("member.oauth.auto_redirect", false, "redirect the Neko root page to OAuth login automatically")
if err := viper.BindPFlag("member.oauth.auto_redirect", flags.Lookup("member.oauth.auto_redirect")); err != nil {
return err
}
flags.String("member.oauth.name", "OAuth", "display name for the OAuth login option")
if err := viper.BindPFlag("member.oauth.name", flags.Lookup("member.oauth.name")); err != nil {
return err
}
flags.StringSlice("member.oauth.admin_emails", []string{}, "OAuth user-info email addresses granted administrator access")
if err := viper.BindPFlag("member.oauth.admin_emails", flags.Lookup("member.oauth.admin_emails")); err != nil {
return err
}
flags.String("member.oauth.client_id", "", "OAuth 2.0 client ID")
if err := viper.BindPFlag("member.oauth.client_id", flags.Lookup("member.oauth.client_id")); err != nil {
return err
}
flags.String("member.oauth.client_secret", "", "OAuth 2.0 client secret")
if err := viper.BindPFlag("member.oauth.client_secret", flags.Lookup("member.oauth.client_secret")); err != nil {
return err
}
flags.String("member.oauth.issuer_url", "", "OpenID Connect issuer URL used to discover OAuth endpoints")
if err := viper.BindPFlag("member.oauth.issuer_url", flags.Lookup("member.oauth.issuer_url")); err != nil {
return err
}
flags.String("member.oauth.authorization_url", "", "OAuth 2.0 authorization endpoint")
if err := viper.BindPFlag("member.oauth.authorization_url", flags.Lookup("member.oauth.authorization_url")); err != nil {
return err
}
flags.String("member.oauth.token_url", "", "OAuth 2.0 token endpoint")
if err := viper.BindPFlag("member.oauth.token_url", flags.Lookup("member.oauth.token_url")); err != nil {
return err
}
flags.String("member.oauth.userinfo_url", "", "OAuth 2.0 user-info endpoint")
if err := viper.BindPFlag("member.oauth.userinfo_url", flags.Lookup("member.oauth.userinfo_url")); err != nil {
return err
}
flags.String("member.oauth.redirect_url", "", "OAuth 2.0 callback URL registered with the provider")
if err := viper.BindPFlag("member.oauth.redirect_url", flags.Lookup("member.oauth.redirect_url")); err != nil {
return err
}
flags.StringSlice("member.oauth.scopes", []string{"openid", "profile"}, "OAuth 2.0 scopes")
if err := viper.BindPFlag("member.oauth.scopes", flags.Lookup("member.oauth.scopes")); err != nil {
return err
}
flags.String("member.oauth.subject_field", "sub", "field in user-info response used as the stable user identifier")
if err := viper.BindPFlag("member.oauth.subject_field", flags.Lookup("member.oauth.subject_field")); err != nil {
return err
}
flags.String("member.oauth.username_field", "name", "field in user-info response used as the display name")
if err := viper.BindPFlag("member.oauth.username_field", flags.Lookup("member.oauth.username_field")); err != nil {
return err
}
flags.String("member.oauth.avatar_field", "picture", "field in user-info response used as the avatar URL")
if err := viper.BindPFlag("member.oauth.avatar_field", flags.Lookup("member.oauth.avatar_field")); err != nil {
return err
}
flags.String("member.oauth.success_redirect", "/", "relative URL to redirect to after OAuth login")
if err := viper.BindPFlag("member.oauth.success_redirect", flags.Lookup("member.oauth.success_redirect")); err != nil {
return err
}
flags.String("member.oauth.user_profile", "{}", "OAuth user permission profile")
if err := viper.BindPFlag("member.oauth.user_profile", flags.Lookup("member.oauth.user_profile")); err != nil {
return err
}
flags.String("member.oauth.admin_profile", "{}", "OAuth administrator permission profile")
return viper.BindPFlag("member.oauth.admin_profile", flags.Lookup("member.oauth.admin_profile"))
}
func (c *Config) Set(userProfile, adminProfile types.MemberProfile) error {
c.Enabled = viper.GetBool("member.oauth.enabled")
c.AutoRedirect = viper.GetBool("member.oauth.auto_redirect")
c.Name = viper.GetString("member.oauth.name")
c.AdminEmails = viper.GetStringSlice("member.oauth.admin_emails")
c.ClientID = viper.GetString("member.oauth.client_id")
c.ClientSecret = viper.GetString("member.oauth.client_secret")
c.IssuerURL = viper.GetString("member.oauth.issuer_url")
c.AuthorizationURL = viper.GetString("member.oauth.authorization_url")
c.TokenURL = viper.GetString("member.oauth.token_url")
c.UserInfoURL = viper.GetString("member.oauth.userinfo_url")
c.RedirectURL = viper.GetString("member.oauth.redirect_url")
c.Scopes = viper.GetStringSlice("member.oauth.scopes")
c.SubjectField = viper.GetString("member.oauth.subject_field")
c.UsernameField = viper.GetString("member.oauth.username_field")
c.AvatarField = viper.GetString("member.oauth.avatar_field")
c.SuccessRedirect = viper.GetString("member.oauth.success_redirect")
c.UserProfile = userProfile
c.AdminProfile = adminProfile
if err := viper.UnmarshalKey("member.oauth.user_profile", &c.UserProfile, viper.DecodeHook(utils.JsonStringAutoDecode(c.UserProfile))); err != nil {
return err
}
return viper.UnmarshalKey("member.oauth.admin_profile", &c.AdminProfile, viper.DecodeHook(utils.JsonStringAutoDecode(c.AdminProfile)))
}
+428
View File
@@ -0,0 +1,428 @@
package oauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/m1k1o/neko/server/pkg/types"
)
// MemberProviderCtx disables password authentication. OAuth sessions are
// created by Service after the OAuth callback succeeds.
type MemberProviderCtx struct{}
func New() types.MemberProvider { return &MemberProviderCtx{} }
func (provider *MemberProviderCtx) Connect() error { return nil }
func (provider *MemberProviderCtx) Disconnect() error { return nil }
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
}
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
return "", errors.New("OAuth members are created by OAuth login")
}
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
return errors.New("OAuth profile is managed by the identity provider")
}
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
return errors.New("OAuth provider does not have passwords")
}
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
return types.MemberProfile{}, errors.New("OAuth members are stored in active sessions")
}
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
return map[string]types.MemberProfile{}, nil
}
func (provider *MemberProviderCtx) Delete(id string) error {
return errors.New("OAuth members are managed by the identity provider")
}
const stateLifetime = 10 * time.Minute
type state struct {
verifier, redirectURL string
expires time.Time
}
// Service owns the OAuth authorization-code flow and creates sessions for the
// OAuth member provider. HTTP packages only translate requests and responses.
type Service struct {
config Config
sessions types.SessionManager
client *http.Client
mu sync.Mutex
issuerResolved bool
states map[string]state
extra map[string]map[string]any
}
func NewService(config Config, sessions types.SessionManager) *Service {
service := &Service{
config: config,
sessions: sessions,
client: &http.Client{Timeout: 15 * time.Second},
states: make(map[string]state),
extra: make(map[string]map[string]any),
}
sessions.OnDeleted(func(session types.Session) {
service.DeleteExtraData(session.ID())
})
return service
}
func (service *Service) Enabled() bool { return service.config.Enabled }
func (service *Service) Name() string {
if name := strings.TrimSpace(service.config.Name); name != "" {
return name
}
return "OAuth"
}
func (service *Service) SuccessRedirect() string { return service.config.SuccessRedirect }
func (service *Service) Start(ctx context.Context, callbackURL string) (string, error) {
config, err := service.resolveConfig(ctx)
if err != nil {
return "", fmt.Errorf("issuer discovery: %w", err)
}
if !configured(config) {
return "", errors.New("OAuth is not fully configured")
}
if config.RedirectURL != "" {
callbackURL = config.RedirectURL
}
if callbackURL == "" {
return "", errors.New("OAuth callback URL is not configured")
}
stateToken, err := token(32)
if err != nil {
return "", err
}
verifier, err := token(64)
if err != nil {
return "", err
}
service.storeState(stateToken, verifier, callbackURL)
authorizationURL, err := url.Parse(config.AuthorizationURL)
if err != nil {
return "", err
}
query := authorizationURL.Query()
query.Set("response_type", "code")
query.Set("client_id", config.ClientID)
query.Set("redirect_uri", callbackURL)
query.Set("scope", strings.Join(config.Scopes, " "))
query.Set("state", stateToken)
query.Set("code_challenge", challenge(verifier))
query.Set("code_challenge_method", "S256")
authorizationURL.RawQuery = query.Encode()
return authorizationURL.String(), nil
}
func (service *Service) Complete(ctx context.Context, stateToken, code string) (types.Session, string, error) {
if !service.config.Enabled {
return nil, "", types.ErrMemberInvalidPassword
}
if stateToken == "" || code == "" {
return nil, "", errors.New("OAuth callback is missing state or code")
}
stored, ok := service.popState(stateToken)
if !ok {
return nil, "", errors.New("OAuth state is invalid or expired")
}
config, err := service.resolveConfig(ctx)
if err != nil {
return nil, "", fmt.Errorf("issuer discovery: %w", err)
}
if !configured(config) {
return nil, "", errors.New("OAuth is not fully configured")
}
accessToken, idClaims, err := service.exchangeCode(ctx, config, code, stored.verifier, stored.redirectURL)
if err != nil {
return nil, "", fmt.Errorf("token exchange: %w", err)
}
claims, err := service.userInfo(ctx, config, accessToken)
if err != nil {
return nil, "", fmt.Errorf("user-info: %w", err)
}
subject := claim(claims, config.SubjectField)
if subject == "" {
return nil, "", errors.New("OAuth user-info response is missing the subject field")
}
if idSubject := claim(idClaims, config.SubjectField); idSubject != "" && idSubject != subject {
return nil, "", errors.New("OAuth ID token subject does not match user-info response")
}
name := claim(claims, config.UsernameField)
if name == "" {
name = claim(idClaims, config.UsernameField)
}
if name == "" {
name = subject
}
avatar := claim(claims, config.AvatarField)
if avatar == "" {
avatar = claim(idClaims, config.AvatarField)
}
email := claim(claims, "email")
if email == "" {
email = claim(idClaims, "email")
}
profile := config.UserProfile
if administrator(email, boolClaim(claims, "isAdmin") || boolClaim(idClaims, "isAdmin"), config.AdminEmails) {
profile = config.AdminProfile
}
profile.Name = name
profile.Avatar = avatar
if !profile.IsAdmin && service.sessions.Settings().LockedLogins {
return nil, "", types.ErrSessionLoginsLocked
}
id := "oauth:" + subject
if session, ok := service.sessions.Get(id); ok {
if session.State().IsConnected {
return nil, "", types.ErrSessionAlreadyConnected
}
if err := service.sessions.Delete(id); err != nil {
return nil, "", err
}
}
session, sessionToken, err := service.sessions.Create(id, profile)
if err == nil {
service.mu.Lock()
service.extra[id] = mergeClaims(claims, idClaims)
service.mu.Unlock()
}
return session, sessionToken, err
}
func (service *Service) ExtraData(id string) map[string]any {
service.mu.Lock()
defer service.mu.Unlock()
return cloneClaims(service.extra[id])
}
func (service *Service) DeleteExtraData(id string) {
service.mu.Lock()
defer service.mu.Unlock()
delete(service.extra, id)
}
type discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
}
func (service *Service) resolveConfig(ctx context.Context) (Config, error) {
service.mu.Lock()
defer service.mu.Unlock()
if service.config.IssuerURL == "" || service.issuerResolved {
return service.config, nil
}
issuer, err := url.Parse(service.config.IssuerURL)
if err != nil || !absoluteURL(service.config.IssuerURL) {
return Config{}, errors.New("OAuth issuer URL must be an absolute HTTP(S) URL")
}
issuer.Path = strings.TrimSuffix(issuer.Path, "/") + "/.well-known/openid-configuration"
issuer.RawQuery = ""
issuer.Fragment = ""
req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer.String(), nil)
if err != nil {
return Config{}, err
}
req.Header.Set("Accept", "application/json")
response, err := service.client.Do(req)
if err != nil {
return Config{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return Config{}, fmt.Errorf("issuer discovery endpoint returned %s", response.Status)
}
var metadata discovery
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
return Config{}, err
}
if metadata.Issuer != service.config.IssuerURL {
return Config{}, errors.New("issuer discovery response issuer does not match configured issuer")
}
for field, value := range map[string]string{"authorization_endpoint": metadata.AuthorizationEndpoint, "token_endpoint": metadata.TokenEndpoint, "userinfo_endpoint": metadata.UserInfoEndpoint} {
if !absoluteURL(value) {
return Config{}, fmt.Errorf("issuer discovery response has invalid %s", field)
}
}
service.config.AuthorizationURL = metadata.AuthorizationEndpoint
service.config.TokenURL = metadata.TokenEndpoint
service.config.UserInfoURL = metadata.UserInfoEndpoint
service.issuerResolved = true
return service.config, nil
}
func configured(config Config) bool {
return config.Enabled && config.ClientID != "" && config.ClientSecret != "" && config.AuthorizationURL != "" && config.TokenURL != "" && config.UserInfoURL != ""
}
func absoluteURL(value string) bool {
parsed, err := url.Parse(value)
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}
func (service *Service) storeState(value, verifier, redirectURL string) {
service.mu.Lock()
defer service.mu.Unlock()
now := time.Now()
for key, value := range service.states {
if now.After(value.expires) {
delete(service.states, key)
}
}
service.states[value] = state{verifier, redirectURL, now.Add(stateLifetime)}
}
func (service *Service) popState(value string) (state, bool) {
service.mu.Lock()
defer service.mu.Unlock()
result, ok := service.states[value]
delete(service.states, value)
return result, ok && time.Now().Before(result.expires)
}
func (service *Service) exchangeCode(ctx context.Context, config Config, code, verifier, redirectURL string) (string, map[string]any, error) {
values := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURL}, "client_id": {config.ClientID}, "client_secret": {config.ClientSecret}, "code_verifier": {verifier}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(values.Encode()))
if err != nil {
return "", nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
response, err := service.client.Do(req)
if err != nil {
return "", nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", nil, fmt.Errorf("token endpoint returned %s", response.Status)
}
var payload struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&payload); err != nil {
return "", nil, err
}
if payload.AccessToken == "" {
return "", nil, errors.New("token response has no access_token")
}
return payload.AccessToken, idTokenClaims(payload.IDToken), nil
}
func (service *Service) userInfo(ctx context.Context, config Config, accessToken string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, config.UserInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
response, err := service.client.Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("user-info endpoint returned %s", response.Status)
}
claims := map[string]any{}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&claims); err != nil {
return nil, err
}
return claims, nil
}
func token(bytes int) (string, error) {
buffer := make([]byte, bytes)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func challenge(verifier string) string {
digest := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(digest[:])
}
func idTokenClaims(value string) map[string]any {
parts := strings.Split(value, ".")
if len(parts) != 3 {
return nil
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil
}
claims := map[string]any{}
if json.Unmarshal(payload, &claims) != nil {
return nil
}
return claims
}
func claim(claims map[string]any, field string) string {
if value, ok := claims[field]; ok {
return fmt.Sprint(value)
}
return ""
}
func boolClaim(claims map[string]any, field string) bool {
value, ok := claims[field]
if !ok {
return false
}
switch value := value.(type) {
case bool:
return value
case string:
return strings.EqualFold(value, "true")
case float64:
return value != 0
}
return false
}
func administrator(email string, isAdmin bool, emails []string) bool {
if isAdmin {
return true
}
for _, candidate := range emails {
if email != "" && strings.EqualFold(strings.TrimSpace(email), strings.TrimSpace(candidate)) {
return true
}
}
return false
}
func mergeClaims(userInfo, idToken map[string]any) map[string]any {
result := make(map[string]any, len(userInfo)+len(idToken))
for key, value := range idToken {
result[key] = value
}
for key, value := range userInfo {
result[key] = value
}
return result
}
func cloneClaims(claims map[string]any) map[string]any {
if len(claims) == 0 {
return nil
}
result := make(map[string]any, len(claims))
for key, value := range claims {
result[key] = value
}
return result
}
+10 -1
View File
@@ -14,6 +14,7 @@ func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token st
if manager.config.Cookie.Secure {
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"`