From a767dc42c0feedba06565d60a26365099e69f493 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:31:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor(identity):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E8=AE=A4=E8=AF=81=E9=85=8D=E7=BD=AE=E6=9D=A5?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除旧 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 --- .env.example | 55 +--- Dockerfile | 6 +- README.md | 26 +- apps/api/internal/config/config.go | 252 +++++------------- apps/api/internal/config/config_test.go | 189 +++---------- apps/api/internal/httpapi/identity_runtime.go | 29 +- .../httpapi/oidc_jit_integration_test.go | 161 ++++++++--- .../api/internal/httpapi/oidc_session_test.go | 20 +- .../httpapi/oidc_user_middleware_test.go | 14 +- ...security_event_connection_handlers_test.go | 8 +- apps/api/internal/httpapi/server.go | 61 +++-- .../pages/admin/UnifiedIdentityPanel.test.tsx | 1 + .../src/pages/admin/UnifiedIdentityPanel.tsx | 3 +- docker-compose.yml | 34 +-- docs/oidc-jit-provisioning.md | 29 +- docs/security/ssf-session-revocation.md | 27 +- ...standard-identity-runtime-configuration.md | 16 +- scripts/dev.sh | 3 - 18 files changed, 355 insertions(+), 579 deletions(-) diff --git a/.env.example b/.env.example index b2591c7..6439feb 100644 --- a/.env.example +++ b/.env.example @@ -23,52 +23,19 @@ CONFIG_JWT_SECRET=this is a very secret secret # - hybrid: both sources are accepted and separated by gateway_users.source. IDENTITY_MODE=hybrid -# Auth Center stable OIDC verification. Keep legacy HS256 during the staged rollout. -OIDC_ENABLED=false -OIDC_ISSUER=https://auth.51easyai.com/issuer/shared -OIDC_AUDIENCE=https://api.51easyai.com/gateway -OIDC_TENANT_ID= -OIDC_ROLE_PREFIX=gateway. -OIDC_REQUIRED_SCOPES=gateway.access -OIDC_JWKS_CACHE_TTL_SECONDS=300 -OIDC_ACCEPT_LEGACY_HS256=true -OIDC_INTROSPECTION_ENABLED=false -# Legacy/static fallback only. New SSF connections receive the machine -# credential once in the web flow and persist it in the configured SecretStore. -OIDC_INTROSPECTION_CLIENT_ID= -OIDC_INTROSPECTION_CLIENT_SECRET= -# SSF/CAEP is activated by the durable connection created in System Settings; -# there is no enable flag and no Push Bearer in environment variables. +# Unified identity business settings are managed in System Settings > Unified +# Identity. Deployment only supplies the SecretStore and infrastructure timing. AI_GATEWAY_PUBLIC_BASE_URL=http://localhost:8088 -OIDC_SECURITY_EVENTS_SECRET_STORE=file -OIDC_SECURITY_EVENTS_SECRET_DIR=.local-secrets/ssf +IDENTITY_SECRET_STORE=file +IDENTITY_SECRET_DIR=.local-secrets/identity # Kubernetes uses one pre-provisioned empty Secret and narrowly scoped RBAC. -# OIDC_SECURITY_EVENTS_SECRET_STORE=kubernetes -# OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE=easyai -# OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME=easyai-gateway-security-events -# OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER=https://kubernetes.default.svc -OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 -OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 -OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 -# Controlled JIT is opt-in. When enabled, bind the validated Auth Center tid to -# one existing active Gateway tenant; tokens never create Gateway tenants. -OIDC_JIT_PROVISIONING_ENABLED=false -OIDC_GATEWAY_TENANT_KEY= -OIDC_BROWSER_SESSION_ENABLED=true -# Staging/production must use true. Local HTTP development may use false. -OIDC_SESSION_COOKIE_SECURE=false -# Public Client ID generated by Auth Center Control Plane. No Client Secret is used. -OIDC_CLIENT_ID= -OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ -# Generate a dedicated value with: openssl rand -base64 32 -# Keep the real value only in a Git-ignored local file or Secret Manager. -OIDC_SESSION_ENCRYPTION_KEY= -OIDC_SESSION_IDLE_TTL_SECONDS=1800 -OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800 -OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 -VITE_OIDC_BROWSER_SESSION_ENABLED=true -VITE_OIDC_ENABLED=false +# IDENTITY_SECRET_STORE=kubernetes +# IDENTITY_KUBERNETES_NAMESPACE=easyai +# IDENTITY_KUBERNETES_SECRET_NAME=easyai-gateway-identity +# IDENTITY_KUBERNETES_API_SERVER=https://kubernetes.default.svc +IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 +IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 +IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 AI_GATEWAY_WEB_BASE_URL=http://localhost:5178 AI_GATEWAY_WEB_BASE_PATH=/ AI_GATEWAY_GO_BUILD_IMAGE=golang:1.26.3-alpine diff --git a/Dockerfile b/Dockerfile index 0bd8467..dfe6ed3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,13 +71,9 @@ COPY packages packages COPY apps/web apps/web ARG VITE_GATEWAY_API_BASE_URL=/gateway-api -ARG VITE_OIDC_ENABLED=false -ARG VITE_OIDC_BROWSER_SESSION_ENABLED=true ARG VITE_BASE_PATH=/ ENV VITE_GATEWAY_API_BASE_URL=$VITE_GATEWAY_API_BASE_URL -ENV VITE_OIDC_ENABLED=$VITE_OIDC_ENABLED \ - VITE_OIDC_BROWSER_SESSION_ENABLED=$VITE_OIDC_BROWSER_SESSION_ENABLED \ - VITE_BASE_PATH=$VITE_BASE_PATH +ENV VITE_BASE_PATH=$VITE_BASE_PATH RUN pnpm --filter @easyai-ai-gateway/web build FROM ${WEB_RUNTIME_IMAGE} AS web diff --git a/README.md b/README.md index 5e33e10..60fa346 100644 --- a/README.md +++ b/README.md @@ -36,27 +36,21 @@ pnpm dev - PostgreSQL: 目标版本 18,默认使用宿主机 `localhost:5432` 上的 `easyai-pgvector` 实例,并使用独立库 `easyai_ai_gateway` - 身份模式: 默认 `IDENTITY_MODE=hybrid`,可同时测试 Gateway 本地账号注册登录、可选邀请码和 `server-main` JWT / API Key 对接。 -### Auth Center OIDC 受控 JIT +### Auth Center 统一认证 -OIDC 用户通过签名、Issuer、Audience、`tid`、Scope 和应用角色校验后,Gateway 可在首个受保护请求前创建本地业务投影。该投影只用于 Gateway 的用户组、钱包、API Key、任务归属和审计,不修改 Auth Center Claims,也不迁移或合并历史账号。 +统一认证不再通过 `OIDC_*` 或 `VITE_OIDC_*` 业务环境变量配置。管理员先在 Auth Center 的通用“应用接入”向导选择 OIDC 登录、API 验证、机器调用、Token Introspection 和 SSF 会话撤销等标准能力,再到 Gateway 的“系统设置 → 统一认证”填写 Auth Center 地址、一次性接入码、API/Web 公网地址和本地租户映射。Gateway 自动领取标准 Manifest 与一次性机器凭据,验证通过后热切换,无需重启。 + +部署环境只提供 SecretStore 等启动级配置: ```dotenv -OIDC_ENABLED=true -OIDC_JIT_PROVISIONING_ENABLED=true -OIDC_GATEWAY_TENANT_KEY=default -OIDC_BROWSER_SESSION_ENABLED=true -OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP;生产必须为 true -OIDC_CLIENT_ID=<公共 Client ID> -OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ -OIDC_SESSION_ENCRYPTION_KEY= +IDENTITY_SECRET_STORE=file +IDENTITY_SECRET_DIR=.local-secrets/identity +IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 +IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 +IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 ``` -`OIDC_GATEWAY_TENANT_KEY` 必须指向已有且启用的 Gateway 租户及其默认用户组;JIT 不会从 Token 自动创建租户。开关默认关闭,关闭后不再创建新用户,但仍可解析此前创建的 `source=oidc` 映射。 - -Web Console 使用公共 Client + PKCE + Gateway BFF Session:Gateway 服务端换码并加密保存 Access/Refresh/ID Token,浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新;Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret,也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。 - -可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token,并删除同一租户 Subject 的 BFF Session。功能由“系统设置”中的持久连接启用,不使用环境变量开关;Gateway 后端自动生成和托管 Push Bearer,并复用现有 RFC 7662 机器 Client。推送不健康时自动切换 RFC 7662,内省也不可用时 OIDC Fail Closed;API Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。 +OIDC 用户通过签名、Issuer、Audience、`tid`、Scope 和应用角色校验后,Gateway 可按当前 Active Revision 的策略创建本地业务投影。Web Console 使用公共 Client + PKCE + Gateway BFF Session;浏览器 JavaScript 只持有 HttpOnly 随机 Session Cookie。可选 SSF/CAEP 能力自动复用同一机器凭据,推送不健康时降级到 RFC 7662,内省也不可用时 OIDC Fail Closed。完整行为见 [统一认证运行时配置](docs/standard-identity-runtime-configuration.md)、[OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)和 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。 `pnpm dev` 会先创建数据库并执行 migration,然后并行启动: diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 310291d..f373963 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -1,7 +1,6 @@ package config import ( - "encoding/base64" "errors" "log/slog" "net/url" @@ -16,68 +15,43 @@ const ( ) type Config struct { - AppEnv string - HTTPAddr string - DatabaseURL string - IdentityMode string - JWTSecret string - ServerMainBaseURL string - ServerMainInternalToken string - ServerMainInternalKey string - ServerMainInternalSecret string - OIDCEnabled bool - OIDCIssuer string - OIDCAudience string - OIDCTenantID string - OIDCRolePrefix string - OIDCRequiredScopes []string - OIDCJWKSCacheTTLSeconds int - OIDCAcceptLegacyHS256 bool - OIDCIntrospectionEnabled bool - OIDCIntrospectionClientID string - OIDCIntrospectionClientSecret string - OIDCSecurityEventsSecretStore string - OIDCSecurityEventsSecretDir string - OIDCSecurityEventsKubernetesNamespace string - OIDCSecurityEventsKubernetesSecretName string - OIDCSecurityEventsKubernetesAPIServer string - OIDCSecurityEventsKubernetesTokenFile string - OIDCSecurityEventsKubernetesCAFile string - OIDCSecurityEventsHeartbeatIntervalSeconds int - OIDCSecurityEventsStaleAfterSeconds int - OIDCSecurityEventsClockSkewSeconds int - OIDCJITProvisioningEnabled bool - OIDCGatewayTenantKey string - OIDCBrowserSessionEnabled bool - OIDCSessionCookieSecure bool - OIDCClientID string - OIDCRedirectURI string - OIDCPostLogoutRedirectURI string - OIDCSessionEncryptionKey string - OIDCSessionIdleTTLSeconds int - OIDCSessionAbsoluteTTLSeconds int - OIDCSessionRefreshBeforeSeconds int - PublicBaseURL string - WebBaseURL string - LocalGeneratedStorageDir string - LocalUploadedStorageDir string - LocalTempAssetTTLHours int - TaskProgressCallbackEnabled bool - TaskProgressCallbackURL string - TaskProgressCallbackTimeoutMS string - TaskProgressCallbackMaxAttempts string - CORSAllowedOrigin string - GlobalHTTPProxy string - GlobalHTTPProxySource string - LogLevel slog.Level + AppEnv string + HTTPAddr string + DatabaseURL string + IdentityMode string + JWTSecret string + ServerMainBaseURL string + ServerMainInternalToken string + ServerMainInternalKey string + ServerMainInternalSecret string + IdentitySecretStore string + IdentitySecretDir string + IdentityKubernetesNamespace string + IdentityKubernetesSecretName string + IdentityKubernetesAPIServer string + IdentityKubernetesTokenFile string + IdentityKubernetesCAFile string + IdentitySecurityEventHeartbeatIntervalSeconds int + IdentitySecurityEventStaleAfterSeconds int + IdentitySecurityEventClockSkewSeconds int + PublicBaseURL string + WebBaseURL string + LocalGeneratedStorageDir string + LocalUploadedStorageDir string + LocalTempAssetTTLHours int + TaskProgressCallbackEnabled bool + TaskProgressCallbackURL string + TaskProgressCallbackTimeoutMS string + TaskProgressCallbackMaxAttempts string + CORSAllowedOrigin string + GlobalHTTPProxy string + GlobalHTTPProxySource string + LogLevel slog.Level } func Load() Config { globalProxy := LoadGlobalHTTPProxyStatus() appEnv := env("APP_ENV", "development") - oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/") - introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "") - introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "") return Config{ AppEnv: appEnv, HTTPAddr: env("HTTP_ADDR", ":8088"), @@ -88,49 +62,25 @@ func Load() Config { env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/", ), - ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), - ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), - ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), - OIDCEnabled: env("OIDC_ENABLED", "false") == "true", - OIDCIssuer: oidcIssuer, - OIDCAudience: env("OIDC_AUDIENCE", ""), - OIDCTenantID: env("OIDC_TENANT_ID", ""), - OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), - OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")), - OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), - OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", - OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true", - OIDCIntrospectionClientID: introspectionClientID, - OIDCIntrospectionClientSecret: introspectionClientSecret, - OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"), - OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"), - OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")), - OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"), - OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"), - OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"), - OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), - OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60), - OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180), - OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60), - OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true", - OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""), - OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true", - OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE", - strconv.FormatBool(!isLocalEnvironment(appEnv)), - ) == "true", - OIDCClientID: env("OIDC_CLIENT_ID", ""), - OIDCRedirectURI: env("OIDC_REDIRECT_URI", ""), - OIDCPostLogoutRedirectURI: env("OIDC_POST_LOGOUT_REDIRECT_URI", ""), - OIDCSessionEncryptionKey: env("OIDC_SESSION_ENCRYPTION_KEY", ""), - OIDCSessionIdleTTLSeconds: envInt("OIDC_SESSION_IDLE_TTL_SECONDS", 1800), - OIDCSessionAbsoluteTTLSeconds: envInt("OIDC_SESSION_ABSOLUTE_TTL_SECONDS", 28800), - OIDCSessionRefreshBeforeSeconds: envInt("OIDC_SESSION_REFRESH_BEFORE_SECONDS", 60), - PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), - WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), - LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), - LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), - LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), - TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", + ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), + ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), + ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), + IdentitySecretStore: env("IDENTITY_SECRET_STORE", "file"), + IdentitySecretDir: env("IDENTITY_SECRET_DIR", ".local-secrets/identity"), + IdentityKubernetesNamespace: env("IDENTITY_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")), + IdentityKubernetesSecretName: env("IDENTITY_KUBERNETES_SECRET_NAME", "easyai-gateway-identity"), + IdentityKubernetesAPIServer: env("IDENTITY_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"), + IdentityKubernetesTokenFile: env("IDENTITY_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"), + IdentityKubernetesCAFile: env("IDENTITY_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), + IdentitySecurityEventHeartbeatIntervalSeconds: envInt("IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60), + IdentitySecurityEventStaleAfterSeconds: envInt("IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180), + IdentitySecurityEventClockSkewSeconds: envInt("IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60), + PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), + WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), + LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), + LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), + LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), + TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL", strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks", ), @@ -144,96 +94,29 @@ func Load() Config { } func (c Config) Validate() error { - switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) { + switch strings.ToLower(strings.TrimSpace(c.IdentitySecretStore)) { case "": case "file": - if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" { - return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore") + if strings.TrimSpace(c.IdentitySecretDir) == "" { + return errors.New("IDENTITY_SECRET_DIR is required for the file SecretStore") } case "kubernetes": - if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" { - return errors.New("Kubernetes security event SecretStore requires namespace and Secret name") + if strings.TrimSpace(c.IdentityKubernetesNamespace) == "" || strings.TrimSpace(c.IdentityKubernetesSecretName) == "" { + return errors.New("Kubernetes identity SecretStore requires namespace and Secret name") } default: - return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes") + return errors.New("IDENTITY_SECRET_STORE must be file or kubernetes") } - if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 { - if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 || - c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds || - c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 { - return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid") - } - } - if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" { - return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true") - } - if c.OIDCEnabled && c.OIDCBrowserSessionEnabled { - for _, scope := range c.OIDCRequiredScopes { - if strings.EqualFold(strings.TrimSpace(scope), "offline_access") { - return errors.New("OIDC_REQUIRED_SCOPES cannot contain offline_access for browser sessions") - } - } - if strings.TrimSpace(c.OIDCClientID) == "" { - return errors.New("OIDC_CLIENT_ID is required when OIDC browser sessions are enabled") - } - if !validPublicRedirectURL(c.OIDCRedirectURI) { - return errors.New("OIDC_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)") - } - if !validPublicRedirectURL(c.OIDCPostLogoutRedirectURI) { - return errors.New("OIDC_POST_LOGOUT_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)") - } - if _, err := c.OIDCSessionEncryptionKeyBytes(); err != nil { - return err - } - if c.OIDCSessionIdleTTLSeconds <= 0 || c.OIDCSessionAbsoluteTTLSeconds <= c.OIDCSessionIdleTTLSeconds { - return errors.New("OIDC session idle TTL must be positive and shorter than the absolute TTL") - } - if c.OIDCSessionRefreshBeforeSeconds <= 0 || c.OIDCSessionRefreshBeforeSeconds >= c.OIDCSessionIdleTTLSeconds { - return errors.New("OIDC_SESSION_REFRESH_BEFORE_SECONDS must be positive and shorter than the idle TTL") - } - if !isLocalEnvironment(c.AppEnv) && !c.OIDCSessionCookieSecure { - return errors.New("OIDC_SESSION_COOKIE_SECURE must be true outside local development and tests") - } - for _, origin := range strings.Split(c.CORSAllowedOrigin, ",") { - if strings.TrimSpace(origin) == "*" { - return errors.New("CORS_ALLOWED_ORIGIN cannot contain * when OIDC browser sessions are enabled") - } + if c.IdentitySecurityEventHeartbeatIntervalSeconds != 0 || c.IdentitySecurityEventStaleAfterSeconds != 0 || c.IdentitySecurityEventClockSkewSeconds != 0 { + if c.IdentitySecurityEventHeartbeatIntervalSeconds <= 0 || + c.IdentitySecurityEventStaleAfterSeconds < 2*c.IdentitySecurityEventHeartbeatIntervalSeconds || + c.IdentitySecurityEventClockSkewSeconds < 0 || c.IdentitySecurityEventClockSkewSeconds > 300 { + return errors.New("identity security event heartbeat, stale threshold, or clock skew is invalid") } } return nil } -func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) { - raw := strings.TrimSpace(c.OIDCSessionEncryptionKey) - for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} { - decoded, err := encoding.DecodeString(raw) - if err == nil && len(decoded) == 32 { - return decoded, nil - } - } - return nil, errors.New("OIDC_SESSION_ENCRYPTION_KEY must be a base64-encoded 32-byte random key") -} - -func validPublicRedirectURL(raw string) bool { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return false - } - if parsed.Scheme == "https" { - return true - } - return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") -} - -func isLocalEnvironment(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "development", "dev", "local", "test": - return true - default: - return false - } -} - type GlobalHTTPProxyStatus struct { HTTPProxy string Source string @@ -288,17 +171,6 @@ func normalizePostgresURL(raw string) string { return parsed.String() } -func splitCSV(value string) []string { - items := strings.Split(value, ",") - result := make([]string, 0, len(items)) - for _, item := range items { - if trimmed := strings.TrimSpace(item); trimmed != "" { - result = append(result, trimmed) - } - } - return result -} - func withDatabase(raw string, databaseName string) string { parsed, err := url.Parse(raw) if err != nil || databaseName == "" { diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index 989d054..1e3f7b9 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -1,182 +1,59 @@ package config import ( - "bytes" - "encoding/base64" + "fmt" "strings" "testing" ) -func TestLoadOIDCJITProvisioningDefaultsToDisabled(t *testing.T) { - t.Setenv("OIDC_JIT_PROVISIONING_ENABLED", "") - t.Setenv("OIDC_GATEWAY_TENANT_KEY", "") +func TestLoadIdentitySecretStoreUsesNewEnvironmentNamesAndIgnoresLegacyBusinessValues(t *testing.T) { + t.Setenv("IDENTITY_SECRET_STORE", "file") + t.Setenv("IDENTITY_SECRET_DIR", ".test-secrets/identity") + t.Setenv("OIDC_ISSUER", "https://legacy-sensitive.example/issuer") + t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "legacy-sensitive-secret") + t.Setenv("OIDC_SESSION_ENCRYPTION_KEY", "legacy-sensitive-session-key") cfg := Load() - if cfg.OIDCJITProvisioningEnabled { - t.Fatal("OIDC JIT provisioning must be disabled by default") + if cfg.IdentitySecretStore != "file" || cfg.IdentitySecretDir != ".test-secrets/identity" { + t.Fatalf("identity SecretStore = %q %q", cfg.IdentitySecretStore, cfg.IdentitySecretDir) } - if cfg.OIDCGatewayTenantKey != "" { - t.Fatalf("unexpected gateway tenant key: %q", cfg.OIDCGatewayTenantKey) + printed := fmt.Sprintf("%+v", cfg) + for _, legacyValue := range []string{"legacy-sensitive.example", "legacy-sensitive-secret", "legacy-sensitive-session-key"} { + if strings.Contains(printed, legacyValue) { + t.Fatalf("legacy identity business setting was loaded into Config: %q", legacyValue) + } } } -func TestValidateRequiresGatewayTenantKeyWhenOIDCJITIsEnabled(t *testing.T) { - cfg := Config{ - OIDCEnabled: true, - OIDCJITProvisioningEnabled: true, +func TestValidateIdentityFileSecretStoreRequiresDirectory(t *testing.T) { + cfg := Config{IdentitySecretStore: "file"} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "IDENTITY_SECRET_DIR") { + t.Fatalf("Validate() error = %v, want missing identity secret directory", err) } - - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "OIDC_GATEWAY_TENANT_KEY") { - t.Fatalf("Validate() error = %v, want missing OIDC_GATEWAY_TENANT_KEY", err) - } - - cfg.OIDCGatewayTenantKey = "default" + cfg.IdentitySecretDir = ".test-secrets/identity" if err := cfg.Validate(); err != nil { - t.Fatalf("Validate() with tenant key: %v", err) + t.Fatalf("valid file SecretStore was rejected: %v", err) } } -func TestLoadOIDCBrowserSessionUsesSafeEnvironmentDefaults(t *testing.T) { - t.Setenv("APP_ENV", "development") - t.Setenv("OIDC_BROWSER_SESSION_ENABLED", "") - t.Setenv("OIDC_SESSION_COOKIE_SECURE", "") - - cfg := Load() - if !cfg.OIDCBrowserSessionEnabled { - t.Fatal("OIDC browser session should be enabled by default") +func TestValidateIdentityKubernetesSecretStore(t *testing.T) { + cfg := Config{IdentitySecretStore: "kubernetes", IdentityKubernetesSecretName: "easyai-gateway-identity"} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") { + t.Fatalf("Validate() error = %v, want missing namespace", err) } - if cfg.OIDCSessionCookieSecure { - t.Fatal("development cookie should allow localhost HTTP by default") - } - - t.Setenv("APP_ENV", "production") - cfg = Load() - if !cfg.OIDCSessionCookieSecure { - t.Fatal("production OIDC session cookie must default to Secure") - } - - t.Setenv("APP_ENV", "staging") - cfg = Load() - if !cfg.OIDCSessionCookieSecure { - t.Fatal("staging OIDC session cookie must default to Secure") - } -} - -func TestValidateRejectsInsecureNonLocalOIDCBrowserSession(t *testing.T) { - cfg := Config{ - AppEnv: "staging", - OIDCEnabled: true, - OIDCBrowserSessionEnabled: true, - OIDCSessionCookieSecure: false, - CORSAllowedOrigin: "https://gateway.example.com", - OIDCClientID: "gateway-public", - OIDCRedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback", - OIDCPostLogoutRedirectURI: "https://gateway.example.com/", - OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)), - OIDCSessionIdleTTLSeconds: 1800, - OIDCSessionAbsoluteTTLSeconds: 28800, - OIDCSessionRefreshBeforeSeconds: 60, - } - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_COOKIE_SECURE") { - t.Fatalf("Validate() error = %v, want insecure non-local cookie rejection", err) - } - - cfg.OIDCSessionCookieSecure = true - cfg.CORSAllowedOrigin = "*" - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "CORS_ALLOWED_ORIGIN") { - t.Fatalf("Validate() error = %v, want wildcard credentialed CORS rejection", err) - } -} - -func TestValidateRequiresCompletePublicClientSessionConfiguration(t *testing.T) { - cfg := Config{ - AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true, - OIDCSessionCookieSecure: false, CORSAllowedOrigin: "http://localhost:5178", - } - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_CLIENT_ID") { - t.Fatalf("Validate() error = %v, want incomplete public client rejection", err) - } - - cfg.OIDCClientID = "gateway-public" - cfg.OIDCRedirectURI = "http://localhost:8088/api/v1/auth/oidc/callback" - cfg.OIDCPostLogoutRedirectURI = "http://localhost:5178/" - cfg.OIDCSessionEncryptionKey = base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)) - cfg.OIDCSessionIdleTTLSeconds = 1800 - cfg.OIDCSessionAbsoluteTTLSeconds = 28800 - cfg.OIDCSessionRefreshBeforeSeconds = 60 + cfg.IdentityKubernetesNamespace = "easyai" if err := cfg.Validate(); err != nil { - t.Fatalf("Validate() valid public session config: %v", err) - } - key, err := cfg.OIDCSessionEncryptionKeyBytes() - if err != nil || len(key) != 32 { - t.Fatalf("decoded encryption key len=%d err=%v", len(key), err) - } -} - -func TestValidateRejectsPlaintextOrWrongLengthSessionKey(t *testing.T) { - cfg := Config{ - AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true, - OIDCClientID: "gateway-public", OIDCRedirectURI: "http://localhost:8088/callback", - OIDCPostLogoutRedirectURI: "http://localhost:5178/", OIDCSessionEncryptionKey: strings.Repeat("x", 32), - OIDCSessionIdleTTLSeconds: 1800, OIDCSessionAbsoluteTTLSeconds: 28800, OIDCSessionRefreshBeforeSeconds: 60, - CORSAllowedOrigin: "http://localhost:5178", - } - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_ENCRYPTION_KEY") { - t.Fatalf("Validate() error = %v, want encoded AES-256 key rejection", err) - } -} - -func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) { - cfg := Config{ - AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true, - OIDCRequiredScopes: []string{"gateway.access", "offline_access"}, - } - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "offline_access") { - t.Fatalf("Validate() error = %v, want offline_access rejection", err) - } -} - -func TestSecurityEventsUseDurableConnectionInsteadOfAnEnableFlag(t *testing.T) { - t.Setenv("OIDC_SECURITY_EVENTS_ENABLED", "true") - t.Setenv("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", "https://untrusted.example/ssf") - t.Setenv("OIDC_SECURITY_EVENTS_BEARER_SECRET", "must-not-be-loaded") - t.Setenv("OIDC_SECURITY_EVENTS_SECRET_STORE", "file") - t.Setenv("OIDC_SECURITY_EVENTS_SECRET_DIR", ".test-secrets/ssf") - - cfg := Load() - if cfg.OIDCSecurityEventsSecretStore != "file" || cfg.OIDCSecurityEventsSecretDir != ".test-secrets/ssf" { - t.Fatalf("secret directory=%q", cfg.OIDCSecurityEventsSecretDir) - } - if err := (Config{}).Validate(); err != nil { - t.Fatalf("an absent security event connection changed existing config: %v", err) - } -} - -func TestValidateKubernetesSecurityEventSecretStore(t *testing.T) { - config := Config{OIDCSecurityEventsSecretStore: "kubernetes", OIDCSecurityEventsKubernetesSecretName: "easyai-gateway-security-events"} - if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") { - t.Fatalf("Validate() error=%v, want missing namespace", err) - } - config.OIDCSecurityEventsKubernetesNamespace = "easyai" - if err := config.Validate(); err != nil { t.Fatalf("valid Kubernetes SecretStore was rejected: %v", err) } } -func TestValidateSecurityEventTiming(t *testing.T) { - config := Config{OIDCSecurityEventsHeartbeatIntervalSeconds: 60, OIDCSecurityEventsStaleAfterSeconds: 60, OIDCSecurityEventsClockSkewSeconds: 60} - if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") { - t.Fatalf("Validate() error=%v, want invalid stale threshold", err) - } -} - -func TestLoadSecurityEventsReusesOnlyTheRFC7662MachineClient(t *testing.T) { - t.Setenv("OIDC_INTROSPECTION_CLIENT_ID", "gateway-service") - t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "service-secret") - - cfg := Load() - if cfg.OIDCIntrospectionClientID != "gateway-service" || cfg.OIDCIntrospectionClientSecret != "service-secret" { - t.Fatal("RFC 7662 machine credentials were not preserved") +func TestValidateIdentitySecurityEventTiming(t *testing.T) { + cfg := Config{ + IdentitySecurityEventHeartbeatIntervalSeconds: 60, + IdentitySecurityEventStaleAfterSeconds: 60, + IdentitySecurityEventClockSkewSeconds: 60, + } + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") { + t.Fatalf("Validate() error = %v, want invalid stale threshold", err) } } diff --git a/apps/api/internal/httpapi/identity_runtime.go b/apps/api/internal/httpapi/identity_runtime.go index cea5c6c..192a9ef 100644 --- a/apps/api/internal/httpapi/identity_runtime.go +++ b/apps/api/internal/httpapi/identity_runtime.go @@ -44,28 +44,37 @@ func (s *Server) currentIdentityRuntime() *identityRequestRuntime { // Compatibility path for focused HTTP tests. NewServer never uses these // static fields after identity revisions are enabled. - if !s.cfg.OIDCEnabled && s.cfg.OIDCIssuer == "" && s.oidcClient == nil && s.oidcSessions == nil && s.oidcSessionCipher == nil && s.securityEventManager == nil { + if s.identityTestRevision.ID == "" && s.identityTestRevision.Issuer == "" && s.oidcClient == nil && s.oidcSessions == nil && s.oidcSessionCipher == nil && s.securityEventManager == nil && !s.identityTestBrowserEnabled { return nil } - webBaseURL := s.cfg.WebBaseURL + revision := s.identityTestRevision + if revision.State == "" { + revision.State = identity.RevisionActive + } + webBaseURL := revision.WebBaseURL + if webBaseURL == "" { + webBaseURL = s.cfg.WebBaseURL + } if webBaseURL == "" { webBaseURL = s.cfg.CORSAllowedOrigin } + revision.WebBaseURL = webBaseURL + if revision.PublicBaseURL == "" { + revision.PublicBaseURL = s.cfg.PublicBaseURL + } + if revision.SessionAbsoluteSeconds <= 0 { + revision.SessionAbsoluteSeconds = 28800 + } var verifier oidcTokenVerifier if s.auth != nil { verifier = s.auth.OIDCVerifier } return &identityRequestRuntime{ - Revision: identity.Revision{ - State: identity.RevisionActive, Issuer: s.cfg.OIDCIssuer, LocalTenantKey: s.cfg.OIDCGatewayTenantKey, - WebBaseURL: webBaseURL, PublicBaseURL: s.cfg.PublicBaseURL, - JITEnabled: s.cfg.OIDCJITProvisioningEnabled, LegacyJWTEnabled: s.cfg.OIDCAcceptLegacyHS256, - SessionAbsoluteSeconds: s.cfg.OIDCSessionAbsoluteTTLSeconds, - }, + Revision: revision, Verifier: verifier, PublicClient: s.oidcClient, Sessions: s.oidcSessions, SessionCipher: s.oidcSessionCipher, SecurityEvents: s.securityEventManager, - CookieSecure: s.cfg.OIDCSessionCookieSecure, - BrowserEnabled: s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled, + CookieSecure: s.identityTestCookieSecure || strings.HasPrefix(strings.ToLower(revision.PublicBaseURL), "https://"), + BrowserEnabled: s.identityTestBrowserEnabled || s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil, } } diff --git a/apps/api/internal/httpapi/oidc_jit_integration_test.go b/apps/api/internal/httpapi/oidc_jit_integration_test.go index 2aa7b37..238d477 100644 --- a/apps/api/internal/httpapi/oidc_jit_integration_test.go +++ b/apps/api/internal/httpapi/oidc_jit_integration_test.go @@ -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(), diff --git a/apps/api/internal/httpapi/oidc_session_test.go b/apps/api/internal/httpapi/oidc_session_test.go index 9c3d442..0c67a9a 100644 --- a/apps/api/internal/httpapi/oidc_session_test.go +++ b/apps/api/internal/httpapi/oidc_session_test.go @@ -14,6 +14,7 @@ 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/oidcsession" ) @@ -21,10 +22,9 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize?request=redacted"} server := &Server{ - cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, OIDCSessionCookieSecure: true}, auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client, oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + identityTestCookieSecure: true, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil) recorder := httptest.NewRecorder() @@ -50,7 +50,6 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) { cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) server := &Server{ - cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true}, auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{}, oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } @@ -86,10 +85,7 @@ func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) { t.Run(test.name, func(t *testing.T) { var logs bytes.Buffer server := &Server{ - cfg: config.Config{ - OIDCEnabled: true, OIDCBrowserSessionEnabled: true, - WebBaseURL: "http://localhost:5178", - }, + cfg: config.Config{WebBaseURL: "http://localhost:5178"}, auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{}, oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewJSONHandler(&logs, nil)), @@ -134,7 +130,7 @@ func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) { func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) { sessions := &fakeOIDCSessions{} - server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions} + server := &Server{oidcSessions: sessions, identityTestCookieSecure: true} request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "opaque-session"}) recorder := httptest.NewRecorder() @@ -151,7 +147,11 @@ func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) { } func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { - server := &Server{cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, CORSAllowedOrigin: "https://gateway.example.com"}} + server := &Server{ + cfg: config.Config{CORSAllowedOrigin: "https://gateway.example.com"}, + identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"}, + identityTestBrowserEnabled: true, + } next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) handler := server.protectOIDCSessionCookie(next) for _, test := range []struct { @@ -182,7 +182,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { } func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) { - server := &Server{cfg: config.Config{OIDCEnabled: false, OIDCBrowserSessionEnabled: true}} + server := &Server{} request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"}) recorder := httptest.NewRecorder() diff --git a/apps/api/internal/httpapi/oidc_user_middleware_test.go b/apps/api/internal/httpapi/oidc_user_middleware_test.go index 3cf80b8..35ad1a0 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware_test.go +++ b/apps/api/internal/httpapi/oidc_user_middleware_test.go @@ -11,7 +11,7 @@ import ( "testing" "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" ) @@ -41,10 +41,8 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) { UserGroupID: "6dcf86f2-8eaf-4b43-8e69-181315db24f0", }}} server := &Server{ - cfg: config.Config{ - OIDCIssuer: "https://auth.test.example/realms/easyai", - OIDCGatewayTenantKey: "default", - OIDCJITProvisioningEnabled: true, + identityTestRevision: identity.Revision{ + Issuer: "https://auth.test.example/issuer", LocalTenantKey: "default", JITEnabled: true, }, oidcUserResolver: resolver, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), @@ -116,9 +114,9 @@ func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := &Server{ - cfg: config.Config{OIDCIssuer: "https://auth.test.example", OIDCGatewayTenantKey: "default"}, - oidcUserResolver: &fakeOIDCUserResolver{err: test.err}, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + identityTestRevision: identity.Revision{Issuer: "https://auth.test.example", LocalTenantKey: "default"}, + oidcUserResolver: &fakeOIDCUserResolver{err: test.err}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request = request.WithContext(auth.WithUser(request.Context(), &auth.User{ diff --git a/apps/api/internal/httpapi/security_event_connection_handlers_test.go b/apps/api/internal/httpapi/security_event_connection_handlers_test.go index f8b6a8c..22e11d9 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers_test.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers_test.go @@ -8,7 +8,7 @@ import ( "testing" "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" ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" ) @@ -63,9 +63,9 @@ func TestSecurityEventConnectionWriteHeaders(t *testing.T) { } func TestSecurityEventPrerequisitesNeverExposeSecrets(t *testing.T) { - server := &Server{cfg: config.Config{ - OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer/shared", OIDCTenantID: "stable-tenant", - OIDCIntrospectionClientID: "gateway-machine", OIDCIntrospectionClientSecret: "must-not-escape", + server := &Server{identityTestRevision: identity.Revision{ + Issuer: "https://auth.example/issuer/shared", TenantID: "stable-tenant", + MachineClientID: "gateway-machine", MachineCredentialRef: "identity-machine-test", PublicBaseURL: "https://gateway.example", }} payload, err := json.Marshal(server.securityEventPrerequisites()) diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 24d4389..b6050f7 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -20,23 +20,26 @@ import ( ) type Server struct { - ctx context.Context - cfg config.Config - store *store.Store - oidcUserResolver oidcUserResolver - auth *auth.Authenticator - oidcClient oidcPublicClient - oidcSessions oidcSessionManager - oidcSessionCipher *oidcsession.Cipher - runner *runner.Service - logger *slog.Logger - geminiUploadSessions sync.Map - securityEventReceiver http.Handler - securityEventManager *ssfreceiver.ConnectionManager - identityRuntime *identityruntime.Manager - identityPairing *identity.PairingService - identityManagementMu sync.Mutex - identityPairingWorkers sync.Map + ctx context.Context + cfg config.Config + store *store.Store + oidcUserResolver oidcUserResolver + auth *auth.Authenticator + oidcClient oidcPublicClient + oidcSessions oidcSessionManager + oidcSessionCipher *oidcsession.Cipher + runner *runner.Service + logger *slog.Logger + geminiUploadSessions sync.Map + securityEventReceiver http.Handler + securityEventManager *ssfreceiver.ConnectionManager + identityRuntime *identityruntime.Manager + identityPairing *identity.PairingService + identityManagementMu sync.Mutex + identityPairingWorkers sync.Map + identityTestRevision identity.Revision + identityTestCookieSecure bool + identityTestBrowserEnabled bool } type oidcPublicClient interface { @@ -72,15 +75,15 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret securityEventMetrics := &ssfreceiver.Metrics{} - secretStore, err := securityEventSecretStore(cfg) + secretStore, err := identitySecretStore(cfg) if err != nil { panic("invalid identity SecretStore: " + err.Error()) } runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{ AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute, - HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, - StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, - ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, + HeartbeatInterval: time.Duration(cfg.IdentitySecurityEventHeartbeatIntervalSeconds) * time.Second, + StaleAfter: time.Duration(cfg.IdentitySecurityEventStaleAfterSeconds) * time.Second, + ClockSkew: time.Duration(cfg.IdentitySecurityEventClockSkewSeconds) * time.Second, }, securityEventMetrics) server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder) if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil { @@ -294,22 +297,22 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor return server.recover(server.cors(server.protectOIDCSessionCookie(mux))) } -func securityEventSecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) { - switch strings.ToLower(strings.TrimSpace(cfg.OIDCSecurityEventsSecretStore)) { +func identitySecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) { + switch strings.ToLower(strings.TrimSpace(cfg.IdentitySecretStore)) { case "", "file": - directory := cfg.OIDCSecurityEventsSecretDir + directory := cfg.IdentitySecretDir if directory == "" { - directory = ".local-secrets/ssf" + directory = ".local-secrets/identity" } return ssfreceiver.NewFileSecretStore(directory) case "kubernetes": return ssfreceiver.NewKubernetesSecretStore(ssfreceiver.KubernetesSecretStoreConfig{ - Namespace: cfg.OIDCSecurityEventsKubernetesNamespace, SecretName: cfg.OIDCSecurityEventsKubernetesSecretName, - APIServer: cfg.OIDCSecurityEventsKubernetesAPIServer, TokenFile: cfg.OIDCSecurityEventsKubernetesTokenFile, - CAFile: cfg.OIDCSecurityEventsKubernetesCAFile, + Namespace: cfg.IdentityKubernetesNamespace, SecretName: cfg.IdentityKubernetesSecretName, + APIServer: cfg.IdentityKubernetesAPIServer, TokenFile: cfg.IdentityKubernetesTokenFile, + CAFile: cfg.IdentityKubernetesCAFile, }) default: - return nil, errors.New("unsupported OIDC security event SecretStore") + return nil, errors.New("unsupported identity SecretStore") } } diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx index 100af13..8cda435 100644 --- a/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx @@ -9,6 +9,7 @@ describe('UnifiedIdentityPanel', () => { expect(html).toContain('Auth Center 地址'); expect(html).toContain('一次性接入码'); expect(html).toContain('Gateway API 公网地址'); + expect(html).not.toContain('value="/gateway-api"'); expect(html).toContain('Gateway Web 地址'); expect(html).toContain('Gateway 本地租户映射'); expect(html).not.toContain('Machine Client Secret'); diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx index 1ee7083..3ae4700 100644 --- a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx @@ -283,10 +283,11 @@ function IdentityHealth({ label, value }: { label: string; value: string }) { function defaultPairingInput(): IdentityPairingInput { const webBaseUrl = typeof window === 'undefined' ? '' : window.location.origin; + const configuredApiBaseUrl = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, ''); return { authCenterUrl: 'https://auth.51easyai.com', onboardingCode: '', - publicBaseUrl: (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, ''), + publicBaseUrl: /^https?:\/\//i.test(configuredApiBaseUrl) ? configuredApiBaseUrl : '', webBaseUrl, localTenantKey: 'default', legacyJwtEnabled: false, diff --git a/docker-compose.yml b/docker-compose.yml index 3e0852f..7422408 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,33 +6,11 @@ x-api-environment: &api-environment AI_GATEWAY_DATABASE_URL: ${AI_GATEWAY_COMPOSE_DATABASE_URL:-postgresql://easyai:easyai2025@postgres:5432/easyai_ai_gateway?sslmode=disable} CONFIG_JWT_SECRET: ${CONFIG_JWT_SECRET:-this is a very secret secret} IDENTITY_MODE: ${AI_GATEWAY_COMPOSE_IDENTITY_MODE:-hybrid} - OIDC_ENABLED: ${OIDC_ENABLED:-false} - OIDC_ISSUER: ${OIDC_ISSUER:-} - OIDC_AUDIENCE: ${OIDC_AUDIENCE:-} - OIDC_TENANT_ID: ${OIDC_TENANT_ID:-} - OIDC_ROLE_PREFIX: ${OIDC_ROLE_PREFIX:-gateway.} - OIDC_REQUIRED_SCOPES: ${OIDC_REQUIRED_SCOPES:-gateway.access} - OIDC_JWKS_CACHE_TTL_SECONDS: ${OIDC_JWKS_CACHE_TTL_SECONDS:-300} - OIDC_ACCEPT_LEGACY_HS256: ${OIDC_ACCEPT_LEGACY_HS256:-true} - OIDC_INTROSPECTION_ENABLED: ${OIDC_INTROSPECTION_ENABLED:-false} - OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-} - OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-} - OIDC_SECURITY_EVENTS_SECRET_STORE: file - OIDC_SECURITY_EVENTS_SECRET_DIR: /app/data/security-events/secrets - OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60} - OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180} - OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60} - OIDC_JIT_PROVISIONING_ENABLED: ${OIDC_JIT_PROVISIONING_ENABLED:-false} - OIDC_GATEWAY_TENANT_KEY: ${OIDC_GATEWAY_TENANT_KEY:-} - OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true} - OIDC_SESSION_COOKIE_SECURE: ${OIDC_SESSION_COOKIE_SECURE:-} - OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} - OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} - OIDC_POST_LOGOUT_REDIRECT_URI: ${OIDC_POST_LOGOUT_REDIRECT_URI:-} - OIDC_SESSION_ENCRYPTION_KEY: ${OIDC_SESSION_ENCRYPTION_KEY:-} - OIDC_SESSION_IDLE_TTL_SECONDS: ${OIDC_SESSION_IDLE_TTL_SECONDS:-1800} - OIDC_SESSION_ABSOLUTE_TTL_SECONDS: ${OIDC_SESSION_ABSOLUTE_TTL_SECONDS:-28800} - OIDC_SESSION_REFRESH_BEFORE_SECONDS: ${OIDC_SESSION_REFRESH_BEFORE_SECONDS:-60} + IDENTITY_SECRET_STORE: file + IDENTITY_SECRET_DIR: /app/data/identity/secrets + IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60} + IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180} + IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60} SERVER_MAIN_BASE_URL: ${AI_GATEWAY_COMPOSE_SERVER_MAIN_BASE_URL:-http://host.docker.internal:3000} SERVER_MAIN_INTERNAL_TOKEN: ${SERVER_MAIN_INTERNAL_TOKEN:-change-me} SERVER_MAIN_INTERNAL_KEY: ${SERVER_MAIN_INTERNAL_KEY:-gateway} @@ -127,8 +105,6 @@ services: VITE_GATEWAY_API_BASE_URL: ${AI_GATEWAY_WEB_API_BASE_URL:-/gateway-api} NODE_BUILD_IMAGE: ${AI_GATEWAY_NODE_BUILD_IMAGE:-node:22-alpine} WEB_RUNTIME_IMAGE: ${AI_GATEWAY_WEB_RUNTIME_IMAGE:-nginx:1.27-alpine} - VITE_OIDC_ENABLED: ${OIDC_ENABLED:-false} - VITE_OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true} VITE_BASE_PATH: ${AI_GATEWAY_WEB_BASE_PATH:-/} ports: - "${AI_GATEWAY_WEB_PORT:-5178}:80" diff --git a/docs/oidc-jit-provisioning.md b/docs/oidc-jit-provisioning.md index bf2dea5..770d1c2 100644 --- a/docs/oidc-jit-provisioning.md +++ b/docs/oidc-jit-provisioning.md @@ -4,28 +4,15 @@ Gateway 只在 OIDC Token 已通过签名、Issuer、Audience、有效期、`tid`、Scope 和应用角色校验后执行 JIT。认证中心的稳定 `sub` 是外部用户标识;Gateway 不读取、不保存或公开 Keycloak 内部 ID,也不向 Token 增加 `gatewayUserId`。 -本次保持单 Gateway 租户:部署方用 `OIDC_GATEWAY_TENANT_KEY` 把 Auth Center 的已验证 `tid` 显式绑定到一个已存在、启用且配置了启用中默认用户组的 Gateway 租户。Token 不能创建租户。 +本次保持单 Gateway 租户:管理员在统一认证 Draft 中把 Auth Center 的已验证 `tid` 显式绑定到一个已存在、启用且配置了启用中默认用户组的 Gateway 租户。Token 不能创建租户。 ## 配置 -```dotenv -OIDC_ENABLED=true -OIDC_JIT_PROVISIONING_ENABLED=true -OIDC_GATEWAY_TENANT_KEY=default -OIDC_BROWSER_SESSION_ENABLED=true -OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP;生产必须为 true -OIDC_CLIENT_ID= -OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ -OIDC_SESSION_ENCRYPTION_KEY=<独立的 32 字节随机密钥,base64 编码> -OIDC_SESSION_IDLE_TTL_SECONDS=1800 -OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800 -OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 -``` +OIDC、JIT 和浏览器会话不读取业务环境变量。管理员在 Auth Center 的通用“应用接入”向导选择 `OIDC 登录` 与 `API 验证`,生成一次性接入码;再到 Gateway“系统设置 → 统一认证”提交认证中心地址、接入码、Gateway API/Web 地址和本地租户映射。系统自动推导回调及退出地址,生成 Session 加密密钥并保存到部署级 SecretStore。 -- `OIDC_JIT_PROVISIONING_ENABLED` 默认 `false`。关闭时停止创建新用户,但已有 `source=oidc + external_user_id=sub` 映射仍会解析和同步登录时间。 -- JIT 开启时 `OIDC_GATEWAY_TENANT_KEY` 必填,缺失会使 Gateway 启动失败。 -- 本地与 Staging 验收环境应在各自 Git 忽略或 Secret 管理的环境文件中显式开启;生产启用需独立变更审批。 +- JIT、Legacy JWT 兼容和 Session 时限属于 Revision 策略,可在 Draft 中修改;默认闲置 30 分钟、绝对 8 小时、提前 60 秒刷新。 +- JIT 关闭时停止创建新用户,但已有 `source=oidc + external_user_id=sub` 映射仍会解析和同步登录时间。 +- 本地租户不存在、公共 Client 不可用或 Discovery/Issuer/JWKS 校验失败时,Revision 不能激活,当前 Active Runtime 不受影响。 ## Web Console 浏览器会话 @@ -35,9 +22,9 @@ OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 - Access Token 继续保持 5 分钟。认证请求发现剩余时间不超过 60 秒时使用 Refresh Token 自动刷新;多实例通过 PostgreSQL 刷新租约保证同一版本只刷新一次,并原子保存旋转后的 Refresh Token。 - Session 闲置 30 分钟、绝对最长 8 小时。闲置 5~30 分钟后的首个请求可自动刷新;超过闲置或绝对期限不会刷新,必须重新登录。浏览器不运行定时刷新。 - 所有 Web API 请求使用 `credentials: include`。Cookie 鉴权的 POST、PUT、PATCH、DELETE 必须携带 `CORS_ALLOWED_ORIGIN` 白名单中的 Origin,否则返回结构化 403。 -- Staging、生产及其他非本地环境启动时强制 `OIDC_SESSION_COOKIE_SECURE=true`,并拒绝带 `*` 的凭据型 CORS 配置。本地 HTTP 开发和自动化测试可以显式设为 `false`。 +- Cookie 的 `Secure` 属性由 Revision 中的 Gateway API 公网地址推导:生产地址只允许 HTTPS;本地开发允许 localhost HTTP。可信 Origin 由 Gateway Web 地址精确推导,不接受 `*`。 - `POST /api/v1/auth/oidc/logout` 校验可信 Origin、删除本地 Session、使用公共 Client ID 撤销 Refresh Token,并跳转 Auth Center 退出地址;`DELETE /api/v1/auth/oidc/session` 保留为幂等的本地删除接口。 -- `OIDC_SESSION_ENCRYPTION_KEY` 必须来自 Git 忽略的本地文件、Kubernetes Secret 或 Secret Manager,不得复用 JWT Secret。关闭 `OIDC_BROWSER_SESSION_ENABLED` 可停止新会话;回滚镜像后已创建记录作为惰性数据保留。 +- Session Encryption Key 由 Gateway 服务端生成,只以 Secret 引用进入 Revision,不得复用 JWT Secret,也不会进入数据库、响应、日志或浏览器。禁用统一认证会清理现有 BFF Session;回滚后用户需要重新登录。 ## 数据和事务语义 @@ -70,4 +57,4 @@ OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 自动化门禁通过后,依次验证本地真实 OIDC 和 `auth.51easyai.com` Staging 专用测试租户。证据应包含脱敏 Claims、网络截图、本地 Gateway 用户记录、Trace ID、Gateway/Auth Center 审计 ID及 200/401/403/503 负向证据;不得保存 Token、授权码、密码或 Secret。 -JIT 回滚时关闭 `OIDC_JIT_PROVISIONING_ENABLED` 并回退 Gateway 镜像。浏览器 Cookie 会话可通过后端和 Web 两侧的独立开关关闭。已经创建的 `source=oidc` 测试投影保留为惰性数据,不自动删除、迁移或合并。 +JIT 策略变更通过创建并验证新 Revision 后激活;身份配置回滚使用“系统设置 → 统一认证”的回滚操作。激活、回滚和禁用均要求本地 Break-glass Manager 可用,并清理受影响的 BFF Session。已经创建的 `source=oidc` 测试投影保留为惰性数据,不自动删除、迁移或合并。 diff --git a/docs/security/ssf-session-revocation.md b/docs/security/ssf-session-revocation.md index 7133aa3..f832f8d 100644 --- a/docs/security/ssf-session-revocation.md +++ b/docs/security/ssf-session-revocation.md @@ -1,36 +1,35 @@ # SSF/CAEP 实时会话撤销运行手册 -Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token,并处理 OpenID CAEP `session-revoked`。功能由数据库中的连接资源控制:没有连接时保持原有 OIDC、BFF、API Key 和 Legacy JWT 行为;不再使用 `OIDC_SECURITY_EVENTS_ENABLED`。 +Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token,并处理 OpenID CAEP `session-revoked`。能力由 Active Identity Revision 控制;没有选择会话撤销时保持原有 OIDC、BFF、API Key 和 Legacy JWT 行为。 ## 用户接入流程 -用户只执行两项操作: +首次接入只执行两端各一次操作: -1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client,点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope;不会创建第二个 Client。 -2. Receiver 就绪后点击“生成一次性连接凭据”,立即复制 machine Client ID/Secret。 -3. 在 Gateway“系统设置 → 认证中心安全事件”中填写 SSF Issuer 和这组一次性凭据,点击“连接认证中心”。 +1. 在认证中心 Application 的通用“应用接入”向导选择“SSF 会话撤销”。能力依赖会自动包含 Token Introspection 和机器调用;预览确认后创建或复用同用途服务客户端。 +2. 在 Gateway“系统设置 → 统一认证”填写一次性接入码和 Gateway 地址。Gateway 自动领取 Manifest 与机器凭据,并在验证候选 Runtime 时建立 SSF Stream。 -Gateway 后端先把 machine Secret 写入 SecretStore,随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器只在两端连接表单的短暂操作期间接触 machine Secret;Push Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。 +Gateway 后端先把 Machine Secret 写入 SecretStore,随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。接入码和 Machine Secret 只从浏览器提交到 Gateway 服务端;Push Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。 -环境前置配置只保留 OIDC 公共参数和 Gateway 公共地址: +环境前置配置只保留 SecretStore 与运行时基础设施参数: -- `OIDC_ISSUER`、`OIDC_TENANT_ID`; -- `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。 +- `IDENTITY_SECRET_STORE`、`IDENTITY_SECRET_DIR`,或对应 Kubernetes SecretStore 参数; +- `IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS`、`IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS` 和 `IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS`。 -`OIDC_INTROSPECTION_CLIENT_ID/SECRET` 仅作为旧部署兼容回退,不再是新连接必填项。新连接把 machine 凭据动态托管到 SecretStore,OIDC fallback、SSF 管理 Token 和 Verification 共用这一份凭据,重启后继续生效。 +Issuer、Tenant、Gateway 公网地址和机器 Client 均来自 Active Revision,不读取旧 OIDC 环境变量。OIDC fallback、SSF 管理 Token 和 Verification 共用一份托管机器凭据,重启后继续生效。 第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。 ## SecretStore -本地和 Docker 使用 `file` 驱动。服务自动创建目录并强制目录 `0700`、文件 `0600`;Docker Compose 将目录放在持久化 `api_data` 卷中。用户不写入任何 `ssf-delivery-*` 文件。 +本地和 Docker 使用 `file` 驱动。服务自动创建目录并强制目录 `0700`、文件 `0600`;Docker Compose 将目录放在持久化 `api_data` 卷中。用户不写入任何 Secret 文件。 Kubernetes 使用 `kubernetes` 驱动和一个部署时预创建的空 Secret: ```text -OIDC_SECURITY_EVENTS_SECRET_STORE=kubernetes -OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE=easyai -OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME=easyai-gateway-security-events +IDENTITY_SECRET_STORE=kubernetes +IDENTITY_KUBERNETES_NAMESPACE=easyai +IDENTITY_KUBERNETES_SECRET_NAME=easyai-gateway-identity ``` 参考清单见 `deploy/kubernetes/security-events-secret-rbac.yaml`。ServiceAccount 只能对这个固定 Secret 执行 `get/update/patch`,不能创建、删除或读取其他 Secret。数据库、API、浏览器、日志和审计只保存或展示非敏感连接元数据。 diff --git a/docs/standard-identity-runtime-configuration.md b/docs/standard-identity-runtime-configuration.md index 45865d2..0638059 100644 --- a/docs/standard-identity-runtime-configuration.md +++ b/docs/standard-identity-runtime-configuration.md @@ -8,7 +8,7 @@ Accepted,2026-07-17。 Gateway 是标准应用接入协议的首个消费方。认证中心保持消费方无关;“统一认证”页面、Gateway 本地租户映射、Legacy JWT 兼容和 Session 策略均属于 Gateway 自己的管理边界。 -OIDC、JIT、Introspection、BFF 和 SSF 的业务配置以 Gateway 管理 API/数据库为唯一来源。数据库、SecretStore、Session 加密根、Bootstrap 管理凭据、TLS 与 RBAC 仍由部署环境提供。开发阶段不读取或迁移旧 `OIDC_*`、`VITE_OIDC_*` 业务变量,升级后由管理员重新配对。 +OIDC、JIT、Introspection、BFF 和 SSF 的业务配置以 Gateway 管理 API/数据库为唯一来源。数据库、SecretStore、Bootstrap 管理凭据、TLS 与 RBAC 仍由部署环境提供;Machine Credential 和 Session Encryption Key 由服务端生成或领取,只存入 SecretStore,Revision 仅保存引用。开发阶段不读取或迁移旧 `OIDC_*`、`VITE_OIDC_*` 业务变量,升级后由管理员重新配对。 ## Revision 状态机 @@ -50,3 +50,17 @@ active -> superseded (回滚、替换或禁用) ## 前端运行时配置 Web 前端启动后读取公开只读统一认证状态,不使用 `VITE_OIDC_*` 构建变量。安全事件作为统一认证能力状态显示,原独立页面只保留运行运维动作。 + +## 部署级配置 + +允许继续由部署环境提供的统一认证相关参数仅限 SecretStore 与基础设施健康窗口: + +```dotenv +IDENTITY_SECRET_STORE=file +IDENTITY_SECRET_DIR=.local-secrets/identity +IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 +IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 +IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 +``` + +Kubernetes 部署改用 `IDENTITY_KUBERNETES_NAMESPACE`、`IDENTITY_KUBERNETES_SECRET_NAME`、`IDENTITY_KUBERNETES_API_SERVER`、`IDENTITY_KUBERNETES_TOKEN_FILE` 和 `IDENTITY_KUBERNETES_CA_FILE`。这些参数不包含 Issuer、Audience、Scope、Client ID、Machine Secret 或业务开关。 diff --git a/scripts/dev.sh b/scripts/dev.sh index 8c2e173..c64c96e 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -20,9 +20,6 @@ load_local_env() { export "$key=$value" done < "$env_file" done - - export VITE_OIDC_ENABLED="${VITE_OIDC_ENABLED:-${OIDC_ENABLED:-false}}" - export VITE_OIDC_BROWSER_SESSION_ENABLED="${VITE_OIDC_BROWSER_SESSION_ENABLED:-${OIDC_BROWSER_SESSION_ENABLED:-true}}" } load_local_env