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" errorCodeOIDCSessionExpired = "OIDC_SESSION_EXPIRED" errorCodeOIDCSessionStoreUnavailable = "OIDC_SESSION_STORE_UNAVAILABLE" errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED" errorCodeOIDCLoginInvalid = "OIDC_LOGIN_INVALID" errorCodeOIDCTokenExchangeFailed = "OIDC_TOKEN_EXCHANGE_FAILED" ) // startOIDCLogin godoc // @Summary 开始 OIDC 公共客户端登录 // @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。 // @Tags auth // @Param returnTo query string false "登录后返回的站内相对路径" // @Success 303 // @Failure 400 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope // @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 } 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 } 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 } 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() 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 } 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 } } 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) } 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: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(), Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode, }) } 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 { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) { next.ServeHTTP(w, r) return } if _, err := r.Cookie(auth.OIDCSessionCookieName); err != nil { next.ServeHTTP(w, r) return } origin := strings.TrimSpace(r.Header.Get("Origin")) if origin == "" || !originAllowed(origin, s.cfg.CORSAllowedOrigin) { writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF) return } next.ServeHTTP(w, r) }) } func isSafeHTTPMethod(method string) bool { switch method { case http.MethodGet, http.MethodHead, http.MethodOptions: return true default: return false } } func hasExplicitCredential(r *http.Request) bool { return strings.TrimSpace(r.Header.Get("Authorization")) != "" || strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" || strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" || strings.TrimSpace(r.URL.Query().Get("key")) != "" }