fix: 修复 OIDC 用户预配与跨标签页登录态

增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
2026-07-13 17:07:52 +08:00
parent 17b1f77e1d
commit a81a7b5200
37 changed files with 2694 additions and 179 deletions
+131
View File
@@ -0,0 +1,131 @@
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")) != ""
}