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:
2026-07-17 12:31:00 +08:00
parent e0a356ee0c
commit a767dc42c0
18 changed files with 354 additions and 578 deletions
+62 -190
View File
@@ -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 == "" {
+32 -155
View File
@@ -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 TestValidateIdentitySecurityEventTiming(t *testing.T) {
cfg := Config{
IdentitySecurityEventHeartbeatIntervalSeconds: 60,
IdentitySecurityEventStaleAfterSeconds: 60,
IdentitySecurityEventClockSkewSeconds: 60,
}
}
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")
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") {
t.Fatalf("Validate() error = %v, want invalid stale threshold", err)
}
}
+19 -10
View File
@@ -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(),
+10 -10
View File
@@ -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())
+32 -29
View File
@@ -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,