diff --git a/server/internal/api/oauth.go b/server/internal/api/oauth.go index b90818a5..2764ed6a 100644 --- a/server/internal/api/oauth.go +++ b/server/internal/api/oauth.go @@ -1,39 +1,17 @@ package api import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" "errors" - "fmt" - "io" "net/http" "net/url" "path" "strings" - "sync" - "time" - "github.com/m1k1o/neko/server/internal/config" + "github.com/m1k1o/neko/server/internal/member/oauth" "github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/utils" ) -const oauthStateLifetime = 10 * time.Minute - -type oauthState struct { - verifier string - redirectURL string - expires time.Time -} - -type oauthToken struct { - accessToken string - idTokenClaims map[string]any -} - type OAuthUIConfig struct { Enabled bool `json:"enabled"` Name string `json:"name"` @@ -41,126 +19,86 @@ type OAuthUIConfig struct { 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 { - config config.OAuth - client *http.Client - pathPrefix string - trustProxy bool - providerOAuth bool - configMu sync.Mutex - issuerResolved bool - statesMu sync.Mutex - states map[string]oauthState + service *oauth.Service + pathPrefix string + trustProxy bool + providerOAuth bool } -func newOAuthHandler(config config.OAuth, pathPrefix string, trustProxy bool, providerOAuth bool) *oauthHandler { +func newOAuthHandler(service *oauth.Service, pathPrefix string, trustProxy, providerOAuth bool) *oauthHandler { if pathPrefix == "" { pathPrefix = "/" } - return &oauthHandler{ - config: config, - client: &http.Client{Timeout: 15 * time.Second}, - pathPrefix: pathPrefix, - trustProxy: trustProxy, - providerOAuth: providerOAuth, - states: make(map[string]oauthState), - } -} - -func (handler *oauthHandler) configured(config config.OAuth) bool { - return handler.providerOAuth && config.Enabled && config.ClientID != "" && config.ClientSecret != "" && - config.AuthorizationURL != "" && config.TokenURL != "" && config.UserInfoURL != "" + return &oauthHandler{service: service, pathPrefix: pathPrefix, trustProxy: trustProxy, providerOAuth: providerOAuth} } func (api *ApiManagerCtx) OAuthConfig(w http.ResponseWriter, r *http.Request) error { - config := api.oauth.config - name := strings.TrimSpace(config.Name) - if name == "" { - name = "OAuth" - } - return utils.HttpSuccess(w, OAuthUIConfig{ - Enabled: api.oauth.providerOAuth && config.Enabled, - Name: name, + 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, }) } -type openIDConfiguration struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - UserInfoEndpoint string `json:"userinfo_endpoint"` +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 } -// resolveConfig uses OpenID Connect discovery when an issuer is configured. -// Issuer metadata deliberately takes precedence over explicitly configured endpoints. -func (handler *oauthHandler) resolveConfig(ctx context.Context) (config.OAuth, error) { - handler.configMu.Lock() - defer handler.configMu.Unlock() - - if handler.config.IssuerURL == "" || handler.issuerResolved { - return handler.config, nil +func (api *ApiManagerCtx) OAuthCallback(w http.ResponseWriter, r *http.Request) error { + if !api.oauth.providerOAuth || !api.oauth.service.Enabled() { + return utils.HttpNotFound() } - - discoveryURL, err := openIDConfigurationURL(handler.config.IssuerURL) + 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 config.OAuth{}, err + return oauthServiceError(err, false) } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) - if err != nil { - return config.OAuth{}, err - } - req.Header.Set("Accept", "application/json") - response, err := handler.client.Do(req) - if err != nil { - return config.OAuth{}, err - } - defer response.Body.Close() - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return config.OAuth{}, fmt.Errorf("issuer discovery endpoint returned %s", response.Status) - } - - var metadata openIDConfiguration - if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil { - return config.OAuth{}, err - } - if metadata.Issuer != handler.config.IssuerURL { - return config.OAuth{}, fmt.Errorf("issuer discovery response issuer does not match configured issuer") - } - for field, endpoint := range map[string]string{ - "authorization_endpoint": metadata.AuthorizationEndpoint, - "token_endpoint": metadata.TokenEndpoint, - "userinfo_endpoint": metadata.UserInfoEndpoint, - } { - if !isAbsoluteHTTPURL(endpoint) { - return config.OAuth{}, fmt.Errorf("issuer discovery response has invalid %s", field) - } - } - - handler.config.AuthorizationURL = metadata.AuthorizationEndpoint - handler.config.TokenURL = metadata.TokenEndpoint - handler.config.UserInfoURL = metadata.UserInfoEndpoint - handler.issuerResolved = true - return handler.config, nil + api.sessions.CookieSetToken(w, token) + http.Redirect(w, r, api.oauth.successRedirect(), http.StatusSeeOther) + return nil } -func openIDConfigurationURL(issuerURL string) (string, error) { - issuer, err := url.Parse(issuerURL) - if err != nil || !isAbsoluteHTTPURL(issuerURL) { - return "", errors.New("OAuth issuer URL must be an absolute HTTP(S) URL") +func oauthServiceError(err error, start bool) error { + if errors.Is(err, types.ErrSessionAlreadyConnected) { + return utils.HttpUnprocessableEntity("session already connected") } - issuer.Path = strings.TrimSuffix(issuer.Path, "/") + "/.well-known/openid-configuration" - issuer.RawQuery = "" - issuer.Fragment = "" - return issuer.String(), nil -} - -func isAbsoluteHTTPURL(value string) bool { - endpoint, err := url.Parse(value) - return err == nil && endpoint.Host != "" && (endpoint.Scheme == "http" || endpoint.Scheme == "https") + 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) { @@ -170,320 +108,29 @@ func (handler *oauthHandler) callbackURL(r *http.Request) (string, error) { } host := r.Host if handler.trustProxy { - if forwardedProto := forwardedHeaderValue(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" { - scheme = forwardedProto + if value := forwardedHeaderValue(r.Header.Get("X-Forwarded-Proto")); value != "" { + scheme = value } - if forwardedHost := forwardedHeaderValue(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" { - host = forwardedHost + 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"), - } - if !isAbsoluteHTTPURL(callback.String()) { + 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.String(), nil + return callback, nil } - -func forwardedHeaderValue(value string) string { - if value == "" { - return "" - } - return strings.TrimSpace(strings.Split(value, ",")[0]) -} - -func (api *ApiManagerCtx) OAuthLogin(w http.ResponseWriter, r *http.Request) error { - if !api.oauth.providerOAuth || !api.oauth.config.Enabled { - return utils.HttpNotFound() - } - config, err := api.oauth.resolveConfig(r.Context()) - if err != nil { - return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err) - } - if !api.oauth.configured(config) { - return utils.HttpError(http.StatusServiceUnavailable, "OAuth is not fully configured") - } - if !api.sessions.CookieEnabled() { - return utils.HttpError(http.StatusServiceUnavailable, "OAuth requires session cookies to be enabled") - } - - state, err := newOAuthToken(32) - if err != nil { - return utils.HttpInternalServerError().WithInternalErr(err) - } - verifier, err := newOAuthToken(64) - if err != nil { - return utils.HttpInternalServerError().WithInternalErr(err) - } - - redirectURL := config.RedirectURL - if redirectURL == "" { - redirectURL, err = api.oauth.callbackURL(r) - if err != nil { - return utils.HttpInternalServerError("unable to determine OAuth callback URL").WithInternalErr(err) - } - } - - api.oauth.storeState(state, verifier, redirectURL) - - authorizationURL, err := url.Parse(config.AuthorizationURL) - if err != nil { - return utils.HttpInternalServerError("invalid OAuth authorization URL").WithInternalErr(err) - } - query := authorizationURL.Query() - query.Set("response_type", "code") - query.Set("client_id", config.ClientID) - query.Set("redirect_uri", redirectURL) - query.Set("scope", strings.Join(config.Scopes, " ")) - query.Set("state", state) - query.Set("code_challenge", oauthChallenge(verifier)) - query.Set("code_challenge_method", "S256") - authorizationURL.RawQuery = query.Encode() - - http.Redirect(w, r, authorizationURL.String(), http.StatusFound) - return nil -} - -func (api *ApiManagerCtx) OAuthCallback(w http.ResponseWriter, r *http.Request) error { - if !api.oauth.providerOAuth || !api.oauth.config.Enabled { - return utils.HttpNotFound() - } - config, err := api.oauth.resolveConfig(r.Context()) - if err != nil { - return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err) - } - if !api.oauth.configured(config) { - return utils.HttpError(http.StatusServiceUnavailable, "OAuth is not fully configured") - } - if providerError := r.URL.Query().Get("error"); providerError != "" { - return utils.HttpUnauthorized("OAuth authorization was declined").WithInternalMsg(providerError) - } - - state := r.URL.Query().Get("state") - code := r.URL.Query().Get("code") - if state == "" || code == "" { - return utils.HttpBadRequest("OAuth callback is missing state or code") - } - oauthState, ok := api.oauth.popState(state) - if !ok { - return utils.HttpBadRequest("OAuth state is invalid or expired") - } - - oauthToken, err := api.oauth.exchangeCode(r, config, code, oauthState.verifier, oauthState.redirectURL) - if err != nil { - return utils.HttpUnauthorized("OAuth token exchange failed").WithInternalErr(err) - } - claims, err := api.oauth.userInfo(r, config, oauthToken.accessToken) - if err != nil { - return utils.HttpUnauthorized("OAuth user-info request failed").WithInternalErr(err) - } - - subject := oauthClaim(claims, config.SubjectField) - if subject == "" { - return utils.HttpUnauthorized("OAuth user-info response is missing the subject field") - } - if idTokenSubject := oauthClaim(oauthToken.idTokenClaims, config.SubjectField); idTokenSubject != "" && idTokenSubject != subject { - return utils.HttpUnauthorized("OAuth ID token subject does not match user-info response") - } - name := oauthClaim(claims, config.UsernameField) - if name == "" { - name = oauthClaim(oauthToken.idTokenClaims, config.UsernameField) - } - if name == "" { - name = subject - } - avatar := oauthClaim(claims, config.AvatarField) - if avatar == "" { - avatar = oauthClaim(oauthToken.idTokenClaims, config.AvatarField) - } - - email := oauthClaim(claims, "email") - if email == "" { - email = oauthClaim(oauthToken.idTokenClaims, "email") - } - isAdmin := oauthBoolClaim(claims, "isAdmin") || oauthBoolClaim(oauthToken.idTokenClaims, "isAdmin") - _, token, err := api.members.LoginOAuth(subject, name, avatar, email, isAdmin, mergeOAuthClaims(claims, oauthToken.idTokenClaims)) - if err != nil { - 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) - } - return utils.HttpInternalServerError().WithInternalErr(err) - } - - api.sessions.CookieSetToken(w, token) - http.Redirect(w, r, api.oauth.successRedirect(), http.StatusSeeOther) - return nil -} - -func (handler *oauthHandler) storeState(state, verifier, redirectURL string) { - handler.statesMu.Lock() - defer handler.statesMu.Unlock() - - now := time.Now() - for key, value := range handler.states { - if now.After(value.expires) { - delete(handler.states, key) - } - } - handler.states[state] = oauthState{verifier: verifier, redirectURL: redirectURL, expires: now.Add(oauthStateLifetime)} -} - -func (handler *oauthHandler) popState(state string) (oauthState, bool) { - handler.statesMu.Lock() - defer handler.statesMu.Unlock() - - value, ok := handler.states[state] - delete(handler.states, state) - return value, ok && time.Now().Before(value.expires) -} - -func (handler *oauthHandler) exchangeCode(r *http.Request, config config.OAuth, code, verifier, redirectURL string) (oauthToken, 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(r.Context(), http.MethodPost, config.TokenURL, strings.NewReader(values.Encode())) - if err != nil { - return oauthToken{}, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - - response, err := handler.client.Do(req) - if err != nil { - return oauthToken{}, err - } - defer response.Body.Close() - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return oauthToken{}, 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 oauthToken{}, err - } - if payload.AccessToken == "" { - return oauthToken{}, errors.New("token response has no access_token") - } - - return oauthToken{ - accessToken: payload.AccessToken, - idTokenClaims: idTokenClaims(payload.IDToken), - }, nil -} - -// idTokenClaims is used only as a profile fallback when userinfo omits optional -// OIDC claims. The userinfo response remains the source of the session subject. -func idTokenClaims(idToken string) map[string]any { - parts := strings.Split(idToken, ".") - if len(parts) != 3 { - return nil - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil - } - claims := map[string]any{} - if err := json.Unmarshal(payload, &claims); err != nil { - return nil - } - return claims -} - -func (handler *oauthHandler) userInfo(r *http.Request, config config.OAuth, accessToken string) (map[string]any, error) { - req, err := http.NewRequestWithContext(r.Context(), 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 := handler.client.Do(req) - if err != nil { - return nil, err - } - defer response.Body.Close() - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - 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 (handler *oauthHandler) successRedirect() string { - redirect := handler.config.SuccessRedirect + redirect := handler.service.SuccessRedirect() if strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") { return path.Join(handler.pathPrefix, redirect) } return handler.pathPrefix } - -func newOAuthToken(bytes int) (string, error) { - buffer := make([]byte, bytes) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buffer), nil -} - -func oauthChallenge(verifier string) string { - digest := sha256.Sum256([]byte(verifier)) - return base64.RawURLEncoding.EncodeToString(digest[:]) -} - -func oauthClaim(claims map[string]any, field string) string { - value, ok := claims[field] - if !ok { - return "" - } - return fmt.Sprint(value) -} - -func oauthBoolClaim(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 - default: - return false - } -} - -func mergeOAuthClaims(userInfo, idToken map[string]any) map[string]any { - claims := make(map[string]any, len(userInfo)+len(idToken)) - for key, value := range idToken { - claims[key] = value - } - for key, value := range userInfo { - claims[key] = value - } - return claims +func forwardedHeaderValue(value string) string { + return strings.TrimSpace(strings.Split(value, ",")[0]) } diff --git a/server/internal/api/oauth_test.go b/server/internal/api/oauth_test.go index 2e15a0e1..2524fddc 100644 --- a/server/internal/api/oauth_test.go +++ b/server/internal/api/oauth_test.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -49,7 +50,7 @@ func TestOAuthLoginSynchronizesProfile(t *testing.T) { memberConfig := &config.Member{ Provider: "oauth", - OAuth: config.OAuth{ + OAuth: oauth.Config{ Enabled: true, ClientID: "client-id", ClientSecret: "client-secret", @@ -122,7 +123,7 @@ func TestOAuthLoginSynchronizesProfile(t *testing.T) { if profile.Name != "Ada Lovelace" || profile.Avatar != "https://example.test/ada.png" || !profile.IsAdmin || profile.CanAccessClipboard { t.Fatalf("profile = %#v", profile) } - if extraData := members.OAuthExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" { + if extraData := api.oauth.service.ExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" { t.Fatalf("extra data = %#v", extraData) } } @@ -166,7 +167,7 @@ func TestOAuthIssuerDiscoveryTakesPrecedence(t *testing.T) { issuerURL = issuer.URL memberConfig := &config.Member{ Provider: "oauth", - OAuth: config.OAuth{ + OAuth: oauth.Config{ Enabled: true, ClientID: "client-id", ClientSecret: "client-secret", @@ -224,7 +225,7 @@ func TestOAuthIssuerDiscoveryTakesPrecedence(t *testing.T) { func TestOAuthConfigUsesProviderAndName(t *testing.T) { memberConfig := &config.Member{ Provider: "oauth", - OAuth: config.OAuth{ + OAuth: oauth.Config{ Enabled: true, Name: "Team SSO", }, diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 7ba1e1f2..10520dfa 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -9,6 +9,7 @@ import ( "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" @@ -42,7 +43,7 @@ func New( desktop: desktop, capture: capture, routers: make(map[string]func(types.Router)), - oauth: newOAuthHandler(memberConfig.OAuth, pathPrefix, serverConfig != nil && serverConfig.Proxy, memberConfig.Provider == "oauth"), + oauth: newOAuthHandler(oauth.NewService(memberConfig.OAuth, sessions), pathPrefix, serverConfig != nil && serverConfig.Proxy, memberConfig.Provider == "oauth"), } } diff --git a/server/internal/api/session.go b/server/internal/api/session.go index 2102c4c1..6c5a7d24 100644 --- a/server/internal/api/session.go +++ b/server/internal/api/session.go @@ -67,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) } @@ -82,7 +81,7 @@ func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error { ID: session.ID(), Profile: session.Profile(), State: session.State(), - ExtraData: api.members.OAuthExtraData(session.ID()), + ExtraData: api.oauth.service.ExtraData(session.ID()), }) } diff --git a/server/internal/config/member.go b/server/internal/config/member.go index 21f0a72d..ae61b4d9 100644 --- a/server/internal/config/member.go +++ b/server/internal/config/member.go @@ -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,30 +20,7 @@ type Member struct { File file.Config Object object.Config Multiuser multiuser.Config - OAuth OAuth -} - -// OAuth contains the generic OAuth 2.0 authorization-code flow settings. -// The provider's user-info response is mapped to a Neko member profile. -type OAuth 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 + OAuth oauth.Config } func (Member) Init(cmd *cobra.Command) error { @@ -89,94 +67,7 @@ func (Member) Init(cmd *cobra.Command) error { return err } - // OAuth 2.0 provider - cmd.PersistentFlags().Bool("member.oauth.enabled", false, "enable OAuth 2.0 login") - if err := viper.BindPFlag("member.oauth.enabled", cmd.PersistentFlags().Lookup("member.oauth.enabled")); err != nil { - return err - } - - cmd.PersistentFlags().Bool("member.oauth.auto_redirect", false, "redirect the Neko root page to OAuth login automatically") - if err := viper.BindPFlag("member.oauth.auto_redirect", cmd.PersistentFlags().Lookup("member.oauth.auto_redirect")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.name", "OAuth", "display name for the OAuth login option") - if err := viper.BindPFlag("member.oauth.name", cmd.PersistentFlags().Lookup("member.oauth.name")); err != nil { - return err - } - - cmd.PersistentFlags().StringSlice("member.oauth.admin_emails", []string{}, "OAuth user-info email addresses granted administrator access") - if err := viper.BindPFlag("member.oauth.admin_emails", cmd.PersistentFlags().Lookup("member.oauth.admin_emails")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.client_id", "", "OAuth 2.0 client ID") - if err := viper.BindPFlag("member.oauth.client_id", cmd.PersistentFlags().Lookup("member.oauth.client_id")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.client_secret", "", "OAuth 2.0 client secret") - if err := viper.BindPFlag("member.oauth.client_secret", cmd.PersistentFlags().Lookup("member.oauth.client_secret")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.issuer_url", "", "OpenID Connect issuer URL used to discover OAuth endpoints") - if err := viper.BindPFlag("member.oauth.issuer_url", cmd.PersistentFlags().Lookup("member.oauth.issuer_url")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.authorization_url", "", "OAuth 2.0 authorization endpoint") - if err := viper.BindPFlag("member.oauth.authorization_url", cmd.PersistentFlags().Lookup("member.oauth.authorization_url")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.token_url", "", "OAuth 2.0 token endpoint") - if err := viper.BindPFlag("member.oauth.token_url", cmd.PersistentFlags().Lookup("member.oauth.token_url")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.userinfo_url", "", "OAuth 2.0 user-info endpoint") - if err := viper.BindPFlag("member.oauth.userinfo_url", cmd.PersistentFlags().Lookup("member.oauth.userinfo_url")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.redirect_url", "", "OAuth 2.0 callback URL registered with the provider") - if err := viper.BindPFlag("member.oauth.redirect_url", cmd.PersistentFlags().Lookup("member.oauth.redirect_url")); err != nil { - return err - } - - cmd.PersistentFlags().StringSlice("member.oauth.scopes", []string{"openid", "profile"}, "OAuth 2.0 scopes") - if err := viper.BindPFlag("member.oauth.scopes", cmd.PersistentFlags().Lookup("member.oauth.scopes")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.subject_field", "sub", "field in user-info response used as the stable user identifier") - if err := viper.BindPFlag("member.oauth.subject_field", cmd.PersistentFlags().Lookup("member.oauth.subject_field")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.username_field", "name", "field in user-info response used as the display name") - if err := viper.BindPFlag("member.oauth.username_field", cmd.PersistentFlags().Lookup("member.oauth.username_field")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.avatar_field", "picture", "field in user-info response used as the avatar URL") - if err := viper.BindPFlag("member.oauth.avatar_field", cmd.PersistentFlags().Lookup("member.oauth.avatar_field")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.success_redirect", "/", "relative URL to redirect to after OAuth login") - if err := viper.BindPFlag("member.oauth.success_redirect", cmd.PersistentFlags().Lookup("member.oauth.success_redirect")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.user_profile", "{}", "OAuth user permission profile") - if err := viper.BindPFlag("member.oauth.user_profile", cmd.PersistentFlags().Lookup("member.oauth.user_profile")); err != nil { - return err - } - - cmd.PersistentFlags().String("member.oauth.admin_profile", "{}", "OAuth administrator permission profile") - if err := viper.BindPFlag("member.oauth.admin_profile", cmd.PersistentFlags().Lookup("member.oauth.admin_profile")); err != nil { + if err := oauth.Init(cmd); err != nil { return err } @@ -215,24 +106,6 @@ func (s *Member) Set() { s.Multiuser.UserPassword = viper.GetString("member.multiuser.user_password") s.Multiuser.AdminPassword = viper.GetString("member.multiuser.admin_password") - // OAuth provider - s.OAuth.Enabled = viper.GetBool("member.oauth.enabled") - s.OAuth.AutoRedirect = viper.GetBool("member.oauth.auto_redirect") - s.OAuth.Name = viper.GetString("member.oauth.name") - s.OAuth.AdminEmails = viper.GetStringSlice("member.oauth.admin_emails") - s.OAuth.ClientID = viper.GetString("member.oauth.client_id") - s.OAuth.ClientSecret = viper.GetString("member.oauth.client_secret") - s.OAuth.IssuerURL = viper.GetString("member.oauth.issuer_url") - s.OAuth.AuthorizationURL = viper.GetString("member.oauth.authorization_url") - s.OAuth.TokenURL = viper.GetString("member.oauth.token_url") - s.OAuth.UserInfoURL = viper.GetString("member.oauth.userinfo_url") - s.OAuth.RedirectURL = viper.GetString("member.oauth.redirect_url") - s.OAuth.Scopes = viper.GetStringSlice("member.oauth.scopes") - s.OAuth.SubjectField = viper.GetString("member.oauth.subject_field") - s.OAuth.UsernameField = viper.GetString("member.oauth.username_field") - s.OAuth.AvatarField = viper.GetString("member.oauth.avatar_field") - s.OAuth.SuccessRedirect = viper.GetString("member.oauth.success_redirect") - // default user profile s.Multiuser.UserProfile = types.MemberProfile{ IsAdmin: false, @@ -245,7 +118,6 @@ func (s *Member) Set() { SendsInactiveCursor: true, CanSeeInactiveCursors: false, } - s.OAuth.UserProfile = s.Multiuser.UserProfile // override user profile if err := viper.UnmarshalKey("member.multiuser.user_profile", &s.Multiuser.UserProfile, viper.DecodeHook( @@ -253,11 +125,6 @@ func (s *Member) Set() { )); err != nil { log.Warn().Err(err).Msgf("unable to parse member multiuser user profile") } - if err := viper.UnmarshalKey("member.oauth.user_profile", &s.OAuth.UserProfile, viper.DecodeHook( - utils.JsonStringAutoDecode(s.OAuth.UserProfile), - )); err != nil { - log.Warn().Err(err).Msgf("unable to parse member OAuth user profile") - } // default admin profile s.Multiuser.AdminProfile = types.MemberProfile{ @@ -278,11 +145,8 @@ func (s *Member) Set() { )); err != nil { log.Warn().Err(err).Msgf("unable to parse member multiuser admin profile") } - s.OAuth.AdminProfile = s.Multiuser.AdminProfile - if err := viper.UnmarshalKey("member.oauth.admin_profile", &s.OAuth.AdminProfile, viper.DecodeHook( - utils.JsonStringAutoDecode(s.OAuth.AdminProfile), - )); err != nil { - log.Warn().Err(err).Msgf("unable to parse member OAuth 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") } } diff --git a/server/internal/http/manager_test.go b/server/internal/http/manager_test.go index 65920785..720698e6 100644 --- a/server/internal/http/manager_test.go +++ b/server/internal/http/manager_test.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -35,7 +36,7 @@ func TestOAuthAutoRedirectSkipsAuthenticatedSessions(t *testing.T) { t.Fatal(err) } - memberConfig := &config.Member{Provider: "oauth", OAuth: config.OAuth{Enabled: true, AutoRedirect: true}} + 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}, }) diff --git a/server/internal/member/manager.go b/server/internal/member/manager.go index c40c2086..676abb32 100644 --- a/server/internal/member/manager.go +++ b/server/internal/member/manager.go @@ -2,7 +2,6 @@ package member import ( "errors" - "strings" "sync" "github.com/rs/zerolog" @@ -12,17 +11,16 @@ 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" - oauthprovider "github.com/m1k1o/neko/server/internal/member/oauth" + "github.com/m1k1o/neko/server/internal/member/oauth" "github.com/m1k1o/neko/server/internal/member/object" "github.com/m1k1o/neko/server/pkg/types" ) func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx { manager := &MemberManagerCtx{ - logger: log.With().Str("module", "member").Logger(), - sessions: sessions, - config: config, - oauthExtra: make(map[string]map[string]any), + logger: log.With().Str("module", "member").Logger(), + sessions: sessions, + config: config, } switch config.Provider { @@ -33,7 +31,7 @@ func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx case "multiuser": manager.provider = multiuser.New(config.Multiuser) case "oauth": - manager.provider = oauthprovider.New() + manager.provider = oauth.New() case "noauth": fallthrough default: @@ -50,7 +48,6 @@ type MemberManagerCtx struct { providerMu sync.Mutex provider types.MemberProvider loginMu sync.Mutex - oauthExtra map[string]map[string]any } func (manager *MemberManagerCtx) Connect() error { @@ -124,11 +121,6 @@ func (manager *MemberManagerCtx) UpdatePassword(id string, password string) erro func (manager *MemberManagerCtx) Delete(id string) error { manager.providerMu.Lock() defer manager.providerMu.Unlock() - defer func() { - manager.loginMu.Lock() - defer manager.loginMu.Unlock() - delete(manager.oauthExtra, id) - }() // destroy corresponding session, if exists err := manager.sessions.Delete(id) @@ -171,88 +163,10 @@ func (manager *MemberManagerCtx) Login(username string, password string) (types. return manager.sessions.Create(id, profile) } -// LoginOAuth creates a stable session identity from an OAuth provider subject. -// The profile data is refreshed on every successful OAuth login. -func (manager *MemberManagerCtx) LoginOAuth(subject, name, avatar, email string, isAdmin bool, extraData map[string]any) (types.Session, string, error) { - manager.loginMu.Lock() - defer manager.loginMu.Unlock() - - if subject == "" { - return nil, "", errors.New("OAuth subject is empty") - } - - profile := manager.config.OAuth.UserProfile - if isOAuthAdministrator(email, isAdmin, manager.config.OAuth.AdminEmails) { - profile = manager.config.OAuth.AdminProfile - } - if name != "" { - profile.Name = name - } - profile.Avatar = avatar - - if !profile.IsAdmin && manager.sessions.Settings().LockedLogins { - return nil, "", types.ErrSessionLoginsLocked - } - - id := "oauth:" + subject - if session, ok := manager.sessions.Get(id); ok { - if session.State().IsConnected { - return nil, "", types.ErrSessionAlreadyConnected - } - - if err := manager.sessions.Delete(id); err != nil { - return nil, "", err - } - } - - session, token, err := manager.sessions.Create(id, profile) - if err == nil { - manager.oauthExtra[id] = cloneOAuthExtraData(extraData) - } - return session, token, err -} - -func isOAuthAdministrator(email string, isAdmin bool, adminEmails []string) bool { - if isAdmin { - return true - } - email = strings.TrimSpace(email) - if email == "" { - return false - } - for _, adminEmail := range adminEmails { - if strings.EqualFold(email, strings.TrimSpace(adminEmail)) { - return true - } - } - return false -} - -func (manager *MemberManagerCtx) OAuthExtraData(id string) map[string]any { - manager.loginMu.Lock() - defer manager.loginMu.Unlock() - - return cloneOAuthExtraData(manager.oauthExtra[id]) -} - -func cloneOAuthExtraData(extraData map[string]any) map[string]any { - if len(extraData) == 0 { - return nil - } - copy := make(map[string]any, len(extraData)) - for key, value := range extraData { - copy[key] = value - } - return copy -} - func (manager *MemberManagerCtx) Logout(id string) error { manager.loginMu.Lock() defer manager.loginMu.Unlock() err := manager.sessions.Delete(id) - if err == nil || errors.Is(err, types.ErrSessionNotFound) { - delete(manager.oauthExtra, id) - } return err } diff --git a/server/internal/member/oauth/config.go b/server/internal/member/oauth/config.go new file mode 100644 index 00000000..99208484 --- /dev/null +++ b/server/internal/member/oauth/config.go @@ -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))) +} diff --git a/server/internal/member/oauth/provider.go b/server/internal/member/oauth/provider.go index 4aa06d24..6f1031f3 100644 --- a/server/internal/member/oauth/provider.go +++ b/server/internal/member/oauth/provider.go @@ -1,13 +1,25 @@ 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 MemberManagerCtx.LoginOAuth after the OAuth callback succeeds. +// created by Service after the OAuth callback succeeds. type MemberProviderCtx struct{} func New() types.MemberProvider { return &MemberProviderCtx{} } @@ -43,3 +55,374 @@ func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string] 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 +} diff --git a/server/pkg/types/member.go b/server/pkg/types/member.go index c0492708..a05b2a35 100644 --- a/server/pkg/types/member.go +++ b/server/pkg/types/member.go @@ -45,7 +45,5 @@ type MemberManager interface { MemberProvider Login(username string, password string) (Session, string, error) - LoginOAuth(subject, name, avatar, email string, isAdmin bool, extraData map[string]any) (Session, string, error) - OAuthExtraData(id string) map[string]any Logout(id string) error }