diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index d1770b1..bab1b40 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -4279,16 +4279,16 @@ } } }, - "/api/v1/auth/oidc/session": { - "post": { - "description": "验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie;不会签发 Gateway JWT。", + "/api/v1/auth/oidc/callback": { + "get": { + "description": "Gateway 使用 client_id、授权码和 PKCE verifier 换取 Token,不发送 Client Secret;Token 加密存入服务端 Session。", "tags": [ "auth" ], - "summary": "建立 OIDC 浏览器会话", + "summary": "完成 OIDC 公共客户端登录", "responses": { - "204": { - "description": "No Content" + "303": { + "description": "See Other" }, "400": { "description": "Bad Request", @@ -4302,6 +4302,40 @@ "$ref": "#/definitions/httpapi.ErrorEnvelope" } }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/v1/auth/oidc/login": { + "get": { + "description": "Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。", + "tags": [ + "auth" + ], + "summary": "开始 OIDC 公共客户端登录", + "parameters": [ + { + "type": "string", + "description": "登录后返回的站内相对路径", + "name": "returnTo", + "in": "query" + } + ], + "responses": { + "303": { + "description": "See Other" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, "404": { "description": "Not Found", "schema": { @@ -4309,15 +4343,49 @@ } } } - }, + } + }, + "/api/v1/auth/oidc/logout": { + "post": { + "description": "删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。", + "tags": [ + "auth" + ], + "summary": "注销 OIDC 登录会话", + "responses": { + "303": { + "description": "See Other" + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/v1/auth/oidc/session": { "delete": { "tags": [ "auth" ], - "summary": "注销 OIDC 浏览器会话", + "summary": "删除本地 OIDC 浏览器会话", "responses": { "204": { "description": "No Content" + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } } } } diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index dbedfcd..ac0cb65 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -5276,20 +5276,13 @@ paths: summary: 本地登录 tags: - auth - /api/v1/auth/oidc/session: - delete: + /api/v1/auth/oidc/callback: + get: + description: Gateway 使用 client_id、授权码和 PKCE verifier 换取 Token,不发送 Client Secret;Token + 加密存入服务端 Session。 responses: - "204": - description: No Content - summary: 注销 OIDC 浏览器会话 - tags: - - auth - post: - description: 验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie;不会签发 Gateway - JWT。 - responses: - "204": - description: No Content + "303": + description: See Other "400": description: Bad Request schema: @@ -5298,11 +5291,62 @@ paths: description: Unauthorized schema: $ref: '#/definitions/httpapi.ErrorEnvelope' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + summary: 完成 OIDC 公共客户端登录 + tags: + - auth + /api/v1/auth/oidc/login: + get: + description: Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。 + parameters: + - description: 登录后返回的站内相对路径 + in: query + name: returnTo + type: string + responses: + "303": + description: See Other + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' "404": description: Not Found schema: $ref: '#/definitions/httpapi.ErrorEnvelope' - summary: 建立 OIDC 浏览器会话 + summary: 开始 OIDC 公共客户端登录 + tags: + - auth + /api/v1/auth/oidc/logout: + post: + description: 删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。 + responses: + "303": + description: See Other + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + summary: 注销 OIDC 登录会话 + tags: + - auth + /api/v1/auth/oidc/session: + delete: + responses: + "204": + description: No Content + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + summary: 删除本地 OIDC 浏览器会话 tags: - auth /api/v1/auth/register: diff --git a/apps/api/internal/httpapi/oidc_jit_integration_test.go b/apps/api/internal/httpapi/oidc_jit_integration_test.go index 8969f57..5e7e988 100644 --- a/apps/api/internal/httpapi/oidc_jit_integration_test.go +++ b/apps/api/internal/httpapi/oidc_jit_integration_test.go @@ -6,12 +6,16 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "io" "log/slog" "net/http" + "net/http/cookiejar" "net/http/httptest" + "net/url" "os" "strings" "testing" @@ -42,12 +46,54 @@ func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) { t.Fatalf("generate test signing key: %v", err) } var issuer string + var gatewayBaseURL string + var expectedPKCEChallenge string + validSubject := "" issuerServer := 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]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"}) + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, "jwks_uri": issuer + "/jwks", + "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token", + "revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout", + }) case "/jwks": _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}}) + case "/authorize": + if r.URL.Query().Get("response_type") != "code" || r.URL.Query().Get("client_id") != "gateway-public-test" || + r.URL.Query().Get("code_challenge_method") != "S256" || r.URL.Query().Get("nonce") == "" { + http.Error(w, "invalid authorization request", http.StatusBadRequest) + return + } + expectedPKCEChallenge = r.URL.Query().Get("code_challenge") + callback := gatewayBaseURL + "/api/v1/auth/oidc/callback?code=test-code&state=" + url.QueryEscape(r.URL.Query().Get("state")) + + "&nonce=" + url.QueryEscape(r.URL.Query().Get("nonce")) + http.Redirect(w, r, callback, http.StatusSeeOther) + case "/token": + if err := r.ParseForm(); err != nil || r.Form.Get("grant_type") != "authorization_code" || + r.Form.Get("client_id") != "gateway-public-test" || r.Form.Get("client_secret") != "" || r.Header.Get("Authorization") != "" { + http.Error(w, "invalid token request", http.StatusBadRequest) + return + } + digest := sha256.Sum256([]byte(r.Form.Get("code_verifier"))) + if base64.RawURLEncoding.EncodeToString(digest[:]) != expectedPKCEChallenge { + http.Error(w, "invalid PKCE verifier", http.StatusBadRequest) + return + } + accessToken := signedOIDCJITToken(t, key, issuer, validSubject, nil) + idToken := signedOIDCJITToken(t, key, issuer, validSubject, func(claims jwt.MapClaims) { + claims["aud"] = "gateway-public-test" + // The nonce is retained from the authorization request by this test issuer. + claims["nonce"] = currentOIDCTestNonce + }) + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, "refresh_token": "opaque-test-refresh-token", "id_token": idToken, + "token_type": "Bearer", "expires_in": 300, + }) + case "/revoke": + w.WriteHeader(http.StatusOK) + case "/logout": + http.Redirect(w, r, r.URL.Query().Get("post_logout_redirect_uri"), http.StatusSeeOther) default: http.NotFound(w, r) } @@ -56,7 +102,7 @@ func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) { issuer = issuerServer.URL suffix := time.Now().UTC().Format("20060102150405.000000000") - validSubject := "platform-http-jit-" + suffix + validSubject = "platform-http-jit-" + suffix rejectedSubjects := []string{ "platform-http-scope-" + suffix, "platform-http-role-" + suffix, @@ -73,31 +119,39 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t }) baseConfig := config.Config{ - AppEnv: "test", - HTTPAddr: ":0", - DatabaseURL: databaseURL, - IdentityMode: "hybrid", - JWTSecret: "test-only-jwt-secret", - OIDCEnabled: true, - OIDCIssuer: issuer, - OIDCAudience: "gateway-api", - OIDCTenantID: "auth-center-test-tenant", - OIDCRolePrefix: "gateway.", - OIDCRequiredScopes: []string{"gateway.access"}, - OIDCJWKSCacheTTLSeconds: 60, - OIDCAcceptLegacyHS256: true, - OIDCJITProvisioningEnabled: true, - OIDCGatewayTenantKey: "default", - OIDCBrowserSessionEnabled: true, - OIDCSessionCookieSecure: false, - LocalGeneratedStorageDir: t.TempDir(), - LocalUploadedStorageDir: t.TempDir(), - LocalTempAssetTTLHours: 1, - CORSAllowedOrigin: "http://localhost:5178", - TaskProgressCallbackEnabled: false, + AppEnv: "test", + HTTPAddr: ":0", + DatabaseURL: databaseURL, + IdentityMode: "hybrid", + JWTSecret: "test-only-jwt-secret", + OIDCEnabled: true, + OIDCIssuer: issuer, + OIDCAudience: "gateway-api", + OIDCTenantID: "auth-center-test-tenant", + OIDCRolePrefix: "gateway.", + OIDCRequiredScopes: []string{"gateway.access"}, + OIDCJWKSCacheTTLSeconds: 60, + OIDCAcceptLegacyHS256: true, + OIDCJITProvisioningEnabled: true, + OIDCGatewayTenantKey: "default", + OIDCBrowserSessionEnabled: true, + OIDCSessionCookieSecure: false, + OIDCClientID: "gateway-public-test", + OIDCRedirectURI: "http://localhost/api/v1/auth/oidc/callback", + OIDCPostLogoutRedirectURI: "http://localhost:5178/", + OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32)), + OIDCSessionIdleTTLSeconds: 1800, + OIDCSessionAbsoluteTTLSeconds: 28800, + OIDCSessionRefreshBeforeSeconds: 60, + LocalGeneratedStorageDir: t.TempDir(), + LocalUploadedStorageDir: t.TempDir(), + LocalTempAssetTTLHours: 1, + CORSAllowedOrigin: "http://localhost:5178", + TaskProgressCallbackEnabled: false, } server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) defer server.Close() + gatewayBaseURL = server.URL validToken := signedOIDCJITToken(t, key, issuer, validSubject, nil) var me auth.User @@ -105,7 +159,7 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t if me.ID != validSubject || me.Source != "oidc" || me.GatewayUserID == "" || me.GatewayTenantID == "" || me.TenantKey != "default" || me.UserGroupID == "" { t.Fatalf("OIDC /me did not include the local Gateway projection") } - sessionCookie := createOIDCSessionCookie(t, server.URL, validToken) + sessionCookie := createOIDCBFFSessionCookie(t, server.URL) request, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/me", nil) if err != nil { t.Fatal(err) @@ -216,26 +270,37 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled) } -func createOIDCSessionCookie(t *testing.T, baseURL string, token string) *http.Cookie { +var currentOIDCTestNonce string + +func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie { t.Helper() - request, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/oidc/session", nil) + jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } - request.Header.Set("Authorization", "Bearer "+token) - request.Header.Set("Origin", "http://localhost:5178") - response, err := http.DefaultClient.Do(request) + client := &http.Client{Jar: jar, CheckRedirect: func(request *http.Request, via []*http.Request) error { + if request.URL.Path == "/api/v1/auth/oidc/callback" { + currentOIDCTestNonce = request.URL.Query().Get("nonce") + } + if len(via) > 10 { + return errors.New("too many redirects") + } + return nil + }} + response, err := client.Get(baseURL + "/api/v1/auth/oidc/login?returnTo=%2Fapi%2Fv1%2Fme") if err != nil { - t.Fatalf("create OIDC browser session: %v", err) + t.Fatalf("complete OIDC BFF login: %v", err) } defer response.Body.Close() - if response.StatusCode != http.StatusNoContent { - t.Fatalf("create OIDC browser session status = %d, want 204", response.StatusCode) + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("OIDC BFF login status = %d, want 200: %s", response.StatusCode, body) } - for _, cookie := range response.Cookies() { + parsedBaseURL, _ := url.Parse(baseURL) + for _, cookie := range jar.Cookies(parsedBaseURL) { if cookie.Name == auth.OIDCSessionCookieName { - if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { - t.Fatalf("unsafe OIDC browser session cookie: %#v", cookie) + if strings.Count(cookie.Value, ".") == 2 { + t.Fatal("browser session cookie contains a JWT instead of an opaque session ID") } return cookie } diff --git a/apps/api/internal/httpapi/oidc_session.go b/apps/api/internal/httpapi/oidc_session.go index 6216db5..43a638f 100644 --- a/apps/api/internal/httpapi/oidc_session.go +++ b/apps/api/internal/httpapi/oidc_session.go @@ -1,90 +1,291 @@ package httpapi import ( + "context" + "errors" "net/http" + "net/url" "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) const ( - errorCodeOIDCBrowserSessionDisabled = "OIDC_BROWSER_SESSION_DISABLED" - errorCodeOIDCSessionInvalid = "OIDC_SESSION_INVALID" - errorCodeOIDCSessionTooLarge = "OIDC_SESSION_TOKEN_TOO_LARGE" - errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED" - maxOIDCSessionCookieTokenBytes = 3800 + errorCodeOIDCBrowserSessionDisabled = "OIDC_BROWSER_SESSION_DISABLED" + errorCodeOIDCSessionInvalid = "OIDC_SESSION_INVALID" + errorCodeOIDCSessionExpired = "OIDC_SESSION_EXPIRED" + errorCodeOIDCSessionStoreUnavailable = "OIDC_SESSION_STORE_UNAVAILABLE" + errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED" + errorCodeOIDCLoginInvalid = "OIDC_LOGIN_INVALID" + errorCodeOIDCTokenExchangeFailed = "OIDC_TOKEN_EXCHANGE_FAILED" ) -// createOIDCBrowserSession godoc -// @Summary 建立 OIDC 浏览器会话 -// @Description 验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie;不会签发 Gateway JWT。 +// startOIDCLogin godoc +// @Summary 开始 OIDC 公共客户端登录 +// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。 // @Tags auth -// @Success 204 +// @Param returnTo query string false "登录后返回的站内相对路径" +// @Success 303 // @Failure 400 {object} ErrorEnvelope -// @Failure 401 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope -// @Router /api/v1/auth/oidc/session [post] -func (s *Server) createOIDCBrowserSession(w http.ResponseWriter, r *http.Request) { - if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || s.auth == nil || s.auth.OIDCVerifier == nil { +// @Router /api/v1/auth/oidc/login [get] +func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { + if !s.oidcBrowserSessionReady() { writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) return } - raw := bearerToken(r.Header.Get("Authorization")) - if raw == "" { - writeError(w, http.StatusUnauthorized, "valid OIDC access token is required", errorCodeOIDCSessionInvalid) + returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo")) + if returnTo == "" { + returnTo = "/" + } + transaction, challenge, err := oidcsession.NewLoginTransaction(returnTo, time.Now()) + if err != nil { + writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid) return } - if len(raw) > maxOIDCSessionCookieTokenBytes { - writeError(w, http.StatusBadRequest, "OIDC access token is too large for browser session", errorCodeOIDCSessionTooLarge) + encoded, err := s.oidcSessionCipher.EncodeLoginTransaction(transaction) + if err != nil { + s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err) + writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable) return } - user, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), raw) - if err != nil || user == nil || !strings.EqualFold(strings.TrimSpace(user.Source), "oidc") { - writeError(w, http.StatusUnauthorized, "valid OIDC access token is required", errorCodeOIDCSessionInvalid) + authorizationURL, err := s.oidcClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, challenge) + if err != nil { + s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err) + writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE") + return + } + http.SetCookie(w, &http.Cookie{ + Name: oidcsession.LoginTransactionCookieName, Value: encoded, + Path: s.oidcCallbackCookiePath(), MaxAge: 600, Expires: time.Now().Add(10 * time.Minute), + HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode, + }) + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, authorizationURL, http.StatusSeeOther) +} + +// completeOIDCLogin godoc +// @Summary 完成 OIDC 公共客户端登录 +// @Description Gateway 使用 client_id、授权码和 PKCE verifier 换取 Token,不发送 Client Secret;Token 加密存入服务端 Session。 +// @Tags auth +// @Success 303 +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 503 {object} ErrorEnvelope +// @Router /api/v1/auth/oidc/callback [get] +func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { + if !s.oidcBrowserSessionReady() { + writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) + return + } + s.clearOIDCLoginCookie(w) + cookie, err := r.Cookie(oidcsession.LoginTransactionCookieName) + if err != nil { + s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务无效或已过期", errorCodeOIDCLoginInvalid) + return + } + transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now()) + if err != nil || r.URL.Query().Get("state") == "" || r.URL.Query().Get("state") != transaction.State || r.URL.Query().Get("code") == "" { + s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务校验失败,请重新登录", errorCodeOIDCLoginInvalid) + return + } + tokens, err := s.oidcClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier) + if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" { + s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed) + return + } + identity, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), tokens.AccessToken) + if err != nil || identity == nil { + s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed) + return + } + idSubject, err := s.auth.OIDCVerifier.VerifyIDToken(r.Context(), tokens.IDToken, s.cfg.OIDCClientID, transaction.Nonce) + if err != nil || idSubject != identity.ID { + s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed) + return + } + projection, err := s.resolveOIDCUserProjection(r.Context(), r, identity) + if err != nil { + s.writeOIDCCallbackProjectionError(w, r, err) + return + } + if projection.User == nil || projection.User.GatewayUserID == "" { + s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user")) + return + } + rawSession, err := s.oidcSessions.Create(r.Context(), oidcsession.TokenBundle{ + AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken, + }, projection.User) + if err != nil { + s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "登录会话保存失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable) return } now := time.Now() - if user.TokenExpiresAt.IsZero() || !user.TokenExpiresAt.After(now) { - writeError(w, http.StatusUnauthorized, "OIDC access token has expired", errorCodeOIDCSessionInvalid) + http.SetCookie(w, &http.Cookie{ + Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/", + MaxAge: s.cfg.OIDCSessionAbsoluteTTLSeconds, Expires: now.Add(time.Duration(s.cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second), + HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode, + }) + w.Header().Set("Cache-Control", "no-store") + s.recordOIDCSessionAudit(r, projection.User) + http.Redirect(w, r, s.oidcReturnLocation(transaction.ReturnTo), http.StatusSeeOther) +} + +// logoutOIDCSession godoc +// @Summary 注销 OIDC 登录会话 +// @Description 删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。 +// @Tags auth +// @Success 303 +// @Failure 403 {object} ErrorEnvelope +// @Failure 503 {object} ErrorEnvelope +// @Router /api/v1/auth/oidc/logout [post] +func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) { + if !s.oidcBrowserSessionReady() { + writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) return } - maxAge := int(time.Until(user.TokenExpiresAt).Seconds()) - if maxAge < 1 { - maxAge = 1 + var bundle oidcsession.TokenBundle + if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil { + bundle, err = s.oidcSessions.Delete(r.Context(), cookie.Value) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable) + return + } } - http.SetCookie(w, &http.Cookie{ - Name: auth.OIDCSessionCookieName, - Value: raw, - Path: "/", - Expires: user.TokenExpiresAt, - MaxAge: maxAge, - HttpOnly: true, - Secure: s.cfg.OIDCSessionCookieSecure, - SameSite: http.SameSiteStrictMode, - }) + s.clearOIDCSessionCookie(w) + if bundle.RefreshToken != "" { + if err := s.oidcClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil { + s.logger.WarnContext(r.Context(), "revoke OIDC refresh token failed", "error", err) + } + } + // Do not put the encrypted-at-rest ID Token into a browser-visible redirect URL. + location, err := s.oidcClient.EndSessionURL(r.Context(), "") + if err != nil { + location = s.cfg.OIDCPostLogoutRedirectURI + } + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, location, http.StatusSeeOther) +} + +// deleteOIDCBrowserSession godoc +// @Summary 删除本地 OIDC 浏览器会话 +// @Tags auth +// @Success 204 +// @Failure 503 {object} ErrorEnvelope +// @Router /api/v1/auth/oidc/session [delete] +func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request) { + if s.oidcSessions != nil { + if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil { + if _, err := s.oidcSessions.Delete(r.Context(), cookie.Value); err != nil { + writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable) + return + } + } + } + s.clearOIDCSessionCookie(w) w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusNoContent) } -// deleteOIDCBrowserSession godoc -// @Summary 注销 OIDC 浏览器会话 -// @Tags auth -// @Success 204 -// @Router /api/v1/auth/oidc/session [delete] -func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, _ *http.Request) { +func (s *Server) oidcBrowserSessionReady() bool { + return s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled && s.auth != nil && s.auth.OIDCVerifier != nil && + s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil +} + +func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ - Name: auth.OIDCSessionCookieName, - Value: "", - Path: "/", - Expires: time.Unix(1, 0), - MaxAge: -1, - HttpOnly: true, - Secure: s.cfg.OIDCSessionCookieSecure, - SameSite: http.SameSiteStrictMode, + Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(), + Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode, }) - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) oidcCallbackCookiePath() string { + if parsed, err := url.Parse(strings.TrimSpace(s.cfg.OIDCRedirectURI)); err == nil && strings.HasPrefix(parsed.Path, "/") { + return parsed.Path + } + return "/api/v1/auth/oidc/callback" +} + +func (s *Server) clearOIDCSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: auth.OIDCSessionCookieName, Value: "", Path: "/", + Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode, + }) +} + +func (s *Server) oidcReturnLocation(returnTo string) string { + if base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/"); base != "" { + return base + returnTo + } + return returnTo +} + +func (s *Server) writeOIDCCallbackError(w http.ResponseWriter, r *http.Request, status int, message, code string) { + base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/") + if parsed, err := url.Parse(base); base != "" && err == nil && parsed.Host != "" && (parsed.Scheme == "https" || parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")) { + query := parsed.Query() + query.Set("oidcError", code) + parsed.RawQuery = query.Encode() + http.Redirect(w, r, parsed.String(), http.StatusSeeOther) + return + } + writeError(w, status, message, code) +} + +func (s *Server) writeOIDCCallbackProjectionError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, store.ErrOIDCUserNotProvisioned): + s.writeOIDCCallbackError(w, r, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned) + case errors.Is(err, store.ErrOIDCUserDisabled): + s.writeOIDCCallbackError(w, r, http.StatusForbidden, "该 Gateway 账号已停用,请联系管理员", errorCodeGatewayUserDisabled) + case errors.Is(err, store.ErrOIDCTenantUnavailable): + s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "Gateway 租户尚未就绪,请联系管理员", errorCodeGatewayTenantUnavailable) + default: + s.logger.ErrorContext(r.Context(), "resolve OIDC gateway user during callback failed", "error", err) + s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "Gateway 账号初始化失败,请稍后重试", errorCodeGatewayProvisioningFailed) + } +} + +func (s *Server) recordOIDCSessionAudit(r *http.Request, user *auth.User) { + if s.store == nil || user == nil { + return + } + audit, err := s.store.RecordAuditLog(r.Context(), store.AuditLogInput{ + Category: "identity", Action: "identity.oidc_session.created", + ActorGatewayUserID: user.GatewayUserID, ActorUsername: user.Username, ActorSource: "oidc", ActorRoles: user.Roles, + TargetType: "gateway_user", TargetID: user.GatewayUserID, + TargetGatewayUserID: user.GatewayUserID, TargetGatewayTenantID: user.GatewayTenantID, + RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512), + Metadata: map[string]any{"sessionMode": "public-client-pkce-bff"}, + }) + if err != nil { + s.logger.WarnContext(r.Context(), "record OIDC session audit failed", "error", err) + return + } + s.logger.InfoContext(r.Context(), "OIDC server session created", "gatewayUserId", user.GatewayUserID, "auditId", audit.ID) +} + +func (s *Server) startOIDCSessionCleanup(ctx context.Context) { + if s.oidcSessions == nil { + return + } + go func() { + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := s.oidcSessions.Cleanup(ctx); err != nil { + s.logger.WarnContext(ctx, "cleanup expired OIDC sessions failed", "error", err) + } + } + } + }() } func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { @@ -106,14 +307,6 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { }) } -func bearerToken(value string) string { - fields := strings.Fields(value) - if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") { - return fields[1] - } - return "" -} - func isSafeHTTPMethod(method string) bool { switch method { case http.MethodGet, http.MethodHead, http.MethodOptions: diff --git a/apps/api/internal/httpapi/oidc_session_test.go b/apps/api/internal/httpapi/oidc_session_test.go index d4d4caa..d9b0c86 100644 --- a/apps/api/internal/httpapi/oidc_session_test.go +++ b/apps/api/internal/httpapi/oidc_session_test.go @@ -1,140 +1,90 @@ package httpapi import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "encoding/json" + "bytes" + "context" "io" "log/slog" "net/http" "net/http/httptest" - "strings" "testing" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" ) -func TestCreateOIDCBrowserSessionSetsProtectedSharedCookie(t *testing.T) { - server, token, closeIssuer := newOIDCSessionTestServer(t) - defer closeIssuer() - request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil) - request.Header.Set("Authorization", "Bearer "+token) +func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testing.T) { + cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) + client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize?request=redacted"} + server := &Server{ + cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, OIDCSessionCookieSecure: true}, + auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client, + oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil) recorder := httptest.NewRecorder() - - server.createOIDCBrowserSession(recorder, request) - + server.startOIDCLogin(recorder, request) response := recorder.Result() defer response.Body.Close() - if response.StatusCode != http.StatusNoContent { - t.Fatalf("session creation status = %d, want 204", response.StatusCode) + if response.StatusCode != http.StatusSeeOther || response.Header.Get("Location") != client.authorizationURL { + t.Fatalf("login status=%d location=%q", response.StatusCode, response.Header.Get("Location")) } - var sessionCookie *http.Cookie - for _, cookie := range response.Cookies() { - if cookie.Name == auth.OIDCSessionCookieName { - sessionCookie = cookie - break - } + cookies := response.Cookies() + if len(cookies) != 1 || cookies[0].Name != oidcsession.LoginTransactionCookieName || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteLaxMode || cookies[0].MaxAge != 600 { + t.Fatalf("unsafe login transaction cookie: %#v", cookies) } - if sessionCookie == nil { - t.Fatal("OIDC session cookie was not set") + transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, cookies[0].Expires.Add(-time.Minute)) + if err != nil || transaction.ReturnTo != "/workspace?tab=wallet" { + t.Fatalf("transaction=%#v err=%v", transaction, err) } - if !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode || sessionCookie.Path != "/" { - t.Fatalf("unsafe OIDC session cookie attributes: %#v", sessionCookie) - } - if sessionCookie.MaxAge <= 0 || sessionCookie.Expires.IsZero() { - t.Fatalf("OIDC session cookie did not inherit token expiration: %#v", sessionCookie) - } - body, err := io.ReadAll(response.Body) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(body), token) { - t.Fatal("OIDC access token leaked into session response body") + if client.state == "" || client.nonce == "" || client.challenge == "" { + t.Fatal("authorization redirect omitted state, nonce or PKCE challenge") } } -func TestCreateOIDCBrowserSessionRejectsNonOIDCCredential(t *testing.T) { - server, _, closeIssuer := newOIDCSessionTestServer(t) - defer closeIssuer() - localToken, err := server.auth.SignJWT(&auth.User{ID: "local-user", Source: "gateway", Roles: []string{"user"}}, 0) - if err != nil { - t.Fatal(err) +func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) { + cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) + server := &Server{ + cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true}, + auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{}, + oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } - request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil) - request.Header.Set("Authorization", "Bearer "+localToken) recorder := httptest.NewRecorder() - - server.createOIDCBrowserSession(recorder, request) - if recorder.Code != http.StatusUnauthorized { - t.Fatalf("local credential session creation status = %d, want 401", recorder.Code) - } -} - -func TestCreateOIDCBrowserSessionRejectsOversizedTokenBeforeCookieWrite(t *testing.T) { - server, _, closeIssuer := newOIDCSessionTestServer(t) - defer closeIssuer() - request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil) - request.Header.Set("Authorization", "Bearer "+strings.Repeat("a", maxOIDCSessionCookieTokenBytes+1)) - recorder := httptest.NewRecorder() - - server.createOIDCBrowserSession(recorder, request) - + server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example", nil)) if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" { - t.Fatalf("oversized token response status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie")) + t.Fatalf("open redirect status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie")) } } -func TestCreateOIDCBrowserSessionHonorsDisabledFlag(t *testing.T) { - server, token, closeIssuer := newOIDCSessionTestServer(t) - defer closeIssuer() - server.cfg.OIDCBrowserSessionEnabled = false - request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil) - request.Header.Set("Authorization", "Bearer "+token) - recorder := httptest.NewRecorder() - - server.createOIDCBrowserSession(recorder, request) - - if recorder.Code != http.StatusNotFound || recorder.Header().Get("Set-Cookie") != "" { - t.Fatalf("disabled session response status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie")) - } -} - -func TestDeleteOIDCBrowserSessionExpiresCookie(t *testing.T) { - server, _, closeIssuer := newOIDCSessionTestServer(t) - defer closeIssuer() +func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) { + sessions := &fakeOIDCSessions{} + server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions} request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil) - request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"}) + request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "opaque-session"}) recorder := httptest.NewRecorder() - server.deleteOIDCBrowserSession(recorder, request) response := recorder.Result() defer response.Body.Close() - if response.StatusCode != http.StatusNoContent { - t.Fatalf("session deletion status = %d, want 204", response.StatusCode) + if response.StatusCode != http.StatusNoContent || sessions.deleted != "opaque-session" { + t.Fatalf("delete status=%d session=%q", response.StatusCode, sessions.deleted) } cookies := response.Cookies() - if len(cookies) != 1 || cookies[0].Name != auth.OIDCSessionCookieName || cookies[0].MaxAge >= 0 { - t.Fatalf("OIDC session cookie was not expired: %#v", cookies) + if len(cookies) != 1 || cookies[0].Name != auth.OIDCSessionCookieName || cookies[0].MaxAge >= 0 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode { + t.Fatalf("OIDC session cookie was not safely expired: %#v", cookies) } } func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { - server := &Server{cfg: config.Config{ - OIDCEnabled: true, - OIDCBrowserSessionEnabled: true, - CORSAllowedOrigin: "https://gateway.example.com", - }} + server := &Server{cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, CORSAllowedOrigin: "https://gateway.example.com"}} next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) handler := server.protectOIDCSessionCookie(next) - for _, test := range []struct { - name string - method string - origin string - bearer bool - wantStatus int + name, method, origin string + bearer bool + wantStatus int }{ {name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden}, {name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden}, @@ -145,9 +95,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { t.Run(test.name, func(t *testing.T) { request := httptest.NewRequest(test.method, "/api/workspace/tasks", nil) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"}) - if test.origin != "" { - request.Header.Set("Origin", test.origin) - } + request.Header.Set("Origin", test.origin) if test.bearer { request.Header.Set("Authorization", "Bearer explicit-token") } @@ -161,60 +109,44 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { } func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) { - server := &Server{cfg: config.Config{ - OIDCEnabled: false, - OIDCBrowserSessionEnabled: true, - }} + server := &Server{cfg: config.Config{OIDCEnabled: false, OIDCBrowserSessionEnabled: true}} request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"}) recorder := httptest.NewRecorder() - - server.protectOIDCSessionCookie(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - })).ServeHTTP(recorder, request) - + server.protectOIDCSessionCookie(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })).ServeHTTP(recorder, request) if recorder.Code != http.StatusNoContent { t.Fatalf("OIDC-disabled request status = %d, want 204", recorder.Code) } } -func newOIDCSessionTestServer(t *testing.T) (*Server, string, func()) { - t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - var issuer string - issuerServer := 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]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"}) - case "/jwks": - _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}}) - default: - http.NotFound(w, r) - } - })) - issuer = issuerServer.URL - verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ - Issuer: issuer, Audience: "gateway-api", TenantID: "auth-center-test-tenant", - RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: issuerServer.Client(), - }) - if err != nil { - issuerServer.Close() - t.Fatal(err) - } - authenticator := auth.New("test-local-jwt-secret", "", "") - authenticator.OIDCVerifier = verifier - server := &Server{ - cfg: config.Config{ - OIDCEnabled: true, - OIDCBrowserSessionEnabled: true, - OIDCSessionCookieSecure: false, - CORSAllowedOrigin: "http://localhost:5178", - }, - auth: authenticator, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - } - return server, signedOIDCJITToken(t, key, issuer, "session-user", nil), issuerServer.Close +type fakeOIDCClient struct { + authorizationURL string + state, nonce, challenge string } + +func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge string) (string, error) { + f.state, f.nonce, f.challenge = state, nonce, challenge + return f.authorizationURL, nil +} +func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) { + return auth.OIDCTokenResponse{}, nil +} +func (f *fakeOIDCClient) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) { + return auth.OIDCTokenResponse{}, nil +} +func (f *fakeOIDCClient) RevokeRefreshToken(context.Context, string) error { return nil } +func (f *fakeOIDCClient) EndSessionURL(context.Context, string) (string, error) { + return "https://gateway.example.com/", nil +} + +type fakeOIDCSessions struct{ deleted string } + +func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error) { + return "opaque-session", nil +} +func (f *fakeOIDCSessions) Resolve(context.Context, string) (*auth.User, error) { return nil, nil } +func (f *fakeOIDCSessions) Delete(_ context.Context, raw string) (oidcsession.TokenBundle, error) { + f.deleted = raw + return oidcsession.TokenBundle{}, nil +} +func (f *fakeOIDCSessions) Cleanup(context.Context) (int64, error) { return 0, nil } diff --git a/apps/api/internal/httpapi/oidc_user_middleware.go b/apps/api/internal/httpapi/oidc_user_middleware.go index 035ea0d..9203921 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware.go +++ b/apps/api/internal/httpapi/oidc_user_middleware.go @@ -32,22 +32,7 @@ func (s *Server) resolveGatewayUser(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } - if s.oidcUserResolver == nil { - s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver is unavailable")) - return - } - - result, err := s.oidcUserResolver.ResolveOrProvisionOIDCUser(r.Context(), store.ResolveOrProvisionOIDCUserInput{ - Issuer: s.cfg.OIDCIssuer, - Subject: user.ID, - Username: user.Username, - Roles: user.Roles, - TenantID: user.TenantID, - GatewayTenantKey: s.cfg.OIDCGatewayTenantKey, - ProvisioningEnabled: s.cfg.OIDCJITProvisioningEnabled, - RequestIP: limitAuditText(requestIP(r), 128), - UserAgent: limitAuditText(r.UserAgent(), 512), - }) + result, err := s.resolveOIDCUserProjection(r.Context(), r, user) if err != nil { s.writeOIDCUserResolutionError(w, r, err) return @@ -66,6 +51,23 @@ func (s *Server) resolveGatewayUser(next http.Handler) http.Handler { }) } +func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request, user *auth.User) (store.ResolveOrProvisionOIDCUserResult, error) { + if s.oidcUserResolver == nil { + return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable") + } + return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{ + Issuer: s.cfg.OIDCIssuer, + Subject: user.ID, + Username: user.Username, + Roles: user.Roles, + TenantID: user.TenantID, + GatewayTenantKey: s.cfg.OIDCGatewayTenantKey, + ProvisioningEnabled: s.cfg.OIDCJITProvisioningEnabled, + RequestIP: limitAuditText(requestIP(r), 128), + UserAgent: limitAuditText(r.UserAgent(), 512), + }) +} + func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, store.ErrOIDCUserNotProvisioned): diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 08d07ab..6ebb207 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -2,6 +2,7 @@ package httpapi import ( "context" + "errors" "log/slog" "net/http" "strings" @@ -10,6 +11,7 @@ import ( "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" "github.com/easyai/easyai-ai-gateway/apps/api/internal/runner" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) @@ -20,11 +22,29 @@ type Server struct { store *store.Store oidcUserResolver oidcUserResolver auth *auth.Authenticator + oidcClient oidcPublicClient + oidcSessions oidcSessionManager + oidcSessionCipher *oidcsession.Cipher runner *runner.Service logger *slog.Logger geminiUploadSessions sync.Map } +type oidcPublicClient interface { + AuthorizationURL(context.Context, string, string, string) (string, error) + ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) + Refresh(context.Context, string) (auth.OIDCTokenResponse, error) + RevokeRefreshToken(context.Context, string) error + EndSessionURL(context.Context, string) (string, error) +} + +type oidcSessionManager interface { + Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error) + Resolve(context.Context, string) (*auth.User, error) + Delete(context.Context, string) (oidcsession.TokenBundle, error) + Cleanup(context.Context) (int64, error) +} + func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler { return NewServerWithContext(context.Background(), cfg, db, logger) } @@ -55,10 +75,44 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor panic("invalid OIDC configuration: " + err.Error()) } server.auth.OIDCVerifier = verifier + if cfg.OIDCBrowserSessionEnabled { + key, err := cfg.OIDCSessionEncryptionKeyBytes() + if err != nil { + panic("invalid OIDC session configuration: " + err.Error()) + } + cipher, err := oidcsession.NewCipher(key) + if err != nil { + panic("invalid OIDC session configuration: " + err.Error()) + } + client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{ + Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, RedirectURI: cfg.OIDCRedirectURI, + PostLogoutRedirectURI: cfg.OIDCPostLogoutRedirectURI, + Scopes: append([]string{"openid", "profile"}, cfg.OIDCRequiredScopes...), + }) + if err != nil { + panic("invalid OIDC public client configuration: " + err.Error()) + } + sessions, err := oidcsession.NewService(db, cipher, verifier, client, oidcsession.Config{ + IdleTTL: time.Duration(cfg.OIDCSessionIdleTTLSeconds) * time.Second, + AbsoluteTTL: time.Duration(cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second, + RefreshBefore: time.Duration(cfg.OIDCSessionRefreshBeforeSeconds) * time.Second, + }) + if err != nil { + panic("invalid OIDC session configuration: " + err.Error()) + } + server.oidcClient = client + server.oidcSessions = sessions + server.oidcSessionCipher = cipher + server.auth.OIDCSessionResolver = func(ctx context.Context, sessionID string) (*auth.User, error) { + user, resolveErr := sessions.Resolve(ctx, sessionID) + return user, oidcSessionRequestError(resolveErr) + } + } } server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey server.runner.StartAsyncQueueWorker(ctx) server.startLocalTempAssetCleanup(ctx) + server.startOIDCSessionCleanup(ctx) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", server.health) @@ -69,7 +123,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register))) mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login))) - mux.HandleFunc("POST /api/v1/auth/oidc/session", server.createOIDCBrowserSession) + mux.HandleFunc("GET /api/v1/auth/oidc/login", server.startOIDCLogin) + mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin) + mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession) mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession) mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me))) mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders))) @@ -217,6 +273,23 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor return server.recover(server.cors(server.protectOIDCSessionCookie(mux))) } +func oidcSessionRequestError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, oidcsession.ErrSessionExpired): + return auth.NewRequestAuthError(http.StatusUnauthorized, "OIDC_SESSION_EXPIRED", "登录会话已过期,请重新登录") + case errors.Is(err, oidcsession.ErrSessionRefreshUnavailable): + return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_REFRESH_UNAVAILABLE", "认证中心暂时不可用,请稍后重试") + case errors.Is(err, oidcsession.ErrSessionStoreUnavailable): + return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_STORE_UNAVAILABLE", "登录会话存储暂时不可用") + case errors.Is(err, oidcsession.ErrGatewayUserDisabled): + return auth.NewRequestAuthError(http.StatusForbidden, "GATEWAY_USER_DISABLED", "该 Gateway 账号已停用,请联系管理员") + default: + return auth.NewRequestAuthError(http.StatusUnauthorized, "OIDC_SESSION_INVALID", "登录会话无效,请重新登录") + } +} + func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) http.Handler { return s.requireUser(permission, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, _ := auth.UserFromContext(r.Context())