refactor(identity): 统一网页认证配置来源
移除旧 OIDC_* 与 VITE_OIDC_* 业务配置读取,统一使用数据库 Revision 和 SecretStore 引用构建运行时。同步更新 Compose、示例配置、接入文档及集成测试,并保留数据库、SecretStore 和安全事件健康窗口等部署级参数。\n\n验证:go test ./...;go test -race ./...;go vet ./...;pnpm test;pnpm lint;pnpm build;docker compose config -q
This commit is contained in:
@@ -23,10 +23,14 @@ import (
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const oidcJITTenantID = "6e6d0a0f-8b08-41ca-bda6-f1a58f065bc3"
|
||||
|
||||
func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
@@ -120,35 +124,32 @@ 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,
|
||||
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,
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-only-jwt-secret",
|
||||
IdentitySecretStore: "file",
|
||||
IdentitySecretDir: t.TempDir(),
|
||||
LocalGeneratedStorageDir: t.TempDir(),
|
||||
LocalUploadedStorageDir: t.TempDir(),
|
||||
LocalTempAssetTTLHours: 1,
|
||||
CORSAllowedOrigin: "http://localhost:5178",
|
||||
TaskProgressCallbackEnabled: false,
|
||||
}
|
||||
previous, previousErr := db.ActiveIdentityConfigurationRevision(ctx)
|
||||
if previousErr != nil && !errors.Is(previousErr, identity.ErrRevisionNotFound) {
|
||||
t.Fatalf("read previous active identity revision: %v", previousErr)
|
||||
}
|
||||
testRevisionIDs := make([]string, 0, 3)
|
||||
t.Cleanup(func() {
|
||||
restoreOIDCJITIdentityRevision(t, context.Background(), db, previous, previousErr == nil, testRevisionIDs)
|
||||
})
|
||||
activeRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", true)
|
||||
testRevisionIDs = append(testRevisionIDs, activeRevision.ID)
|
||||
activeRevision, _, err = db.ActivateIdentityRevision(ctx, activeRevision.ID, activeRevision.Version, "oidc-jit-test", "oidc-jit-test")
|
||||
if err != nil {
|
||||
t.Fatalf("activate OIDC JIT identity revision: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
@@ -246,17 +247,21 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
|
||||
t.Fatalf("rejected OIDC tokens created %d local users", rejectedWrites)
|
||||
}
|
||||
|
||||
disabledJITConfig := baseConfig
|
||||
disabledJITConfig.OIDCJITProvisioningEnabled = false
|
||||
disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, disabledJITConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
disabledJITRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", false)
|
||||
testRevisionIDs = append(testRevisionIDs, disabledJITRevision.ID)
|
||||
disabledJITRevision, _, err = db.ActivateIdentityRevision(ctx, disabledJITRevision.ID, disabledJITRevision.Version, "oidc-jit-disabled", "oidc-jit-disabled")
|
||||
if err != nil {
|
||||
t.Fatalf("activate disabled-JIT revision: %v", err)
|
||||
}
|
||||
disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer disabledJITServer.Close()
|
||||
assertOIDCJITError(t, disabledJITServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[3], nil), http.StatusForbidden, errorCodeGatewayUserNotProvisioned)
|
||||
|
||||
missingTenantConfig := baseConfig
|
||||
missingTenantConfig.OIDCGatewayTenantKey = "missing-tenant-" + suffix
|
||||
missingTenantServer := httptest.NewServer(NewServerWithContext(ctx, missingTenantConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer missingTenantServer.Close()
|
||||
assertOIDCJITError(t, missingTenantServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[4], nil), http.StatusServiceUnavailable, errorCodeGatewayTenantUnavailable)
|
||||
missingTenantRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "missing-tenant-"+suffix, true)
|
||||
testRevisionIDs = append(testRevisionIDs, missingTenantRevision.ID)
|
||||
if _, _, activateErr := db.ActivateIdentityRevision(ctx, missingTenantRevision.ID, missingTenantRevision.Version, "oidc-jit-missing-tenant", "oidc-jit-missing-tenant"); !errors.Is(activateErr, identity.ErrLocalTenantInvalid) {
|
||||
t.Fatalf("missing local tenant activation error = %v, want %v", activateErr, identity.ErrLocalTenantInvalid)
|
||||
}
|
||||
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, me.GatewayUserID); err != nil {
|
||||
t.Fatalf("disable projected user: %v", err)
|
||||
@@ -271,6 +276,86 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
|
||||
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
|
||||
}
|
||||
|
||||
func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store, cfg config.Config, issuer, localTenantKey string, jitEnabled bool) identity.Revision {
|
||||
t.Helper()
|
||||
draft, err := identity.NewDraft(identity.PairingInput{
|
||||
AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178",
|
||||
LocalTenantKey: localTenantKey, LegacyJWTEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create OIDC JIT draft: %v", err)
|
||||
}
|
||||
draft.JITEnabled = jitEnabled
|
||||
draft, err = db.CreateIdentityConfigurationRevision(ctx, draft)
|
||||
if err != nil {
|
||||
t.Fatalf("persist OIDC JIT draft: %v", err)
|
||||
}
|
||||
secrets, err := identitySecretStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("create identity SecretStore: %v", err)
|
||||
}
|
||||
sessionReference := "oidc-jit-session-" + draft.ID
|
||||
if err := secrets.Put(ctx, sessionReference, bytes.Repeat([]byte{8}, 32)); err != nil {
|
||||
t.Fatalf("store OIDC JIT session key: %v", err)
|
||||
}
|
||||
draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{
|
||||
Manifest: identity.ManifestV1{
|
||||
SchemaVersion: 1, Issuer: issuer, TenantID: oidcJITTenantID, ApplicationID: uuid.NewString(),
|
||||
Capabilities: []string{"oidc_login", "api_access"}, Audience: "gateway-api", Scopes: []string{"gateway.access"},
|
||||
Clients: identity.ManifestClients{BrowserLogin: &identity.ManifestClient{ClientID: "gateway-public-test"}},
|
||||
},
|
||||
SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply OIDC JIT manifest: %v", err)
|
||||
}
|
||||
draft, err = db.MarkIdentityRevisionValidated(ctx, draft.ID, draft.Version, "oidc-jit-test", "oidc-jit-test")
|
||||
if err != nil {
|
||||
t.Fatalf("validate OIDC JIT revision: %v", err)
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
func restoreOIDCJITIdentityRevision(t *testing.T, ctx context.Context, db *store.Store, previous identity.Revision, hadPrevious bool, testRevisionIDs []string) {
|
||||
t.Helper()
|
||||
isTestRevision := func(id string) bool {
|
||||
for _, testID := range testRevisionIDs {
|
||||
if id == testID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if active, err := db.ActiveIdentityConfigurationRevision(ctx); err == nil && isTestRevision(active.ID) {
|
||||
if _, disableErr := db.DisableActiveIdentityRevision(ctx, active.Version, "oidc-jit-cleanup", "oidc-jit-cleanup"); disableErr != nil {
|
||||
t.Errorf("disable test identity revision during cleanup: %v", disableErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if hadPrevious {
|
||||
refreshed, err := db.IdentityConfigurationRevision(ctx, previous.ID)
|
||||
if err != nil {
|
||||
t.Errorf("reload previous identity revision during cleanup: %v", err)
|
||||
return
|
||||
}
|
||||
if refreshed.State == identity.RevisionSuperseded {
|
||||
refreshed, err = db.MarkIdentityRevisionValidated(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore")
|
||||
if err == nil {
|
||||
_, _, err = db.ActivateIdentityRevision(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("restore previous identity revision: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(testRevisionIDs) > 0 {
|
||||
if _, err := db.Pool().Exec(ctx, `DELETE FROM gateway_identity_configuration_revisions WHERE id = ANY($1::uuid[])`, testRevisionIDs); err != nil {
|
||||
t.Errorf("delete test identity revisions: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var currentOIDCTestNonce string
|
||||
|
||||
func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
|
||||
@@ -364,7 +449,7 @@ func signedOIDCJITToken(t *testing.T, key *ecdsa.PrivateKey, issuer string, subj
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"iss": issuer, "aud": "gateway-api", "sub": subject, "tid": "auth-center-test-tenant",
|
||||
"iss": issuer, "aud": "gateway-api", "sub": subject, "tid": oidcJITTenantID,
|
||||
"preferred_username": "oidc-jit-acceptance", "roles": []string{"gateway.admin"},
|
||||
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
|
||||
Reference in New Issue
Block a user