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:
parent
e0a356ee0c
commit
a767dc42c0
55
.env.example
55
.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
|
||||
|
||||
@ -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
|
||||
|
||||
26
README.md
26
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=<base64 编码的独立 32 字节随机密钥>
|
||||
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,然后并行启动:
|
||||
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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=<Control Plane 生成的公共 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` 测试投影保留为惰性数据,不自动删除、迁移或合并。
|
||||
|
||||
@ -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、浏览器、日志和审计只保存或展示非敏感连接元数据。
|
||||
|
||||
@ -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 或业务开关。
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user