feat: 接入 OIDC 公共客户端登录链路
由 Gateway 服务端完成 PKCE 换码、ID Token 校验、JIT 用户解析和 Session 建立。新增登录、回调、退出与本地会话删除接口,并移除旧的 Access Token Cookie 桥接接口。
This commit is contained in:
@@ -6,12 +6,16 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -42,12 +46,54 @@ func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
|
||||
t.Fatalf("generate test signing key: %v", err)
|
||||
}
|
||||
var issuer string
|
||||
var gatewayBaseURL string
|
||||
var expectedPKCEChallenge string
|
||||
validSubject := ""
|
||||
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"})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": issuer, "jwks_uri": issuer + "/jwks",
|
||||
"authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token",
|
||||
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
|
||||
})
|
||||
case "/jwks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}})
|
||||
case "/authorize":
|
||||
if r.URL.Query().Get("response_type") != "code" || r.URL.Query().Get("client_id") != "gateway-public-test" ||
|
||||
r.URL.Query().Get("code_challenge_method") != "S256" || r.URL.Query().Get("nonce") == "" {
|
||||
http.Error(w, "invalid authorization request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
expectedPKCEChallenge = r.URL.Query().Get("code_challenge")
|
||||
callback := gatewayBaseURL + "/api/v1/auth/oidc/callback?code=test-code&state=" + url.QueryEscape(r.URL.Query().Get("state")) +
|
||||
"&nonce=" + url.QueryEscape(r.URL.Query().Get("nonce"))
|
||||
http.Redirect(w, r, callback, http.StatusSeeOther)
|
||||
case "/token":
|
||||
if err := r.ParseForm(); err != nil || r.Form.Get("grant_type") != "authorization_code" ||
|
||||
r.Form.Get("client_id") != "gateway-public-test" || r.Form.Get("client_secret") != "" || r.Header.Get("Authorization") != "" {
|
||||
http.Error(w, "invalid token request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
digest := sha256.Sum256([]byte(r.Form.Get("code_verifier")))
|
||||
if base64.RawURLEncoding.EncodeToString(digest[:]) != expectedPKCEChallenge {
|
||||
http.Error(w, "invalid PKCE verifier", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accessToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
|
||||
idToken := signedOIDCJITToken(t, key, issuer, validSubject, func(claims jwt.MapClaims) {
|
||||
claims["aud"] = "gateway-public-test"
|
||||
// The nonce is retained from the authorization request by this test issuer.
|
||||
claims["nonce"] = currentOIDCTestNonce
|
||||
})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": accessToken, "refresh_token": "opaque-test-refresh-token", "id_token": idToken,
|
||||
"token_type": "Bearer", "expires_in": 300,
|
||||
})
|
||||
case "/revoke":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/logout":
|
||||
http.Redirect(w, r, r.URL.Query().Get("post_logout_redirect_uri"), http.StatusSeeOther)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -56,7 +102,7 @@ func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
|
||||
issuer = issuerServer.URL
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102150405.000000000")
|
||||
validSubject := "platform-http-jit-" + suffix
|
||||
validSubject = "platform-http-jit-" + suffix
|
||||
rejectedSubjects := []string{
|
||||
"platform-http-scope-" + suffix,
|
||||
"platform-http-role-" + suffix,
|
||||
@@ -73,31 +119,39 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
|
||||
})
|
||||
|
||||
baseConfig := config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-only-jwt-secret",
|
||||
OIDCEnabled: true,
|
||||
OIDCIssuer: issuer,
|
||||
OIDCAudience: "gateway-api",
|
||||
OIDCTenantID: "auth-center-test-tenant",
|
||||
OIDCRolePrefix: "gateway.",
|
||||
OIDCRequiredScopes: []string{"gateway.access"},
|
||||
OIDCJWKSCacheTTLSeconds: 60,
|
||||
OIDCAcceptLegacyHS256: true,
|
||||
OIDCJITProvisioningEnabled: true,
|
||||
OIDCGatewayTenantKey: "default",
|
||||
OIDCBrowserSessionEnabled: true,
|
||||
OIDCSessionCookieSecure: false,
|
||||
LocalGeneratedStorageDir: t.TempDir(),
|
||||
LocalUploadedStorageDir: t.TempDir(),
|
||||
LocalTempAssetTTLHours: 1,
|
||||
CORSAllowedOrigin: "http://localhost:5178",
|
||||
TaskProgressCallbackEnabled: false,
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-only-jwt-secret",
|
||||
OIDCEnabled: true,
|
||||
OIDCIssuer: issuer,
|
||||
OIDCAudience: "gateway-api",
|
||||
OIDCTenantID: "auth-center-test-tenant",
|
||||
OIDCRolePrefix: "gateway.",
|
||||
OIDCRequiredScopes: []string{"gateway.access"},
|
||||
OIDCJWKSCacheTTLSeconds: 60,
|
||||
OIDCAcceptLegacyHS256: true,
|
||||
OIDCJITProvisioningEnabled: true,
|
||||
OIDCGatewayTenantKey: "default",
|
||||
OIDCBrowserSessionEnabled: true,
|
||||
OIDCSessionCookieSecure: false,
|
||||
OIDCClientID: "gateway-public-test",
|
||||
OIDCRedirectURI: "http://localhost/api/v1/auth/oidc/callback",
|
||||
OIDCPostLogoutRedirectURI: "http://localhost:5178/",
|
||||
OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32)),
|
||||
OIDCSessionIdleTTLSeconds: 1800,
|
||||
OIDCSessionAbsoluteTTLSeconds: 28800,
|
||||
OIDCSessionRefreshBeforeSeconds: 60,
|
||||
LocalGeneratedStorageDir: t.TempDir(),
|
||||
LocalUploadedStorageDir: t.TempDir(),
|
||||
LocalTempAssetTTLHours: 1,
|
||||
CORSAllowedOrigin: "http://localhost:5178",
|
||||
TaskProgressCallbackEnabled: false,
|
||||
}
|
||||
server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
gatewayBaseURL = server.URL
|
||||
|
||||
validToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
|
||||
var me auth.User
|
||||
@@ -105,7 +159,7 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
|
||||
if me.ID != validSubject || me.Source != "oidc" || me.GatewayUserID == "" || me.GatewayTenantID == "" || me.TenantKey != "default" || me.UserGroupID == "" {
|
||||
t.Fatalf("OIDC /me did not include the local Gateway projection")
|
||||
}
|
||||
sessionCookie := createOIDCSessionCookie(t, server.URL, validToken)
|
||||
sessionCookie := createOIDCBFFSessionCookie(t, server.URL)
|
||||
request, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/me", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -216,26 +270,37 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
|
||||
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
|
||||
}
|
||||
|
||||
func createOIDCSessionCookie(t *testing.T, baseURL string, token string) *http.Cookie {
|
||||
var currentOIDCTestNonce string
|
||||
|
||||
func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
|
||||
t.Helper()
|
||||
request, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/oidc/session", nil)
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Origin", "http://localhost:5178")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
client := &http.Client{Jar: jar, CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if request.URL.Path == "/api/v1/auth/oidc/callback" {
|
||||
currentOIDCTestNonce = request.URL.Query().Get("nonce")
|
||||
}
|
||||
if len(via) > 10 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
response, err := client.Get(baseURL + "/api/v1/auth/oidc/login?returnTo=%2Fapi%2Fv1%2Fme")
|
||||
if err != nil {
|
||||
t.Fatalf("create OIDC browser session: %v", err)
|
||||
t.Fatalf("complete OIDC BFF login: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("create OIDC browser session status = %d, want 204", response.StatusCode)
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("OIDC BFF login status = %d, want 200: %s", response.StatusCode, body)
|
||||
}
|
||||
for _, cookie := range response.Cookies() {
|
||||
parsedBaseURL, _ := url.Parse(baseURL)
|
||||
for _, cookie := range jar.Cookies(parsedBaseURL) {
|
||||
if cookie.Name == auth.OIDCSessionCookieName {
|
||||
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("unsafe OIDC browser session cookie: %#v", cookie)
|
||||
if strings.Count(cookie.Value, ".") == 2 {
|
||||
t.Fatal("browser session cookie contains a JWT instead of an opaque session ID")
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user