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:
@@ -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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user