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:
chengcheng 2026-07-17 09:13:57 +08:00
parent 8e33d1d33e
commit ffb85b73af
15 changed files with 327 additions and 90 deletions

View File

@ -33,6 +33,8 @@ OIDC_REQUIRED_SCOPES=gateway.access
OIDC_JWKS_CACHE_TTL_SECONDS=300
OIDC_ACCEPT_LEGACY_HS256=true
OIDC_INTROSPECTION_ENABLED=false
# Legacy/static fallback only. New SSF connections receive the machine
# credential once in the web flow and persist it in the configured SecretStore.
OIDC_INTROSPECTION_CLIENT_ID=
OIDC_INTROSPECTION_CLIENT_SECRET=
# SSF/CAEP is activated by the durable connection created in System Settings;

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

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)

View File

@ -16,7 +16,9 @@ import (
)
type securityEventConnectionRequest struct {
TransmitterIssuer string `json:"transmitter_issuer"`
TransmitterIssuer string `json:"transmitter_issuer"`
ManagementClientID string `json:"management_client_id"`
ManagementClientSecret string `json:"management_client_secret"`
}
type securityEventConnectionResponse struct {
@ -65,14 +67,22 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque
return
}
request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/")
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer)
request.ManagementClientID = strings.TrimSpace(request.ManagementClientID)
if (request.ManagementClientID == "") != (request.ManagementClientSecret == "") || len(request.ManagementClientID) > 200 || len(request.ManagementClientSecret) > 512 {
writeError(w, http.StatusBadRequest, "machine Client ID and Secret must be provided together", "invalid_machine_credential")
return
}
secret := []byte(request.ManagementClientSecret)
defer clear(secret)
secretDigest := sha256.Sum256(secret)
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:]))
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
return
}
if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) {
return
}
connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, idempotencyKey)
connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey)
if err != nil {
s.writeSecurityEventConnectionError(w, r, "connect", traceID, err)
return
@ -128,6 +138,7 @@ func (s *Server) securityEventPrerequisites() map[string]any {
"introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "",
"publicBaseUrlConfigured": s.cfg.PublicBaseURL != "",
"managementClientId": s.cfg.OIDCIntrospectionClientID,
"credentialInputSupported": true,
}
}

View File

@ -94,13 +94,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes,
JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second,
IntrospectionEnabled: cfg.OIDCIntrospectionEnabled,
IntrospectionClientID: cfg.OIDCIntrospectionClientID,
IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret,
SecurityEventEvaluator: evaluator,
IntrospectionObserver: securityEventMetrics.ObserveIntrospection,
JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") },
JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second,
IntrospectionEnabled: cfg.OIDCIntrospectionEnabled,
IntrospectionClientID: cfg.OIDCIntrospectionClientID,
IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret,
IntrospectionCredentialProvider: server.securityEventManager.IntrospectionCredential,
SecurityEventEvaluator: evaluator,
IntrospectionObserver: securityEventMetrics.ObserveIntrospection,
JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") },
})
if err != nil {
panic("invalid OIDC configuration: " + err.Error())

View File

@ -34,6 +34,7 @@ type ConnectionRepository interface {
CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error)
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
BindSecurityEventConnection(context.Context, string, string, string, string, int64) (store.SecurityEventConnection, error)
SetSecurityEventManagementCredential(context.Context, string, string, string) (store.SecurityEventConnection, error)
UpdateSecurityEventConnectionLifecycle(context.Context, string, string, *string) (store.SecurityEventConnection, error)
SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error)
PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error)
@ -181,51 +182,114 @@ func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) {
return m.view(connection), nil
}
func (m *ConnectionManager) Connect(ctx context.Context, issuer, idempotencyKey string) (ConnectionView, error) {
func (m *ConnectionManager) IntrospectionCredential(ctx context.Context) (string, []byte, error) {
connection, err := m.repository.SecurityEventConnection(ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" {
return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil
}
return "", nil, errors.New("managed machine credential is unavailable")
}
if err != nil {
return "", nil, err
}
return m.managementCredential(ctx, connection)
}
func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClientID string, managementSecret []byte, idempotencyKey string) (ConnectionView, error) {
m.operation.Lock()
defer m.operation.Unlock()
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
managementClientID = strings.TrimSpace(managementClientID)
if idempotencyKey == "" {
return ConnectionView{}, errors.New("idempotency key is required")
}
if err := m.validatePrerequisites(issuer); err != nil {
existing, err := m.repository.SecurityEventConnection(ctx)
if err != nil && !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return ConnectionView{}, err
}
credentialsProvided := managementClientID != "" || len(managementSecret) > 0
if (managementClientID == "") != (len(managementSecret) == 0) {
return ConnectionView{}, errors.New("machine client id and secret must be provided together")
}
if !credentialsProvided {
if err == nil {
managementClientID, managementSecret, _ = m.managementCredential(ctx, existing)
} else {
managementClientID = strings.TrimSpace(m.config.ManagementClientID)
managementSecret = []byte(m.config.ManagementClientSecret)
}
}
defer clear(managementSecret)
if err := m.validatePrerequisites(issuer, managementClientID, managementSecret, errors.Is(err, store.ErrSecurityEventConnectionNotFound)); err != nil {
return ConnectionView{}, err
}
existing, err := m.repository.SecurityEventConnection(ctx)
if err == nil {
if existing.TransmitterIssuer != issuer {
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
}
if credentialsProvided {
existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret)
if err != nil {
return ConnectionView{}, err
}
}
if existing.StreamID != nil && !resumableConnectionLifecycle(existing.LifecycleStatus) {
if existing.Audience != nil {
if err := m.activate(ctx, existing); err != nil {
return ConnectionView{}, err
}
}
return m.view(existing), nil
}
return m.resumeConnecting(ctx, existing)
}
if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return ConnectionView{}, err
}
connectionID := uuid.NewString()
reference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
if err := m.secrets.Put(ctx, managementReference, managementSecret); err != nil {
return ConnectionView{}, err
}
secret, err := randomPushBearer()
if err != nil {
_ = m.secrets.Delete(ctx, managementReference)
return ConnectionView{}, err
}
defer clear(secret)
if err := m.secrets.Put(ctx, reference, secret); err != nil {
_ = m.secrets.Delete(ctx, managementReference)
return ConnectionView{}, err
}
endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf"
connection, err := m.repository.CreateSecurityEventConnection(ctx, store.CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: issuer, EndpointURL: endpoint,
CredentialRef: reference, IdempotencyKey: idempotencyKey,
CredentialRef: reference, ManagementClientID: managementClientID,
ManagementCredentialRef: managementReference, IdempotencyKey: idempotencyKey,
})
if err != nil {
_ = m.secrets.Delete(ctx, reference)
_ = m.secrets.Delete(ctx, managementReference)
return ConnectionView{}, err
}
return m.resumeConnecting(ctx, connection)
}
func (m *ConnectionManager) persistManagementCredential(ctx context.Context, connection store.SecurityEventConnection, clientID string, secret []byte) (store.SecurityEventConnection, error) {
reference := "ssf-management-" + connection.ConnectionID + "-" + uuid.NewString()
if err := m.secrets.Put(ctx, reference, secret); err != nil {
return store.SecurityEventConnection{}, err
}
updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference)
if err != nil {
_ = m.secrets.Delete(ctx, reference)
return store.SecurityEventConnection{}, err
}
if connection.ManagementCredentialRef != nil {
_ = m.secrets.Delete(ctx, *connection.ManagementCredentialRef)
}
return updated, nil
}
func resumableConnectionLifecycle(status string) bool {
switch status {
case "connecting", "verifying", "degraded", "error":
@ -484,6 +548,11 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur
return err
}
defer clear(current)
managementClientID, managementSecret, err := m.managementCredential(ctx, connection)
if err != nil {
return err
}
defer clear(managementSecret)
var next []byte
if connection.NextCredentialRef != nil {
next, err = m.secrets.Get(ctx, *connection.NextCredentialRef)
@ -517,7 +586,7 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur
service, err := NewService(m.repository, ServiceConfig{
Issuer: connection.TransmitterIssuer, Audience: *connection.Audience, StreamID: *connection.StreamID,
ManagementTokenURL: strings.TrimRight(m.config.OIDCIssuer, "/") + "/token",
ManagementClientID: m.config.ManagementClientID, ManagementSecret: m.config.ManagementClientSecret,
ManagementClientID: managementClientID, ManagementSecret: string(managementSecret),
HeartbeatInterval: m.config.HeartbeatInterval, StaleAfter: m.config.StaleAfter,
HTTPClient: safeClient,
})
@ -656,13 +725,16 @@ func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConne
}
m.stopRuntime()
_ = m.secrets.Delete(m.ctx, connection.CredentialRef)
if connection.ManagementCredentialRef != nil {
_ = m.secrets.Delete(m.ctx, *connection.ManagementCredentialRef)
}
if connection.NextCredentialRef != nil {
_ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef)
}
}
func (m *ConnectionManager) validatePrerequisites(issuer string) error {
if !m.config.OIDCEnabled || m.config.ManagementClientID == "" || m.config.ManagementClientSecret == "" {
func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID string, managementSecret []byte, requirePublicBaseURL bool) error {
if !m.config.OIDCEnabled || strings.TrimSpace(managementClientID) == "" || len(managementSecret) < 16 {
return errors.New("OIDC and RFC 7662 machine client must be configured")
}
if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil {
@ -671,6 +743,9 @@ func (m *ConnectionManager) validatePrerequisites(issuer string) error {
if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil {
return err
}
if !requirePublicBaseURL {
return nil
}
endpoint, err := url.Parse(strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf")
if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
return errors.New("AI_GATEWAY_PUBLIC_BASE_URL is invalid")
@ -710,12 +785,21 @@ func (m *ConnectionManager) discover(ctx context.Context, issuer string) (transm
}
func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Client, scopes string) (string, error) {
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
return "", err
}
managementClientID, managementSecret, err := m.managementCredential(ctx, connection)
if err != nil {
return "", err
}
defer clear(managementSecret)
form := url.Values{"grant_type": {"client_credentials"}, "scope": {scopes}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
request.SetBasicAuth(m.config.ManagementClientID, m.config.ManagementClientSecret)
request.SetBasicAuth(managementClientID, string(managementSecret))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := client.Do(request)
if err != nil {
@ -736,6 +820,24 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl
return payload.AccessToken, nil
}
func (m *ConnectionManager) managementCredential(ctx context.Context, connection store.SecurityEventConnection) (string, []byte, error) {
if connection.ManagementCredentialRef != nil && connection.ManagementClientID != "" {
secret, err := m.secrets.Get(ctx, *connection.ManagementCredentialRef)
if err != nil {
return "", nil, err
}
if len(secret) < 16 {
clear(secret)
return "", nil, errors.New("managed machine credential is invalid")
}
return connection.ManagementClientID, secret, nil
}
if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" {
return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil
}
return "", nil, errors.New("managed machine credential is unavailable")
}
func (m *ConnectionManager) findStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, description, endpoint string) (streamConfiguration, error) {
var streams []streamConfiguration
if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil {
@ -789,11 +891,18 @@ func (m *ConnectionManager) view(connection store.SecurityEventConnection) Conne
ConnectionID: connection.ConnectionID, TransmitterIssuer: connection.TransmitterIssuer,
ReceiverEndpoint: connection.EndpointURL, Audience: connection.Audience, StreamID: connection.StreamID,
LifecycleStatus: connection.LifecycleStatus, HealthMode: connection.HealthMode,
ManagementClientID: m.config.ManagementClientID, LastVerificationAt: connection.LastVerificationAt,
ManagementClientID: managementClientIDForView(connection.ManagementClientID, m.config.ManagementClientID), LastVerificationAt: connection.LastVerificationAt,
LastErrorCategory: connection.LastErrorCategory, Version: connection.Version,
}
}
func managementClientIDForView(managed, fallback string) string {
if strings.TrimSpace(managed) != "" {
return managed
}
return fallback
}
func validateCreatedStream(stream streamConfiguration, issuer, endpoint string) error {
if _, err := uuid.Parse(stream.StreamID); err != nil || stream.Issuer != issuer || stream.Audience == "" ||
stream.Delivery.Method != pushDeliveryMethod || stream.Delivery.EndpointURL != endpoint ||

View File

@ -66,11 +66,21 @@ func (r *memoryConnectionRepository) CreateSecurityEventConnection(_ context.Con
}
now := time.Now().UTC()
value := store.SecurityEventConnection{ConnectionID: input.ConnectionID, TransmitterIssuer: input.TransmitterIssuer,
EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, LifecycleStatus: "connecting", Version: 1,
EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, ManagementClientID: input.ManagementClientID,
ManagementCredentialRef: &input.ManagementCredentialRef, LifecycleStatus: "connecting", Version: 1,
IdempotencyKey: input.IdempotencyKey, CreatedAt: now, UpdatedAt: now, HealthMode: "disabled"}
r.connection = &value
return value, nil
}
func (r *memoryConnectionRepository) SetSecurityEventManagementCredential(_ context.Context, id, clientID, reference string) (store.SecurityEventConnection, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
if r.connection == nil || r.connection.ConnectionID != id {
return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound
}
r.connection.ManagementClientID, r.connection.ManagementCredentialRef = clientID, &reference
return *r.connection, nil
}
func (r *memoryConnectionRepository) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
@ -183,6 +193,20 @@ func TestConnectionLifecycleCanResumeOnlyIncompleteOrFailedConnections(t *testin
}
}
func TestExistingConnectionCredentialRepairDoesNotRequirePublicBaseURL(t *testing.T) {
manager := &ConnectionManager{config: ConnectionManagerConfig{
AppEnv: "test", OIDCEnabled: true, OIDCTenantID: uuid.NewString(),
}}
secret := []byte("machine-secret-for-existing-connection")
defer clear(secret)
if err := manager.validatePrerequisites("https://auth.example/ssf", "gateway-machine", secret, false); err != nil {
t.Fatalf("existing connection credential repair unexpectedly required a public base URL: %v", err)
}
if err := manager.validatePrerequisites("https://auth.example/ssf", "gateway-machine", secret, true); err == nil {
t.Fatal("a new connection must still require AI_GATEWAY_PUBLIC_BASE_URL")
}
}
func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) {
for _, address := range []string{
"127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1",
@ -296,7 +320,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
})
case "/oidc/token":
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != "gateway-machine" || secret != "machine-secret" {
if !ok || clientID != "gateway-machine" || secret != "machine-secret-for-test" {
t.Fatal("RFC 7662 machine client was not reused")
}
_ = r.ParseForm()
@ -339,7 +363,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
defer transmitter.Close()
manager, err := NewConnectionManager(ctx, repository, secrets, ConnectionManagerConfig{
AppEnv: "test", OIDCEnabled: true, OIDCIssuer: transmitter.URL + "/oidc", OIDCTenantID: uuid.NewString(),
ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret",
ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret-for-test",
PublicBaseURL: transmitter.URL, HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: transmitter.Client(),
}, &Metrics{})
if err != nil {
@ -349,7 +373,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
if err != nil || evaluation.Enabled {
t.Fatalf("unconfigured manager evaluation=%#v err=%v", evaluation, err)
}
view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "connect-once")
view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "", nil, "connect-once")
if err != nil {
t.Fatal(err)
}
@ -357,12 +381,18 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi
t.Fatalf("connection view=%#v", view)
}
encoded, _ := json.Marshal(view)
if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret") || strings.Contains(string(encoded), "ssf-push-") {
if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret-for-test") || strings.Contains(string(encoded), "ssf-push-") {
t.Fatalf("secret metadata escaped in response: %s", encoded)
}
if len(secrets.values) != 1 {
if len(secrets.values) != 2 {
t.Fatalf("secret count=%d", len(secrets.values))
}
restarted := &ConnectionManager{repository: repository, secrets: secrets, config: ConnectionManagerConfig{}}
restartedClientID, restartedSecret, err := restarted.IntrospectionCredential(ctx)
if err != nil || restartedClientID != "gateway-machine" || string(restartedSecret) != "machine-secret-for-test" {
t.Fatalf("persisted management credential was not recoverable after restart: client=%q secret_present=%v err=%v", restartedClientID, len(restartedSecret) > 0, err)
}
clear(restartedSecret)
if !managementScopeRequested.Load() {
t.Fatal("management scopes were not requested automatically")
}

View File

@ -16,25 +16,27 @@ var (
)
type SecurityEventConnection struct {
ConnectionID string `json:"connectionId"`
TransmitterIssuer string `json:"transmitterIssuer"`
EndpointURL string `json:"endpointUrl"`
Audience *string `json:"audience,omitempty"`
StreamID *string `json:"streamId,omitempty"`
CredentialRef string `json:"-"`
NextCredentialRef *string `json:"-"`
LifecycleStatus string `json:"lifecycleStatus"`
Version int64 `json:"version"`
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
IdempotencyKey string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
HealthMode string `json:"healthMode"`
ConnectionID string `json:"connectionId"`
TransmitterIssuer string `json:"transmitterIssuer"`
EndpointURL string `json:"endpointUrl"`
Audience *string `json:"audience,omitempty"`
StreamID *string `json:"streamId,omitempty"`
CredentialRef string `json:"-"`
NextCredentialRef *string `json:"-"`
ManagementClientID string `json:"managementClientId"`
ManagementCredentialRef *string `json:"-"`
LifecycleStatus string `json:"lifecycleStatus"`
Version int64 `json:"version"`
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
IdempotencyKey string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
HealthMode string `json:"healthMode"`
}
type CreateSecurityEventConnectionInput struct {
ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, IdempotencyKey string
ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, ManagementClientID, ManagementCredentialRef, IdempotencyKey string
}
type SecurityEventConnectionIdempotency struct {
@ -65,13 +67,14 @@ func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateS
var value SecurityEventConnection
err := s.pool.QueryRow(ctx, `
INSERT INTO gateway_security_event_connections(
connection_id,transmitter_issuer,endpoint_url,credential_ref,lifecycle_status,idempotency_key
) VALUES($1::uuid,$2,$3,$4,'connecting',$5)
connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key
) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7)
RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id::text,credential_ref,
next_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`,
input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, input.IdempotencyKey,
next_credential_ref,management_client_id,management_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`,
input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef,
input.ManagementClientID, input.ManagementCredentialRef, input.IdempotencyKey,
).Scan(&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
&value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version,
&value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, &value.LifecycleStatus, &value.Version,
&value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt)
if err != nil {
if isUniqueViolation(err) {
@ -90,7 +93,8 @@ func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConne
var value SecurityEventConnection
err := s.pool.QueryRow(ctx, `
SELECT connection.connection_id::text,connection.transmitter_issuer,connection.endpoint_url,connection.audience,
connection.stream_id::text,connection.credential_ref,connection.next_credential_ref,connection.lifecycle_status,
connection.stream_id::text,connection.credential_ref,connection.next_credential_ref,
COALESCE(connection.management_client_id,''),connection.management_credential_ref,connection.lifecycle_status,
connection.version,connection.last_error_category,connection.idempotency_key,connection.created_at,connection.updated_at,
state.last_verification_at,COALESCE(state.mode,'disabled')
FROM gateway_security_event_connections connection
@ -98,7 +102,8 @@ LEFT JOIN gateway_security_event_stream_state state
ON state.issuer=connection.transmitter_issuer AND state.audience=connection.audience
WHERE connection.singleton=true`).Scan(
&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
&value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version,
&value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef,
&value.LifecycleStatus, &value.Version,
&value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt,
&value.LastVerificationAt, &value.HealthMode,
)
@ -121,6 +126,19 @@ WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID,
return s.SecurityEventConnection(ctx)
}
func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL,updated_at=now()
WHERE connection_id=$1::uuid`, connectionID, clientID, reference)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) UpdateSecurityEventConnectionLifecycle(ctx context.Context, connectionID, lifecycle string, errorCategory *string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET lifecycle_status=$2,last_error_category=$3,version=version+1,updated_at=now() WHERE connection_id=$1::uuid`, connectionID, lifecycle, errorCategory)

View File

@ -0,0 +1,9 @@
ALTER TABLE gateway_security_event_connections
ADD COLUMN IF NOT EXISTS management_client_id text,
ADD COLUMN IF NOT EXISTS management_credential_ref text;
ALTER TABLE gateway_security_event_connections
ADD CONSTRAINT gateway_security_event_management_credential_pair CHECK (
(management_client_id IS NULL AND management_credential_ref IS NULL) OR
(management_client_id IS NOT NULL AND management_credential_ref IS NOT NULL)
);

View File

@ -1029,12 +1029,12 @@ export function App() {
}
}
async function connectSecurityEvents(transmitterIssuer: string) {
async function connectSecurityEvents(transmitterIssuer: string, managementClientId = '', managementClientSecret = '') {
setCoreState('loading');
setCoreMessage('');
try {
const version = securityEventConnection?.connection?.version;
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, version);
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, managementClientId, managementClientSecret, version);
setSecurityEventConnection(connection);
setCoreState('ready');
setCoreMessage('认证中心安全事件连接正在验证。');

View File

@ -40,7 +40,7 @@ describe('security event connection transport', () => {
vi.unstubAllGlobals();
});
it('sends only the public transmitter issuer and concurrency headers', async () => {
it('sends the one-time machine credential only in the connection request', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
@ -48,12 +48,15 @@ describe('security event connection transport', () => {
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf');
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('PUT');
expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' });
expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i);
expect(JSON.parse(String(init.body))).toEqual({
transmitter_issuer: 'https://auth.example/ssf',
management_client_id: 'gateway-machine',
management_client_secret: 'one-time-machine-secret',
});
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-');
expect(new Headers(init.headers).has('If-Match')).toBe(false);
});
@ -66,7 +69,7 @@ describe('security event connection transport', () => {
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 7);
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret', 7);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');

View File

@ -1007,11 +1007,21 @@ export async function getSecurityEventConnection(token: string): Promise<Securit
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
}
export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string, version?: number): Promise<SecurityEventConnectionResponse> {
export async function connectSecurityEventTransmitter(
token: string,
transmitterIssuer: string,
managementClientId: string,
managementClientSecret: string,
version?: number,
): Promise<SecurityEventConnectionResponse> {
const headers: Record<string, string> = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` };
if (version !== undefined) headers['If-Match'] = `W/"${version}"`;
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token,
body: {
transmitter_issuer: transmitterIssuer,
management_client_id: managementClientId,
management_client_secret: managementClientSecret,
}, method: 'PUT', token,
headers,
});
}

View File

@ -64,7 +64,7 @@ export function SystemSettingsPanel(props: {
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
@ -411,7 +411,7 @@ function SecurityEventConnectionPanel(props: {
issuer: string;
loading: boolean;
onIssuerChange(value: string): void;
onConnect(issuer: string): Promise<void>;
onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise<void>;
onDisconnect(): void;
onRefresh(): Promise<void>;
onRotate(): Promise<void>;
@ -419,9 +419,13 @@ function SecurityEventConnectionPanel(props: {
}) {
const connection = props.connection?.connection;
const prerequisites = props.connection?.prerequisites;
const [managementClientId, setManagementClientId] = useState('');
const [managementClientSecret, setManagementClientSecret] = useState('');
useEffect(() => {
if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId);
}, [managementClientId, prerequisites?.managementClientId]);
const missing = [
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
!prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '',
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
].filter(Boolean);
return (
@ -446,15 +450,22 @@ function SecurityEventConnectionPanel(props: {
<p className="mutedText"> Application Gateway </p>
</div>
{missing.length > 0 && <div className="formMessage">{missing.join('')}</div>}
<div className="fileStorageMeta">
<span> Client: {prerequisites?.managementClientId || '未配置'}</span>
</div>
<Label>
SSF Transmitter Issuer
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
<small> IssuerEndpointScopePush Bearer Stream </small>
<small>EndpointScopePush Bearer Stream </small>
</Label>
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim()} onClick={() => void props.onConnect(props.issuer.trim())}>
<Label>
Machine Client ID
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
</Label>
<Label>
Machine Client Secret
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
<small>Gateway SecretStore</small>
</Label>
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim() || !managementClientId.trim() || !managementClientSecret}
onClick={() => void props.onConnect(props.issuer.trim(), managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
<Link2 size={15} />
</Button>
</CardContent>
@ -475,6 +486,21 @@ function SecurityEventConnectionPanel(props: {
{props.connection?.auditId && <span> Audit ID: {props.connection.auditId}</span>}
{connection.lastErrorCategory && <span>: {connection.lastErrorCategory}</span>}
</div>
{(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') && <div className="pageStack">
<div className="formMessage"> Stream</div>
<Label>
Machine Client ID
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
</Label>
<Label>
Machine Client Secret
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
</Label>
<Button type="button" disabled={props.loading || !managementClientId.trim() || !managementClientSecret}
onClick={() => void props.onConnect(connection.transmitterIssuer, managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
<Link2 size={15} />
</Button>
</div>}
<div className="fileStorageToolbar">
{connection.lifecycleStatus === 'error' && (
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} /></Button>

View File

@ -7,17 +7,18 @@ Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Securit
用户只执行两项操作:
1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope不会创建第二个 Client。
2. 在 Gateway“系统设置 → 认证中心安全事件”中填写认证中心公开 SSF Issuer点击“连接认证中心”。
2. Receiver 就绪后点击“生成一次性连接凭据”,立即复制 machine Client ID/Secret。
3. 在 Gateway“系统设置 → 认证中心安全事件”中填写 SSF Issuer 和这组一次性凭据,点击“连接认证中心”。
Gateway 后端随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream并进入 360 秒 RFC 7662 bootstrap。浏览器看不到 Push Bearer、OAuth Secret 或 Secret 引用,管理员不编辑 Secret 文件,也不需要重启 Gateway。
Gateway 后端先把 machine Secret 写入 SecretStore随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream并进入 360 秒 RFC 7662 bootstrap。浏览器只在两端连接表单的短暂操作期间接触 machine SecretPush Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。
前置配置只有既有 OIDC/RFC 7662 配置和 Gateway 公共地址:
环境前置配置只保留 OIDC 公共参数和 Gateway 公共地址:
- `OIDC_ISSUER`、`OIDC_TENANT_ID`
- `OIDC_INTROSPECTION_ENABLED=true`
- `OIDC_INTROSPECTION_CLIENT_ID/SECRET`,与认证中心准备 Receiver 时选择的机器 Client 相同;
- `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。
`OIDC_INTROSPECTION_CLIENT_ID/SECRET` 仅作为旧部署兼容回退,不再是新连接必填项。新连接把 machine 凭据动态托管到 SecretStoreOIDC fallback、SSF 管理 Token 和 Verification 共用这一份凭据,重启后继续生效。
第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。
## SecretStore

View File

@ -976,6 +976,7 @@ export interface SecurityEventConnectionPrerequisites {
introspectionClientConfigured: boolean;
publicBaseUrlConfigured: boolean;
managementClientId: string;
credentialInputSupported: boolean;
}
export interface SecurityEventConnectionResponse {