fix: 修复 OIDC 用户预配与跨标签页登录态
增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user