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
@@ -0,0 +1,220 @@
package httpapi
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
)
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)
recorder := httptest.NewRecorder()
server.createOIDCBrowserSession(recorder, request)
response := recorder.Result()
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("session creation status = %d, want 204", response.StatusCode)
}
var sessionCookie *http.Cookie
for _, cookie := range response.Cookies() {
if cookie.Name == auth.OIDCSessionCookieName {
sessionCookie = cookie
break
}
}
if sessionCookie == nil {
t.Fatal("OIDC session cookie was not set")
}
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")
}
}
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)
}
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)
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"))
}
}
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()
request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"})
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)
}
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)
}
}
func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
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: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden},
{name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden},
{name: "allowed origin", method: http.MethodPatch, origin: "https://gateway.example.com", wantStatus: http.StatusNoContent},
{name: "safe request", method: http.MethodGet, wantStatus: http.StatusNoContent},
{name: "explicit bearer bypasses cookie csrf", method: http.MethodPost, bearer: true, wantStatus: http.StatusNoContent},
} {
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)
}
if test.bearer {
request.Header.Set("Authorization", "Bearer explicit-token")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != test.wantStatus {
t.Fatalf("status = %d, want %d", recorder.Code, test.wantStatus)
}
})
}
}
func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
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)
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
}