easyai-ai-gateway/apps/api/internal/httpapi/oidc_session.go
chengcheng a81a7b5200 fix: 修复 OIDC 用户预配与跨标签页登录态
增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
2026-07-13 17:07:52 +08:00

132 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
)
const (
errorCodeOIDCBrowserSessionDisabled = "OIDC_BROWSER_SESSION_DISABLED"
errorCodeOIDCSessionInvalid = "OIDC_SESSION_INVALID"
errorCodeOIDCSessionTooLarge = "OIDC_SESSION_TOKEN_TOO_LARGE"
errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED"
maxOIDCSessionCookieTokenBytes = 3800
)
// createOIDCBrowserSession godoc
// @Summary 建立 OIDC 浏览器会话
// @Description 验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie不会签发 Gateway JWT。
// @Tags auth
// @Success 204
// @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 {
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)
return
}
if len(raw) > maxOIDCSessionCookieTokenBytes {
writeError(w, http.StatusBadRequest, "OIDC access token is too large for browser session", errorCodeOIDCSessionTooLarge)
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)
return
}
now := time.Now()
if user.TokenExpiresAt.IsZero() || !user.TokenExpiresAt.After(now) {
writeError(w, http.StatusUnauthorized, "OIDC access token has expired", errorCodeOIDCSessionInvalid)
return
}
maxAge := int(time.Until(user.TokenExpiresAt).Seconds())
if maxAge < 1 {
maxAge = 1
}
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,
})
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) {
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,
})
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
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 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:
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")) != ""
}