feat(ssf): 托管机器凭据并支持动态恢复

Gateway 连接表单接收认证中心一次性交付的 machine Client 凭据,后端立即写入 SecretStore,数据库和公开响应只保留引用及公开 Client ID。SSF 管理 Token、Verification 和 RFC 7662 内省统一从动态凭据读取,支持现有连接无重启修复及进程重启恢复,环境变量仅保留为旧部署回退。\n\n验证:go test ./apps/api/...;pnpm nx test web;真实重启、OIDC 登录与 session-revoked 1.29 秒失效验收。
This commit is contained in:
2026-07-17 09:13:57 +08:00
parent 8e33d1d33e
commit ffb85b73af
15 changed files with 327 additions and 90 deletions
+29 -15
View File
@@ -25,19 +25,20 @@ const maxOIDCResponseBytes = 1 << 20
const defaultOIDCHTTPTimeout = 10 * time.Second
type OIDCConfig struct {
Issuer string
Audience string
TenantID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
IntrospectionEnabled bool
IntrospectionClientID string
IntrospectionClientSecret string
HTTPClient *http.Client
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
IntrospectionObserver func(string)
JWKSRefreshFailureObserver func()
Issuer string
Audience string
TenantID string
RolePrefix string
RequiredScopes []string
JWKSCacheTTL time.Duration
IntrospectionEnabled bool
IntrospectionClientID string
IntrospectionClientSecret string
IntrospectionCredentialProvider func(context.Context) (string, []byte, error)
HTTPClient *http.Client
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
IntrospectionObserver func(string)
JWKSRefreshFailureObserver func()
}
type OIDCSecurityEventIdentity struct {
@@ -92,7 +93,8 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil &&
(strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
if config.JWKSCacheTTL <= 0 {
@@ -295,7 +297,19 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.SetBasicAuth(v.config.IntrospectionClientID, v.config.IntrospectionClientSecret)
clientID := v.config.IntrospectionClientID
secret := []byte(v.config.IntrospectionClientSecret)
if v.config.IntrospectionCredentialProvider != nil {
clientID, secret, err = v.config.IntrospectionCredentialProvider(ctx)
if err != nil {
return false, err
}
}
defer clear(secret)
if strings.TrimSpace(clientID) == "" || len(secret) < 16 {
return false, errors.New("OIDC introspection credential is unavailable")
}
request.SetBasicAuth(clientID, string(secret))
response, err := v.client.Do(request)
if err != nil {
return false, err
+4 -2
View File
@@ -138,8 +138,10 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
verifier, err := NewOIDCVerifier(OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true, IntrospectionClientID: "gateway-api",
IntrospectionClientSecret: "introspection-secret",
IntrospectionEnabled: true,
IntrospectionCredentialProvider: func(context.Context) (string, []byte, error) {
return "gateway-api", []byte("introspection-secret"), nil
},
})
if err != nil {
t.Fatal(err)