fix: 修复 OIDC 用户预配与跨标签页登录态

增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
2026-07-13 17:07:52 +08:00
parent 17b1f77e1d
commit a81a7b5200
37 changed files with 2694 additions and 179 deletions
+45 -7
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"log/slog"
"net/url"
"os"
@@ -34,6 +35,10 @@ type Config struct {
OIDCIntrospectionEnabled bool
OIDCIntrospectionClientID string
OIDCIntrospectionClientSecret string
OIDCJITProvisioningEnabled bool
OIDCGatewayTenantKey string
OIDCBrowserSessionEnabled bool
OIDCSessionCookieSecure bool
PublicBaseURL string
WebBaseURL string
LocalGeneratedStorageDir string
@@ -51,8 +56,9 @@ type Config struct {
func Load() Config {
globalProxy := LoadGlobalHTTPProxyStatus()
appEnv := env("APP_ENV", "development")
return Config{
AppEnv: env("APP_ENV", "development"),
AppEnv: appEnv,
HTTPAddr: env("HTTP_ADDR", ":8088"),
DatabaseURL: gatewayDatabaseURL(),
IdentityMode: env("IDENTITY_MODE", "hybrid"),
@@ -75,12 +81,18 @@ func Load() Config {
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
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",
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",
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",
),
@@ -93,6 +105,32 @@ func Load() Config {
}
}
func (c Config) Validate() error {
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 {
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")
}
}
}
return nil
}
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
+81
View File
@@ -0,0 +1,81 @@
package config
import (
"strings"
"testing"
)
func TestLoadOIDCJITProvisioningDefaultsToDisabled(t *testing.T) {
t.Setenv("OIDC_JIT_PROVISIONING_ENABLED", "")
t.Setenv("OIDC_GATEWAY_TENANT_KEY", "")
cfg := Load()
if cfg.OIDCJITProvisioningEnabled {
t.Fatal("OIDC JIT provisioning must be disabled by default")
}
if cfg.OIDCGatewayTenantKey != "" {
t.Fatalf("unexpected gateway tenant key: %q", cfg.OIDCGatewayTenantKey)
}
}
func TestValidateRequiresGatewayTenantKeyWhenOIDCJITIsEnabled(t *testing.T) {
cfg := Config{
OIDCEnabled: true,
OIDCJITProvisioningEnabled: true,
}
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"
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() with tenant key: %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")
}
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",
}
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)
}
}