feat: 接入 SSF 实时会话撤销

This commit is contained in:
chengcheng 2026-07-14 17:06:41 +08:00
parent 30ad0e9f2c
commit f30aaeb2d4
28 changed files with 2597 additions and 91 deletions

View File

@ -35,6 +35,22 @@ OIDC_ACCEPT_LEGACY_HS256=true
OIDC_INTROSPECTION_ENABLED=false
OIDC_INTROSPECTION_CLIENT_ID=
OIDC_INTROSPECTION_CLIENT_SECRET=
# Optional SSF/CAEP real-time revocation receiver. Enabling it also requires
# the RFC 7662 credentials above so bootstrap and fail-closed fallback work.
OIDC_SECURITY_EVENTS_ENABLED=false
OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER=https://auth.51easyai.com/ssf
OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE=
OIDC_SECURITY_EVENTS_BEARER_SECRET=
# During zero-downtime rotation, set next before removing current.
OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT=
OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT=https://api.51easyai.com/api/v1/security-events/ssf
OIDC_SECURITY_EVENTS_STREAM_ID=
OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL=
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID=
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET=
OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60
OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS=180
OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60
# Controlled JIT is opt-in. When enabled, bind the validated Auth Center tid to
# one existing active Gateway tenant; tokens never create Gateway tenants.
OIDC_JIT_PROVISIONING_ENABLED=false

View File

@ -56,6 +56,8 @@ OIDC_SESSION_ENCRYPTION_KEY=<base64 编码的独立 32 字节随机密钥>
Web Console 使用公共 Client + PKCE + Gateway BFF SessionGateway 服务端换码并加密保存 Access/Refresh/ID Token浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。
可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token并删除同一租户 Subject 的 BFF Session。推送不健康时自动切换 RFC 7662内省也不可用时 OIDC Fail ClosedAPI Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。
`pnpm dev` 会先创建数据库并执行 migration然后并行启动
- `api:dev`:通过 `scripts/go-watch.mjs` 运行 Go API监听 `.go`、`go.mod`、`go.sum` 变化并自动重启后端进程watcher 会按进程组终止旧的 `go run` 和其子进程,避免热更新时残留进程占用 API 端口。

View File

@ -5506,6 +5506,80 @@
}
}
},
"/api/v1/security-events/ssf": {
"post": {
"description": "Optional endpoint. It validates a stream-specific Bearer before parsing and verifying an ES256 SET. A committed event and a duplicate jti both return an empty 202 response.",
"consumes": [
"application/secevent+jwt"
],
"produces": [
"application/json"
],
"tags": [
"Security Events"
],
"summary": "Receive an SSF Security Event Token",
"parameters": [
{
"type": "string",
"description": "Bearer stream-specific-secret",
"name": "Authorization",
"in": "header",
"required": true
},
{
"description": "Compact signed Security Event Token",
"name": "set",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"202": {
"description": "Accepted"
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/song/generations": {
"post": {
"security": [

View File

@ -6073,6 +6073,57 @@ paths:
summary: 创建或执行 AI 任务
tags:
- tasks
/api/v1/security-events/ssf:
post:
consumes:
- application/secevent+jwt
description: Optional endpoint. It validates a stream-specific Bearer before
parsing and verifying an ES256 SET. A committed event and a duplicate jti
both return an empty 202 response.
parameters:
- description: Bearer stream-specific-secret
in: header
name: Authorization
required: true
type: string
- description: Compact signed Security Event Token
in: body
name: set
required: true
schema:
type: string
produces:
- application/json
responses:
"202":
description: Accepted
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"503":
description: Service Unavailable
schema:
additionalProperties:
type: string
type: object
summary: Receive an SSF Security Event Token
tags:
- Security Events
/api/v1/song/generations:
post:
consumes:

View File

@ -9,6 +9,7 @@ require (
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/riverqueue/river v0.24.0
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0

View File

@ -19,6 +19,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@ -50,6 +50,8 @@ type User struct {
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
APIKeyScopes []string `json:"apiKeyScopes,omitempty"`
TokenExpiresAt time.Time `json:"-"`
TokenIssuedAt time.Time `json:"-"`
Issuer string `json:"-"`
}
type contextKey string

View File

@ -5,6 +5,7 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
@ -24,16 +25,31 @@ 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
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()
}
type OIDCSecurityEventIdentity struct {
Issuer string
TenantID string
Subject string
IssuedAt time.Time
}
type OIDCSecurityEventEvaluation struct {
Revoked bool
RequireIntrospection bool
}
type OIDCVerifier struct {
@ -75,7 +91,7 @@ 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.SecurityEventEvaluator != nil) && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
return nil, errors.New("introspection client credentials are required when introspection is enabled")
}
if config.JWKSCacheTTL <= 0 {
@ -83,9 +99,12 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
}
client := config.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DisableCompression = true
client = &http.Client{Timeout: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}}
}, Transport: transport}
}
return &OIDCVerifier{config: config, client: client, keys: map[string]any{}}, nil
}
@ -125,6 +144,10 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if _, ok := numericDateClaim(claims["nbf"]); !ok {
return nil, oidcUnauthorized("nbf is missing", nil)
}
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
if v.config.SecurityEventEvaluator != nil && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
}
scopes := scopeClaims(claims)
if !containsAll(scopes, v.config.RequiredScopes) {
return nil, oidcUnauthorized("required scope is missing", nil)
@ -136,7 +159,26 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if len(roles) == 0 {
return nil, oidcUnauthorized("mapped role is missing", nil)
}
if v.config.IntrospectionEnabled {
if v.config.SecurityEventEvaluator != nil {
evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{
Issuer: v.config.Issuer, TenantID: v.config.TenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
})
if evaluateErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
}
if evaluation.Revoked {
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
}
if evaluation.RequireIntrospection {
active, introspectionErr := v.introspect(ctx, raw)
if introspectionErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_INTROSPECTION_UNAVAILABLE", "认证中心内省暂时不可用")
}
if !active {
return nil, oidcUnauthorized("token is inactive", nil)
}
}
} else if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)
if err != nil || !active {
return nil, oidcUnauthorized("token is inactive", err)
@ -148,7 +190,7 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
}
return &User{
ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt,
TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
}, nil
}
@ -161,6 +203,9 @@ func oidcUnauthorized(reason string, cause error) error {
func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {
if err := v.refresh(ctx, false); err != nil {
if v.config.JWKSRefreshFailureObserver != nil {
v.config.JWKSRefreshFailureObserver()
}
return nil, err
}
v.mu.Lock()
@ -170,6 +215,9 @@ func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {
return key, nil
}
if err := v.refresh(ctx, true); err != nil {
if v.config.JWKSRefreshFailureObserver != nil {
v.config.JWKSRefreshFailureObserver()
}
return nil, err
}
v.mu.Lock()
@ -195,7 +243,7 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil {
return errors.New("OIDC discovery metadata is invalid")
}
if v.config.IntrospectionEnabled && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
return errors.New("OIDC introspection metadata is invalid")
}
var set jsonWebKeySet
@ -222,6 +270,12 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
}
func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) {
outcome := "failed"
defer func() {
if v.config.IntrospectionObserver != nil {
v.config.IntrospectionObserver(outcome)
}
}()
v.mu.Lock()
endpoint := v.introspectionEndpoint
v.mu.Unlock()
@ -252,6 +306,11 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error)
if err := decoder.Decode(&result); err != nil {
return false, errors.New("OIDC introspection response is invalid")
}
if result.Active {
outcome = "active"
} else {
outcome = "inactive"
}
return result.Active, nil
}

View File

@ -8,6 +8,7 @@ import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
@ -153,6 +154,59 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
}
}
func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
active, introspectionAvailable, introspectionCalls := true, true, 0
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks", "introspection_endpoint": issuer + "/introspect"})
case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}})
case "/introspect":
introspectionCalls++
if !introspectionAvailable {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"active": active})
}
}))
defer server.Close()
issuer = server.URL
evaluation := OIDCSecurityEventEvaluation{RequireIntrospection: true}
verifier, err := NewOIDCVerifier(OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret",
SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) {
if identity.Subject != "platform-subject" || identity.IssuedAt.IsZero() {
t.Fatal("security event evaluator received incomplete identity")
}
return evaluation, nil
},
})
if err != nil {
t.Fatal(err)
}
raw := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, nil)
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{Revoked: true}
if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 {
t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls)
}
evaluation = OIDCSecurityEventEvaluation{RequireIntrospection: true}
introspectionAvailable = false
_, err = verifier.Verify(context.Background(), raw)
var requestError *RequestAuthError
if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable {
t.Fatalf("introspection outage error=%T %v", err, err)
}
}
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
t.Helper()
now := time.Now()

View File

@ -8,6 +8,8 @@ import (
"os"
"strconv"
"strings"
"github.com/google/uuid"
)
const (
@ -16,50 +18,63 @@ 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
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
OIDCEnabled bool
OIDCIssuer string
OIDCAudience string
OIDCTenantID string
OIDCRolePrefix string
OIDCRequiredScopes []string
OIDCJWKSCacheTTLSeconds int
OIDCAcceptLegacyHS256 bool
OIDCIntrospectionEnabled bool
OIDCIntrospectionClientID string
OIDCIntrospectionClientSecret string
OIDCSecurityEventsEnabled bool
OIDCSecurityEventsTransmitterIssuer string
OIDCSecurityEventsReceiverAudience string
OIDCSecurityEventsBearerSecret string
OIDCSecurityEventsBearerSecretNext string
OIDCSecurityEventsPublicEndpoint string
OIDCSecurityEventsStreamID string
OIDCSecurityEventsManagementTokenURL string
OIDCSecurityEventsManagementClientID string
OIDCSecurityEventsManagementClientSecret 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
}
func Load() Config {
@ -75,23 +90,36 @@ 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: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
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: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true",
OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""),
OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_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", "")),
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
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: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true",
OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"),
OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""),
OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""),
OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""),
OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""),
OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""),
OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""),
OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""),
OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""),
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",
@ -157,9 +185,59 @@ func (c Config) Validate() error {
}
}
}
if c.OIDCSecurityEventsEnabled {
if !c.OIDCEnabled {
return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled")
}
if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) ||
!validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) ||
!validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) {
return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs")
}
if _, err := uuid.Parse(c.OIDCTenantID); err != nil {
return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled")
}
applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:")
if applicationID == c.OIDCSecurityEventsReceiverAudience {
return errors.New("OIDC security event receiver audience and stream ID are required")
}
if _, err := uuid.Parse(applicationID); err != nil {
return errors.New("OIDC security event receiver audience must contain an application UUID")
}
if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil {
return errors.New("OIDC security event stream ID must be a UUID")
}
if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) ||
c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) {
return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters")
}
if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" {
return errors.New("OIDC security event management machine client credentials are required")
}
if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" {
return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled")
}
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")
}
}
return nil
}
func validBearerSecret(value string) bool {
if len(value) < 16 || len(value) > 4096 {
return false
}
for _, character := range []byte(value) {
if character < 0x21 || character > 0x7e {
return false
}
}
return true
}
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
@ -182,6 +260,25 @@ func validPublicRedirectURL(raw string) bool {
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func validServiceURL(raw, appEnv string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return false
}
if parsed.Scheme == "https" {
return true
}
return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func validSecurityEventReceiverURL(raw, appEnv string) bool {
if !validServiceURL(raw, appEnv) {
return false
}
parsed, err := url.Parse(strings.TrimSpace(raw))
return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == ""
}
func isLocalEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":

View File

@ -136,3 +136,34 @@ func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) {
t.Fatalf("Validate() error = %v, want offline_access rejection", err)
}
}
func TestSecurityEventsAreOptionalButFailFastWhenPartiallyConfigured(t *testing.T) {
if err := (Config{}).Validate(); err != nil {
t.Fatalf("disabled security events changed existing config: %v", err)
}
cfg := Config{AppEnv: "test", OIDCEnabled: true, OIDCSecurityEventsEnabled: true}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "issuer") {
t.Fatalf("partial security event config error = %v", err)
}
cfg.OIDCSecurityEventsTransmitterIssuer = "http://localhost:8080/ssf"
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/api/v1/security-events/ssf"
cfg.OIDCSecurityEventsManagementTokenURL = "http://localhost:8080/oauth/token"
cfg.OIDCTenantID = "00000000-0000-4000-8000-000000000003"
cfg.OIDCSecurityEventsReceiverAudience = "urn:easyai:ssf:receiver:00000000-0000-4000-8000-000000000002"
cfg.OIDCSecurityEventsStreamID = "00000000-0000-4000-8000-000000000001"
cfg.OIDCSecurityEventsBearerSecret = "receiver-secret-at-least-16"
cfg.OIDCSecurityEventsManagementClientID = "gateway-ssf"
cfg.OIDCSecurityEventsManagementClientSecret = "management-secret"
cfg.OIDCIntrospectionClientID = "gateway-introspection"
cfg.OIDCIntrospectionClientSecret = "introspection-secret"
cfg.OIDCSecurityEventsHeartbeatIntervalSeconds = 60
cfg.OIDCSecurityEventsStaleAfterSeconds = 180
cfg.OIDCSecurityEventsClockSkewSeconds = 60
if err := cfg.Validate(); err != nil {
t.Fatalf("valid security event config: %v", err)
}
cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/wrong-path"
if err := cfg.Validate(); err == nil {
t.Fatal("non-canonical security event receiver path was accepted")
}
}

View File

@ -0,0 +1,22 @@
package httpapi
import "net/http"
// receiveSecurityEvent accepts an RFC 8417 Security Event Token over RFC 8935 Push.
//
// @Summary Receive an SSF Security Event Token
// @Description Optional endpoint. It validates a stream-specific Bearer before parsing and verifying an ES256 SET. A committed event and a duplicate jti both return an empty 202 response.
// @Tags Security Events
// @Accept application/secevent+jwt
// @Produce json
// @Param Authorization header string true "Bearer stream-specific-secret"
// @Param set body string true "Compact signed Security Event Token"
// @Success 202
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/security-events/ssf [post]
func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) {
s.securityEventReceiver.ServeHTTP(w, r)
}

View File

@ -13,21 +13,23 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
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
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
}
type oidcPublicClient interface {
@ -63,14 +65,58 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
securityEventMetrics := &ssfreceiver.Metrics{}
var securityEventService *ssfreceiver.Service
if cfg.OIDCSecurityEventsEnabled {
ssfVerifier, err := ssfreceiver.NewVerifier(ssfreceiver.VerifierConfig{
TransmitterIssuer: cfg.OIDCSecurityEventsTransmitterIssuer,
Audience: cfg.OIDCSecurityEventsReceiverAudience, SubjectIssuer: cfg.OIDCIssuer,
TenantID: cfg.OIDCTenantID, StreamID: cfg.OIDCSecurityEventsStreamID,
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
})
if err != nil {
panic("invalid OIDC security event verifier configuration: " + err.Error())
}
ssfVerifier.SetMetrics(securityEventMetrics)
securityEventService, err = ssfreceiver.NewService(db, ssfreceiver.ServiceConfig{
Issuer: cfg.OIDCSecurityEventsTransmitterIssuer, Audience: cfg.OIDCSecurityEventsReceiverAudience,
StreamID: cfg.OIDCSecurityEventsStreamID, ManagementTokenURL: cfg.OIDCSecurityEventsManagementTokenURL,
ManagementClientID: cfg.OIDCSecurityEventsManagementClientID, ManagementSecret: cfg.OIDCSecurityEventsManagementClientSecret,
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second,
})
if err != nil {
panic("invalid OIDC security event heartbeat configuration: " + err.Error())
}
securityEventService.SetMetrics(securityEventMetrics)
if err := securityEventService.Start(ctx); err != nil {
panic("OIDC security event state initialization failed")
}
receiver, err := ssfreceiver.NewHandler(
ssfVerifier, db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience,
cfg.OIDCSecurityEventsStreamID, cfg.OIDCSecurityEventsBearerSecret, cfg.OIDCSecurityEventsBearerSecretNext,
)
if err != nil {
panic("invalid OIDC security event receiver configuration: " + err.Error())
}
receiver.SetMetrics(securityEventMetrics)
server.securityEventReceiver = receiver
}
if cfg.OIDCEnabled {
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
if securityEventService != nil {
evaluator = securityEventService.Evaluate
}
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,
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") },
})
if err != nil {
panic("invalid OIDC configuration: " + err.Error())
@ -118,6 +164,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.Handle("GET /metrics", securityEventMetrics.Handler(db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience, cfg.OIDCSecurityEventsEnabled))
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
@ -128,6 +175,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
if server.securityEventReceiver != nil {
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
}
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels)))
@ -284,6 +334,8 @@ func oidcSessionRequestError(err error) error {
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_REFRESH_UNAVAILABLE", "认证中心暂时不可用,请稍后重试")
case errors.Is(err, oidcsession.ErrSessionStoreUnavailable):
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_STORE_UNAVAILABLE", "登录会话存储暂时不可用")
case errors.Is(err, oidcsession.ErrSecurityStateUnavailable):
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
case errors.Is(err, oidcsession.ErrGatewayUserDisabled):
return auth.NewRequestAuthError(http.StatusForbidden, "GATEWAY_USER_DISABLED", "该 Gateway 账号已停用,请联系管理员")
default:

View File

@ -20,6 +20,7 @@ var (
ErrSessionRefreshUnavailable = errors.New("OIDC session refresh is unavailable")
ErrSessionStoreUnavailable = errors.New("OIDC session store is unavailable")
ErrGatewayUserDisabled = errors.New("gateway user is disabled")
ErrSecurityStateUnavailable = errors.New("OIDC security event state is unavailable")
)
type Repository interface {
@ -179,11 +180,17 @@ func (s *Service) refresh(ctx context.Context, hash []byte, record store.OIDCSes
}
return user, nil
}
if errors.Is(verifyErr, ErrSecurityStateUnavailable) {
return nil, verifyErr
}
}
return nil, ErrSessionRefreshUnavailable
}
user, err := s.verifySessionUser(ctx, record, refreshed.AccessToken)
if err != nil {
if errors.Is(err, ErrSecurityStateUnavailable) {
return nil, err
}
_ = s.repository.DeleteOIDCSessionByID(ctx, record.ID)
return nil, ErrSessionExpired
}
@ -241,12 +248,19 @@ func (s *Service) waitForRefresh(ctx context.Context, hash []byte, original stor
}
return user, nil
}
if errors.Is(err, ErrSecurityStateUnavailable) {
return nil, err
}
}
return nil, ErrSessionRefreshUnavailable
}
func (s *Service) verifySessionUser(ctx context.Context, record store.OIDCSession, accessToken string) (*auth.User, error) {
user, err := s.verifier.Verify(ctx, accessToken)
var requestError *auth.RequestAuthError
if errors.As(err, &requestError) && requestError.Status == 503 {
return nil, ErrSecurityStateUnavailable
}
if err != nil || user == nil || user.Source != "oidc" || user.ID != record.ExternalUserID {
return nil, ErrSessionInvalid
}

View File

@ -0,0 +1,185 @@
package securityevents
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type Repository interface {
ApplySessionRevoked(context.Context, store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error)
ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error)
ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error)
RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error)
}
type Handler struct {
verifier *Verifier
repository Repository
issuer string
audience string
streamID string
bearerSecret string
nextSecret string
clock func() time.Time
metrics *Metrics
}
func (h *Handler) SetMetrics(metrics *Metrics) { h.metrics = metrics }
func NewHandler(verifier *Verifier, repository Repository, issuer, audience, streamID, bearerSecret, nextSecret string) (*Handler, error) {
if verifier == nil || repository == nil || issuer == "" || audience == "" || streamID == "" || len(bearerSecret) < 16 {
return nil, errors.New("SSF receiver dependencies and bearer secret are required")
}
return &Handler{verifier: verifier, repository: repository, issuer: issuer, audience: audience, streamID: streamID, bearerSecret: bearerSecret, nextSecret: nextSecret, clock: time.Now}, nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
started := h.clock()
outcome := "rejected"
var sessionsDeleted int64
defer func() {
if h.metrics != nil {
h.metrics.ObserveReceipt(outcome, h.clock().Sub(started), sessionsDeleted)
}
}()
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !h.validAuthorization(r.Header.Get("Authorization")) {
w.Header().Set("WWW-Authenticate", `Bearer realm="ssf-receiver"`)
writeSETError(w, http.StatusUnauthorized, "authentication_failed")
return
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/secevent+jwt" || r.Header.Get("Content-Encoding") != "" {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
payload, err := io.ReadAll(r.Body)
if err != nil || len(payload) == 0 {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
event, err := h.verifier.Verify(r.Context(), string(payload))
if err != nil {
var protocol *protocolError
if errors.As(err, &protocol) {
writeSETError(w, http.StatusBadRequest, protocol.Code)
} else {
writeSETError(w, http.StatusBadRequest, "invalid_request")
}
return
}
switch event.EventType {
case SessionRevokedEventType:
var result store.ApplySecurityEventResult
result, err = h.repository.ApplySessionRevoked(r.Context(), store.ApplySessionRevokedInput{
Issuer: event.Issuer, Audience: event.Audience, JTI: event.JTI, TransactionID: event.TransactionID,
SubjectIssuer: event.SubjectIssuer, TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp,
InitiatingEntity: event.InitiatingEntity,
})
if err == nil {
sessionsDeleted = result.SessionsDeleted
if result.Duplicate {
outcome = "duplicate"
} else {
outcome = "accepted"
}
}
case VerificationEventType:
var confirmed bool
if event.State == "" {
confirmed, err = h.repository.RecordSecurityEventReceipt(r.Context(), h.issuer, h.audience, event.JTI, VerificationEventType, h.streamID)
} else {
stateHash := sha256.Sum256([]byte(event.State))
confirmed, err = h.repository.ConfirmSecurityEventVerification(r.Context(), h.issuer, h.audience, h.streamID, event.JTI, stateHash[:], h.clock().UTC())
}
if err != nil && strings.Contains(err.Error(), "invalid verification state") {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
if err == nil {
if confirmed {
outcome = "accepted"
if h.metrics != nil {
h.metrics.ObserveVerificationAccepted()
}
} else {
outcome = "duplicate"
}
}
case StreamUpdatedEventType:
var inserted bool
inserted, err = h.repository.ApplySecurityEventStreamUpdated(
r.Context(), h.issuer, h.audience, event.JTI, h.streamID, event.StreamStatus, event.Reason, h.clock().UTC(),
)
if err == nil {
if inserted {
outcome = "accepted"
} else {
outcome = "duplicate"
}
}
}
if err != nil {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (h *Handler) validAuthorization(value string) bool {
if constantBearerCompare(value, h.bearerSecret) {
return true
}
return h.nextSecret != "" && constantBearerCompare(value, h.nextSecret)
}
func constantBearerCompare(header, secret string) bool {
providedHash := sha256.Sum256([]byte(header))
expectedHash := sha256.Sum256([]byte("Bearer " + secret))
return subtle.ConstantTimeCompare(providedHash[:], expectedHash[:]) == 1
}
func writeSETError(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Language", "en")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{
"err": code,
"description": setErrorDescription(code),
})
}
func setErrorDescription(code string) string {
switch code {
case "invalid_request":
return "The SET request is malformed or contains invalid claims."
case "invalid_key":
return "The SET signing key or signature is invalid."
case "invalid_issuer":
return "The SET issuer is not accepted by this receiver."
case "invalid_audience":
return "The SET audience does not identify this receiver."
case "authentication_failed":
return "Receiver authentication failed."
case "access_denied":
return "The SET is not permitted for this receiver."
default:
return "The SET request could not be processed."
}
}

View File

@ -0,0 +1,269 @@
package securityevents
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type fakeRepository struct {
input *store.ApplySessionRevokedInput
dedup map[string]struct{}
confirmed bool
}
func (f *fakeRepository) ApplySessionRevoked(_ context.Context, input store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) {
if f.dedup == nil {
f.dedup = make(map[string]struct{})
}
if _, exists := f.dedup[input.JTI]; exists {
return store.ApplySecurityEventResult{Duplicate: true}, nil
}
f.dedup[input.JTI] = struct{}{}
f.input = &input
return store.ApplySecurityEventResult{WatermarkMoved: true, SessionsDeleted: 2}, nil
}
func (f *fakeRepository) ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error) {
f.confirmed = true
return true, nil
}
func (*fakeRepository) RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error) {
return true, nil
}
func (*fakeRepository) ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error) {
return true, nil
}
func TestReceiverAcceptsValidSessionRevokedAndDuplicate(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
var transmitter *httptest.Server
transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/ssf-configuration/ssf":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json"})
case "/ssf/jwks.json":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{
"kty": "EC", "kid": "current", "use": "sig", "alg": "ES256", "crv": "P-256",
"x": coordinate(privateKey.X), "y": coordinate(privateKey.Y),
}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer transmitter.Close()
tenantID, subjectID, applicationID, streamID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
audience := "urn:easyai:ssf:receiver:" + applicationID
verifier, err := NewVerifier(VerifierConfig{
TransmitterIssuer: transmitter.URL + "/ssf", Audience: audience,
SubjectIssuer: "https://auth.example/issuer/tenant-a", TenantID: tenantID, StreamID: streamID,
ClockSkew: time.Minute,
})
if err != nil {
t.Fatal(err)
}
repository := &fakeRepository{}
handler, err := NewHandler(verifier, repository, transmitter.URL+"/ssf", audience, streamID, "receiver-bearer-secret", "next-receiver-secret")
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Truncate(time.Second)
jti := uuid.NewString()
set := signSET(t, privateKey, map[string]any{
"iss": transmitter.URL + "/ssf", "aud": audience, "iat": now.Unix(), "jti": jti, "txn": uuid.NewString(),
"sub_id": map[string]any{
"format": "complex",
"user": map[string]any{"format": "iss_sub", "iss": "https://auth.example/issuer/tenant-a", "sub": subjectID},
"tenant": map[string]any{"format": "opaque", "id": tenantID},
},
"events": map[string]any{SessionRevokedEventType: map[string]any{"event_timestamp": now.Unix(), "initiating_entity": "admin"}},
})
for _, secret := range []string{"receiver-bearer-secret", "next-receiver-secret"} {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer "+secret)
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted || response.Body.Len() != 0 {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
if repository.input == nil || repository.input.Subject != subjectID || repository.input.TenantID != tenantID ||
repository.input.SubjectIssuer != "https://auth.example/issuer/tenant-a" {
t.Fatalf("applied event = %#v", repository.input)
}
}
func TestReceiverRejectsAuthenticationMediaTypeAndForbiddenClaims(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, "https://auth.example/ssf", verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader("token"))
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("missing bearer status=%d", response.Code)
}
assertSETError(t, response, "authentication_failed")
set := signSET(t, privateKey, map[string]any{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "sub": "forbidden", "exp": time.Now().Add(time.Hour).Unix(), "events": map[string]any{},
})
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("wrong media status=%d", response.Code)
}
assertSETError(t, response, "invalid_request")
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request") {
t.Fatalf("forbidden claims status=%d body=%s", response.Code, response.Body.String())
}
future := time.Now().Add(10 * time.Minute)
futureSET := signSET(t, privateKey, map[string]any{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "txn": uuid.NewString(),
"sub_id": map[string]any{
"format": "complex",
"user": map[string]any{"format": "iss_sub", "iss": verifier.config.SubjectIssuer, "sub": uuid.NewString()},
"tenant": map[string]any{"format": "opaque", "id": verifier.config.TenantID},
},
"events": map[string]any{SessionRevokedEventType: map[string]any{
"event_timestamp": future.Unix(), "initiating_entity": "admin",
}},
})
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(futureSET))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("future event timestamp status=%d", response.Code)
}
}
func TestReceiverReturnsRFC8935IssuerAudienceAndKeyErrors(t *testing.T) {
trustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
untrustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &trustedKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
baseClaims := jwt.MapClaims{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "events": map[string]any{},
}
tests := []struct {
name string
key *ecdsa.PrivateKey
mutate func(jwt.MapClaims)
code string
}{
{name: "issuer", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["iss"] = "https://wrong.example/ssf" }, code: "invalid_issuer"},
{name: "audience", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["aud"] = "urn:wrong" }, code: "invalid_audience"},
{name: "signature", key: untrustedKey, mutate: func(jwt.MapClaims) {}, code: "invalid_key"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
claims := jwt.MapClaims{}
for key, value := range baseClaims {
claims[key] = value
}
test.mutate(claims)
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(signSET(t, test.key, claims)))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
assertSETError(t, response, test.code)
})
}
}
func TestReceiverAcceptsStreamUpdatedEvent(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
set := signSET(t, privateKey, jwt.MapClaims{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(), "jti": uuid.NewString(),
"sub_id": map[string]any{"format": "opaque", "id": verifier.config.StreamID},
"events": map[string]any{StreamUpdatedEventType: map[string]any{"status": "paused", "reason": "maintenance"}},
})
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted || response.Body.Len() != 0 {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
func assertSETError(t *testing.T, response *httptest.ResponseRecorder, code string) {
t.Helper()
if response.Header().Get("Content-Language") != "en" {
t.Fatalf("Content-Language=%q", response.Header().Get("Content-Language"))
}
var payload map[string]string
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("invalid error body: %v", err)
}
if payload["err"] != code || payload["description"] == "" {
t.Fatalf("error payload=%#v", payload)
}
}
func signSET(t *testing.T, key *ecdsa.PrivateKey, claims jwt.MapClaims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["typ"], token.Header["kid"] = "secevent+jwt", "current"
raw, err := token.SignedString(key)
if err != nil {
t.Fatal(err)
}
return raw
}
func coordinate(value *big.Int) string {
payload := make([]byte, 32)
value.FillBytes(payload)
return base64.RawURLEncoding.EncodeToString(payload)
}

View File

@ -0,0 +1,168 @@
package securityevents
import (
"context"
"fmt"
"net/http"
"sync/atomic"
"time"
)
type MetricsSnapshotProvider interface {
SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error)
}
type Metrics struct {
accepted atomic.Uint64
rejected atomic.Uint64
duplicate atomic.Uint64
sessionsDeleted atomic.Uint64
watermarkRejected atomic.Uint64
verificationAccepted atomic.Uint64
heartbeatAccepted atomic.Uint64
heartbeatFailed atomic.Uint64
introspectionActive atomic.Uint64
introspectionInactive atomic.Uint64
introspectionFailed atomic.Uint64
jwksSSFFailed atomic.Uint64
jwksOIDCFailed atomic.Uint64
processingCount atomic.Uint64
processingNanos atomic.Uint64
processingBuckets [6]atomic.Uint64
}
var processingDurationBounds = [...]time.Duration{
10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond,
500 * time.Millisecond, time.Second, 3 * time.Second,
}
func (m *Metrics) ObserveReceipt(outcome string, duration time.Duration, sessionsDeleted int64) {
switch outcome {
case "accepted":
m.accepted.Add(1)
case "duplicate":
m.duplicate.Add(1)
default:
m.rejected.Add(1)
}
if sessionsDeleted > 0 {
m.sessionsDeleted.Add(uint64(sessionsDeleted))
}
if duration < 0 {
duration = 0
}
m.processingCount.Add(1)
m.processingNanos.Add(uint64(duration))
for index, bound := range processingDurationBounds {
if duration <= bound {
m.processingBuckets[index].Add(1)
}
}
}
func (m *Metrics) ObserveWatermarkRejection() { m.watermarkRejected.Add(1) }
func (m *Metrics) ObserveVerificationAccepted() { m.verificationAccepted.Add(1) }
func (m *Metrics) ObserveHeartbeat(outcome string) {
if outcome == "accepted" {
m.heartbeatAccepted.Add(1)
return
}
m.heartbeatFailed.Add(1)
}
func (m *Metrics) ObserveIntrospection(outcome string) {
switch outcome {
case "active":
m.introspectionActive.Add(1)
case "inactive":
m.introspectionInactive.Add(1)
default:
m.introspectionFailed.Add(1)
}
}
func (m *Metrics) ObserveJWKSRefreshFailure(source string) {
if source == "ssf" {
m.jwksSSFFailed.Add(1)
return
}
m.jwksOIDCFailed.Add(1)
}
func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience string, enabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mode := "disabled"
var lastVerificationAt *time.Time
if enabled {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
var err error
mode, lastVerificationAt, err = provider.SecurityEventMetrics(ctx, issuer, audience)
if err != nil {
http.Error(w, "metrics unavailable", http.StatusServiceUnavailable)
return
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
outcomeCounters(w, "easyai_gateway_ssf_receipts_total", "Received SETs by bounded outcome.", []outcomeValue{
{"accepted", m.accepted.Load()}, {"rejected", m.rejected.Load()}, {"duplicate", m.duplicate.Load()},
})
plainCounter(w, "easyai_gateway_ssf_sessions_deleted_total", "OIDC browser sessions deleted by accepted session-revoked events.", m.sessionsDeleted.Load())
plainCounter(w, "easyai_gateway_ssf_watermark_rejections_total", "OIDC access tokens rejected by a local revocation watermark.", m.watermarkRejected.Load())
plainCounter(w, "easyai_gateway_ssf_verifications_total", "Valid verification SETs accepted by the receiver.", m.verificationAccepted.Load())
outcomeCounters(w, "easyai_gateway_ssf_heartbeat_requests_total", "Verification requests by bounded outcome.", []outcomeValue{
{"accepted", m.heartbeatAccepted.Load()}, {"failed", m.heartbeatFailed.Load()},
})
outcomeCounters(w, "easyai_gateway_oidc_introspection_total", "OIDC introspection requests by bounded outcome.", []outcomeValue{
{"active", m.introspectionActive.Load()}, {"inactive", m.introspectionInactive.Load()}, {"failed", m.introspectionFailed.Load()},
})
outcomeCounters(w, "easyai_gateway_jwks_refresh_failures_total", "JWKS refresh failures by bounded source.", []outcomeValue{
{"ssf", m.jwksSSFFailed.Load()}, {"oidc", m.jwksOIDCFailed.Load()},
})
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_processing_duration_seconds SSF receiver transaction processing time.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_processing_duration_seconds histogram")
for index, bound := range processingDurationBounds {
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_bucket{le=\"%.2f\"} %d\n", bound.Seconds(), m.processingBuckets[index].Load())
}
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_bucket{le=\"+Inf\"} %d\n", m.processingCount.Load())
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_sum %.6f\n", float64(m.processingNanos.Load())/float64(time.Second))
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_count %d\n", m.processingCount.Load())
verificationAge := float64(0)
if lastVerificationAt != nil {
verificationAge = time.Since(*lastVerificationAt).Seconds()
if verificationAge < 0 {
verificationAge = 0
}
}
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_verification_age_seconds Seconds since the last matched verification SET.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_verification_age_seconds gauge")
fmt.Fprintf(w, "easyai_gateway_ssf_verification_age_seconds %.6f\n", verificationAge)
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_mode Current SSF receiver mode as a one-hot gauge.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_mode gauge")
for _, modeName := range []string{"disabled", "bootstrap", "push_healthy", "introspection_fallback"} {
value := 0
if mode == modeName {
value = 1
}
fmt.Fprintf(w, "easyai_gateway_ssf_mode{mode=\"%s\"} %d\n", modeName, value)
}
})
}
type outcomeValue struct {
name string
value uint64
}
func outcomeCounters(w http.ResponseWriter, name, help string, values []outcomeValue) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n", name, help, name)
for _, value := range values {
fmt.Fprintf(w, "%s{outcome=\"%s\"} %d\n", name, value.name, value.value)
}
}
func plainCounter(w http.ResponseWriter, name, help string, value uint64) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", name, help, name, name, value)
}

View File

@ -0,0 +1,50 @@
package securityevents
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type metricsSnapshot struct {
mode string
last time.Time
}
func (m metricsSnapshot) SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) {
return m.mode, &m.last, nil
}
func TestMetricsExposeBoundedOutcomesAndState(t *testing.T) {
metrics := &Metrics{}
metrics.ObserveReceipt("accepted", 20*time.Millisecond, 2)
metrics.ObserveReceipt("duplicate", 10*time.Millisecond, 0)
metrics.ObserveWatermarkRejection()
metrics.ObserveHeartbeat("accepted")
metrics.ObserveIntrospection("failed")
metrics.ObserveJWKSRefreshFailure("ssf")
recorder := httptest.NewRecorder()
metrics.Handler(metricsSnapshot{mode: "push_healthy", last: time.Now().Add(-time.Minute)}, "issuer", "audience", true).
ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("metrics status=%d", recorder.Code)
}
for _, expected := range []string{
`easyai_gateway_ssf_receipts_total{outcome="accepted"} 1`,
`easyai_gateway_ssf_receipts_total{outcome="duplicate"} 1`,
`easyai_gateway_ssf_sessions_deleted_total 2`,
`easyai_gateway_ssf_watermark_rejections_total 1`,
`easyai_gateway_ssf_processing_duration_seconds_bucket{le="0.05"} 2`,
`easyai_gateway_ssf_mode{mode="push_healthy"} 1`,
`easyai_gateway_oidc_introspection_total{outcome="failed"} 1`,
`easyai_gateway_jwks_refresh_failures_total{outcome="ssf"} 1`,
} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("missing metric %q in:\n%s", expected, recorder.Body.String())
}
}
}

View File

@ -0,0 +1,211 @@
package securityevents
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type StateRepository interface {
EnsureSecurityEventStreamState(context.Context, string, string, string) error
BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error
RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error
AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error
EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error)
}
type ServiceConfig struct {
Issuer string
Audience string
StreamID string
ManagementTokenURL string
ManagementClientID string
ManagementSecret string
HeartbeatInterval time.Duration
StaleAfter time.Duration
HTTPClient *http.Client
}
type Service struct {
repository StateRepository
config ServiceConfig
client *http.Client
clock func() time.Time
tokenMu sync.Mutex
token string
tokenUntil time.Time
metrics *Metrics
}
func (s *Service) SetMetrics(metrics *Metrics) { s.metrics = metrics }
func NewService(repository StateRepository, config ServiceConfig) (*Service, error) {
if repository == nil || config.Issuer == "" || config.Audience == "" || config.StreamID == "" ||
config.ManagementTokenURL == "" || config.ManagementClientID == "" || config.ManagementSecret == "" {
return nil, errors.New("SSF heartbeat configuration is incomplete")
}
if config.HeartbeatInterval <= 0 || config.StaleAfter < 2*config.HeartbeatInterval {
return nil, errors.New("SSF heartbeat timing is invalid")
}
client := config.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DisableCompression = true
client = &http.Client{Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
}
return &Service{repository: repository, config: config, client: client, clock: time.Now}, nil
}
func (s *Service) Start(ctx context.Context) error {
if err := s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID); err != nil {
return err
}
go s.run(ctx)
return nil
}
func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
result, err := s.repository.EvaluateOIDCSecurityEvent(
ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject,
identity.IssuedAt, s.clock().UTC(), s.config.StaleAfter,
)
if err != nil {
return auth.OIDCSecurityEventEvaluation{}, err
}
if result.Revoked && s.metrics != nil {
s.metrics.ObserveWatermarkRejection()
}
return auth.OIDCSecurityEventEvaluation{Revoked: result.Revoked, RequireIntrospection: result.RequireIntrospection}, nil
}
func (s *Service) run(ctx context.Context) {
s.sendVerification(ctx)
ticker := time.NewTicker(s.config.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sendVerification(ctx)
}
}
}
func (s *Service) sendVerification(ctx context.Context) {
outcome := "failed"
defer func() {
if s.metrics != nil {
s.metrics.ObserveHeartbeat(outcome)
}
}()
if err := s.repository.AdvanceSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.clock().UTC(), s.config.StaleAfter); err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_advance_failed")
}
state, err := randomVerificationState()
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_generation_failed")
return
}
hash := sha256.Sum256([]byte(state))
now := s.clock().UTC()
if err := s.repository.BeginSecurityEventVerification(ctx, s.config.Issuer, s.config.Audience, hash[:], now); err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_persistence_failed")
return
}
token, err := s.managementToken(ctx)
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "management_token_failed")
return
}
payload, _ := json.Marshal(map[string]string{"stream_id": s.config.StreamID, "state": state})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.config.Issuer, "/")+"/v1/verify", bytes.NewReader(payload))
if err != nil {
return
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := s.client.Do(request)
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "verification_request_failed")
return
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
if response.StatusCode != http.StatusNoContent {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, fmt.Sprintf("verification_http_%d", response.StatusCode))
return
}
outcome = "accepted"
}
func (s *Service) managementToken(ctx context.Context) (string, error) {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
if s.token != "" && s.clock().Before(s.tokenUntil) {
return s.token, nil
}
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"ssf.stream.read ssf.stream.verify"}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.ManagementTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
request.SetBasicAuth(s.config.ManagementClientID, s.config.ManagementSecret)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := s.client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return "", fmt.Errorf("management token endpoint returned HTTP %d", response.StatusCode)
}
payload, err := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
if err != nil || len(payload) > 64*1024 {
return "", errors.New("management token response is too large")
}
var result struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
if json.Unmarshal(payload, &result) != nil || result.AccessToken == "" || !strings.EqualFold(result.TokenType, "Bearer") {
return "", errors.New("management token response is invalid")
}
if result.ExpiresIn <= 0 {
result.ExpiresIn = 60
}
cacheFor := time.Duration(result.ExpiresIn) * time.Second
if cacheFor > 30*time.Second {
cacheFor -= 30 * time.Second
}
s.token, s.tokenUntil = result.AccessToken, s.clock().Add(cacheFor)
return s.token, nil
}
func randomVerificationState() (string, error) {
payload := make([]byte, 32)
if _, err := rand.Read(payload); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}

View File

@ -0,0 +1,86 @@
package securityevents
import (
"context"
"crypto/sha256"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type fakeStateRepository struct {
mutex sync.Mutex
stateHash []byte
verification chan struct{}
evaluation store.SecurityEventEvaluation
}
func (*fakeStateRepository) EnsureSecurityEventStreamState(context.Context, string, string, string) error {
return nil
}
func (f *fakeStateRepository) BeginSecurityEventVerification(_ context.Context, _, _ string, hash []byte, _ time.Time) error {
f.mutex.Lock()
f.stateHash = append([]byte(nil), hash...)
f.mutex.Unlock()
return nil
}
func (*fakeStateRepository) RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error {
return nil
}
func (*fakeStateRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error {
return nil
}
func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
return f.evaluation, nil
}
func TestServiceRequestsVerificationWithMachineTokenAndPersistsStateFirst(t *testing.T) {
repository := &fakeStateRepository{verification: make(chan struct{}, 1)}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != "gateway-ssf" || secret != "management-secret" {
t.Fatal("machine client authentication is missing")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"management-token","token_type":"Bearer","expires_in":300}`))
case "/ssf/v1/verify":
if r.Header.Get("Authorization") != "Bearer management-token" {
t.Fatal("verification bearer is missing")
}
repository.mutex.Lock()
persisted := len(repository.stateHash) == sha256.Size
repository.mutex.Unlock()
if !persisted {
t.Fatal("verification state was not persisted before request")
}
w.WriteHeader(http.StatusNoContent)
repository.verification <- struct{}{}
}
}))
defer server.Close()
service, err := NewService(repository, ServiceConfig{
Issuer: server.URL + "/ssf", Audience: "urn:easyai:ssf:receiver:app", StreamID: "00000000-0000-4000-8000-000000000001",
ManagementTokenURL: server.URL + "/token", ManagementClientID: "gateway-ssf", ManagementSecret: "management-secret",
HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := service.Start(ctx); err != nil {
t.Fatal(err)
}
select {
case <-repository.verification:
case <-time.After(2 * time.Second):
t.Fatal("verification was not requested")
}
}

View File

@ -0,0 +1,412 @@
package securityevents
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
SessionRevokedEventType = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
VerificationEventType = "https://schemas.openid.net/secevent/ssf/event-type/verification"
StreamUpdatedEventType = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated"
)
type Event struct {
Issuer string
Audience string
JTI string
TransactionID string
IssuedAt time.Time
EventType string
EventTimestamp time.Time
SubjectIssuer string
Subject string
TenantID string
InitiatingEntity string
StreamID string
State string
StreamStatus string
Reason string
}
type VerifierConfig struct {
TransmitterIssuer string
Audience string
SubjectIssuer string
TenantID string
StreamID string
ClockSkew time.Duration
HTTPClient *http.Client
}
type Verifier struct {
config VerifierConfig
client *http.Client
mu sync.Mutex
keys map[string]*ecdsa.PublicKey
expiresAt time.Time
metrics *Metrics
clock func() time.Time
}
func (v *Verifier) SetMetrics(metrics *Metrics) { v.metrics = metrics }
type transmitterMetadata struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
}
type jwkSet struct {
Keys []struct {
KID string `json:"kid"`
KTY string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
} `json:"keys"`
}
type protocolError struct {
Code string
}
func (e *protocolError) Error() string { return e.Code }
func NewVerifier(config VerifierConfig) (*Verifier, error) {
config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/")
config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/")
if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" {
return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant are required")
}
if _, err := uuid.Parse(config.StreamID); err != nil {
return nil, errors.New("SSF stream id must be a UUID")
}
if config.ClockSkew < 0 || config.ClockSkew > 5*time.Minute {
return nil, errors.New("SSF clock skew is invalid")
}
client := config.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DisableCompression = true
client = &http.Client{Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
}
return &Verifier{config: config, client: client, keys: make(map[string]*ecdsa.PublicKey), clock: time.Now}, nil
}
func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) {
parser := jwt.NewParser(jwt.WithValidMethods([]string{"ES256"}))
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
if err != nil || unverified == nil || unverified.Header["typ"] != "secevent+jwt" || unverified.Header["alg"] != "ES256" {
return Event{}, &protocolError{Code: "invalid_request"}
}
unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims)
if !ok {
return Event{}, &protocolError{Code: "invalid_request"}
}
if stringClaim(unverifiedClaims, "iss") != v.config.TransmitterIssuer {
return Event{}, &protocolError{Code: "invalid_issuer"}
}
if !claimHasAudience(unverifiedClaims["aud"], v.config.Audience) {
return Event{}, &protocolError{Code: "invalid_audience"}
}
kid, _ := unverified.Header["kid"].(string)
if kid == "" {
return Event{}, &protocolError{Code: "invalid_key"}
}
key, err := v.key(ctx, kid)
if err != nil {
return Event{}, &protocolError{Code: "invalid_key"}
}
token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { return key, nil },
jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(v.config.TransmitterIssuer),
jwt.WithAudience(v.config.Audience), jwt.WithIssuedAt(), jwt.WithLeeway(v.config.ClockSkew))
if err != nil || !token.Valid {
if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
return Event{}, &protocolError{Code: "invalid_key"}
}
return Event{}, &protocolError{Code: "invalid_request"}
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, present := claims["sub"]; present {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, present := claims["exp"]; present {
return Event{}, &protocolError{Code: "invalid_request"}
}
jti := stringClaim(claims, "jti")
if _, err := uuid.Parse(jti); err != nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
issuedAt, err := claims.GetIssuedAt()
if err != nil || issuedAt == nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
events, ok := claims["events"].(map[string]any)
if !ok || len(events) != 1 {
return Event{}, &protocolError{Code: "invalid_request"}
}
event := Event{Issuer: v.config.TransmitterIssuer, Audience: v.config.Audience, JTI: jti, TransactionID: stringClaim(claims, "txn"), IssuedAt: issuedAt.Time}
if payload, present := events[SessionRevokedEventType]; present {
value, ok := payload.(map[string]any)
if !ok || event.TransactionID == "" {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, err := uuid.Parse(event.TransactionID); err != nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"])
if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) ||
!validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
if payload, present := events[VerificationEventType]; present {
value, ok := payload.(map[string]any)
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType = VerificationEventType
if rawState, present := value["state"]; present {
var valid bool
event.State, valid = rawState.(string)
if !valid || len(event.State) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
}
if len(event.State) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
if payload, present := events[StreamUpdatedEventType]; present {
value, ok := payload.(map[string]any)
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType = StreamUpdatedEventType
event.StreamStatus, event.Reason = stringValue(value["status"]), stringValue(value["reason"])
if event.StreamStatus != "enabled" && event.StreamStatus != "paused" && event.StreamStatus != "disabled" {
return Event{}, &protocolError{Code: "invalid_request"}
}
if len(event.Reason) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
return Event{}, &protocolError{Code: "invalid_request"}
}
func claimHasAudience(raw any, expected string) bool {
switch value := raw.(type) {
case string:
return value == expected
case []string:
for _, audience := range value {
if audience == expected {
return true
}
}
case []any:
for _, item := range value {
if audience, ok := item.(string); ok && audience == expected {
return true
}
}
}
return false
}
func (v *Verifier) now() time.Time {
if v.clock != nil {
return v.clock().UTC()
}
return time.Now().UTC()
}
func (v *Verifier) readSessionSubject(raw any, event *Event) bool {
subject, ok := raw.(map[string]any)
if !ok || stringValue(subject["format"]) != "complex" {
return false
}
user, userOK := subject["user"].(map[string]any)
tenant, tenantOK := subject["tenant"].(map[string]any)
if !userOK || !tenantOK || stringValue(user["format"]) != "iss_sub" || stringValue(tenant["format"]) != "opaque" {
return false
}
event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"])
if event.SubjectIssuer != v.config.SubjectIssuer || event.TenantID != v.config.TenantID {
return false
}
_, err := uuid.Parse(event.Subject)
return err == nil
}
func (v *Verifier) readStreamSubject(raw any, event *Event) bool {
subject, ok := raw.(map[string]any)
if !ok || stringValue(subject["format"]) != "opaque" || stringValue(subject["id"]) != v.config.StreamID {
return false
}
event.StreamID = v.config.StreamID
return true
}
func (v *Verifier) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) {
v.mu.Lock()
if key := v.keys[kid]; key != nil && time.Now().Before(v.expiresAt) {
v.mu.Unlock()
return key, nil
}
v.mu.Unlock()
if err := v.refresh(ctx); err != nil {
if v.metrics != nil {
v.metrics.ObserveJWKSRefreshFailure("ssf")
}
return nil, err
}
v.mu.Lock()
defer v.mu.Unlock()
key := v.keys[kid]
if key == nil {
return nil, errors.New("SSF signing key is unavailable")
}
return key, nil
}
func (v *Verifier) refresh(ctx context.Context) error {
v.mu.Lock()
defer v.mu.Unlock()
configurationURL, err := ssfConfigurationURL(v.config.TransmitterIssuer)
if err != nil {
return err
}
var metadata transmitterMetadata
if err := v.fetchJSON(ctx, configurationURL, &metadata); err != nil {
return err
}
if strings.TrimRight(metadata.Issuer, "/") != v.config.TransmitterIssuer || !sameOrigin(metadata.JWKSURI, v.config.TransmitterIssuer) {
return errors.New("SSF metadata is not bound to configured issuer")
}
var set jwkSet
if err := v.fetchJSON(ctx, metadata.JWKSURI, &set); err != nil {
return err
}
keys := make(map[string]*ecdsa.PublicKey)
for _, item := range set.Keys {
if item.KID == "" || item.KTY != "EC" || item.Alg != "ES256" || item.Crv != "P-256" || item.Use != "sig" {
continue
}
x, xErr := decodeCoordinate(item.X)
y, yErr := decodeCoordinate(item.Y)
if xErr == nil && yErr == nil && elliptic.P256().IsOnCurve(x, y) {
keys[item.KID] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
}
}
if len(keys) == 0 {
return errors.New("SSF JWKS contains no ES256 keys")
}
v.keys, v.expiresAt = keys, time.Now().Add(5*time.Minute)
return nil
}
func (v *Verifier) fetchJSON(ctx context.Context, endpoint string, target any) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
response, err := v.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("SSF endpoint returned HTTP %d", response.StatusCode)
}
payload, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
if err != nil || len(payload) > 1<<20 {
return errors.New("SSF endpoint response is too large")
}
if err := json.Unmarshal(payload, target); err != nil {
return errors.New("SSF endpoint returned invalid JSON")
}
return nil
}
func ssfConfigurationURL(issuer string) (string, error) {
parsed, err := url.Parse(issuer)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", errors.New("SSF issuer is invalid")
}
path := strings.TrimSuffix(parsed.EscapedPath(), "/")
parsed.Path, parsed.RawPath = "/.well-known/ssf-configuration"+path, ""
parsed.RawQuery, parsed.Fragment = "", ""
return parsed.String(), nil
}
func sameOrigin(candidate, issuer string) bool {
candidateURL, candidateErr := url.Parse(candidate)
issuerURL, issuerErr := url.Parse(issuer)
return candidateErr == nil && issuerErr == nil && candidateURL.Scheme == issuerURL.Scheme && strings.EqualFold(candidateURL.Host, issuerURL.Host) && candidateURL.User == nil && candidateURL.Fragment == ""
}
func absoluteHTTPURL(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && parsed.IsAbs() && parsed.Hostname() != "" && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.User == nil && parsed.Fragment == ""
}
func decodeCoordinate(raw string) (*big.Int, error) {
value, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || len(value) != 32 {
return nil, errors.New("invalid EC coordinate")
}
return new(big.Int).SetBytes(value), nil
}
func stringClaim(claims jwt.MapClaims, name string) string { return stringValue(claims[name]) }
func stringValue(value any) string {
result, _ := value.(string)
return result
}
func unixClaim(value any) time.Time {
switch typed := value.(type) {
case float64:
return time.Unix(int64(typed), 0).UTC()
case json.Number:
integer, err := typed.Int64()
if err == nil {
return time.Unix(integer, 0).UTC()
}
}
return time.Time{}
}
func validInitiator(value string) bool {
return value == "admin" || value == "user" || value == "policy" || value == "system"
}

View File

@ -0,0 +1,341 @@
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
type ApplySessionRevokedInput struct {
Issuer string
Audience string
JTI string
TransactionID string
SubjectIssuer string
TenantID string
Subject string
EventTimestamp time.Time
InitiatingEntity string
}
type ApplySecurityEventResult struct {
Duplicate bool
WatermarkMoved bool
SessionsDeleted int64
AuditID string
}
type SecurityEventEvaluation struct {
Revoked bool
RequireIntrospection bool
Mode string
}
func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevokedInput) (ApplySecurityEventResult, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return ApplySecurityEventResult{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
subjectHash := shortSecurityEventHash(input.Subject)
tag, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_receipts (
issuer, jti, audience, event_type, transaction_id, tenant_id, subject_hash, event_timestamp
)
VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8)
ON CONFLICT (issuer, jti) DO NOTHING`,
input.Issuer, input.JTI, input.Audience,
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
input.TransactionID, input.TenantID, subjectHash, input.EventTimestamp,
)
if err != nil {
return ApplySecurityEventResult{}, err
}
if tag.RowsAffected() == 0 {
if err := tx.Commit(ctx); err != nil {
return ApplySecurityEventResult{}, err
}
return ApplySecurityEventResult{Duplicate: true}, nil
}
var revokedAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO gateway_oidc_revocation_watermarks (issuer, tenant_id, subject, revoked_at, source_jti)
VALUES ($1, $2, $3, $4, $5::uuid)
ON CONFLICT (issuer, tenant_id, subject) DO UPDATE
SET revoked_at = EXCLUDED.revoked_at, source_jti = EXCLUDED.source_jti, updated_at = now()
WHERE EXCLUDED.revoked_at > gateway_oidc_revocation_watermarks.revoked_at
RETURNING revoked_at`, input.SubjectIssuer, input.TenantID, input.Subject, input.EventTimestamp, input.JTI).Scan(&revokedAt)
watermarkMoved := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return ApplySecurityEventResult{}, err
}
var sessionsDeleted int64
if watermarkMoved {
tag, err = tx.Exec(ctx, `
DELETE FROM gateway_oidc_sessions session
USING gateway_users gateway_user
WHERE session.gateway_user_id = gateway_user.id
AND gateway_user.source = 'oidc'
AND gateway_user.external_user_id = $1
AND gateway_user.tenant_id = $2`, input.Subject, input.TenantID)
if err != nil {
return ApplySecurityEventResult{}, err
}
sessionsDeleted = tag.RowsAffected()
}
jtiHash := shortSecurityEventHash(input.JTI)
audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
Category: "identity", Action: "identity.oidc_session.revoked", ActorSource: "ssf",
TargetType: "oidc_subject", TargetID: subjectHash,
AfterState: map[string]any{"watermarkMoved": watermarkMoved, "sessionsDeleted": sessionsDeleted},
Metadata: map[string]any{
"issuer": input.SubjectIssuer, "transmitterIssuer": input.Issuer, "tenantId": input.TenantID, "jtiHash": jtiHash,
"initiatingEntity": input.InitiatingEntity,
},
})
if err != nil {
return ApplySecurityEventResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return ApplySecurityEventResult{}, err
}
return ApplySecurityEventResult{WatermarkMoved: watermarkMoved, SessionsDeleted: sessionsDeleted, AuditID: audit.ID}, nil
}
func (s *Store) EnsureSecurityEventStreamState(ctx context.Context, issuer, audience, streamID string) error {
tag, err := s.pool.Exec(ctx, `
INSERT INTO gateway_security_event_stream_state(issuer,audience,stream_id,mode)
VALUES($1,$2,$3::uuid,'bootstrap')
ON CONFLICT(issuer,audience) DO UPDATE
SET stream_id=EXCLUDED.stream_id,updated_at=now()
WHERE gateway_security_event_stream_state.stream_id=EXCLUDED.stream_id`, issuer, audience, streamID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("security event stream id cannot be changed")
}
return nil
}
func (s *Store) BeginSecurityEventVerification(ctx context.Context, issuer, audience string, stateHash []byte, now time.Time) error {
if len(stateHash) != sha256.Size {
return errors.New("verification state hash must be SHA-256")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET pending_state_hash=$3,pending_state_created_at=$4,updated_at=now()
WHERE issuer=$1 AND audience=$2`, issuer, audience, stateHash, now)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("security event stream state is missing")
}
if _, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_verification_challenges(issuer,audience,state_hash,created_at,expires_at)
VALUES($1,$2,$3,$4,$4 + interval '180 seconds')
ON CONFLICT(issuer,audience,state_hash) DO NOTHING`, issuer, audience, stateHash, now); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_verification_challenges WHERE expires_at < $1`, now); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) ConfirmSecurityEventVerification(ctx context.Context, issuer, audience, streamID, jti string, stateHash []byte, now time.Time) (bool, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash)
VALUES($1,$2::uuid,$3,$4,$5)
ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience,
"https://schemas.openid.net/secevent/ssf/event-type/verification", shortSecurityEventHash(streamID))
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, tx.Commit(ctx)
}
tag, err = tx.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET last_verification_at=$5,
bootstrap_until=CASE WHEN last_verification_at IS NULL THEN $5 + interval '360 seconds' ELSE bootstrap_until END,
consecutive_verifications=CASE
WHEN stream_status <> 'enabled' THEN 0
WHEN mode='introspection_fallback' THEN consecutive_verifications+1
ELSE 1
END,
mode=CASE
WHEN stream_status <> 'enabled' THEN mode
WHEN mode='introspection_fallback' AND consecutive_verifications+1 >= 2
AND (bootstrap_until IS NULL OR $5 >= bootstrap_until) THEN 'push_healthy'
WHEN mode='introspection_fallback' THEN 'introspection_fallback'
WHEN mode='push_healthy' THEN 'push_healthy'
ELSE 'bootstrap'
END,
fallback_since=CASE
WHEN stream_status='enabled' AND mode='introspection_fallback' AND consecutive_verifications+1 >= 2
AND (bootstrap_until IS NULL OR $5 >= bootstrap_until) THEN NULL
ELSE fallback_since
END,
pending_state_hash=NULL,pending_state_created_at=NULL,
last_error_category=CASE WHEN stream_status='enabled' THEN NULL ELSE 'stream_' || stream_status END,
updated_at=now()
WHERE issuer=$1 AND audience=$2 AND stream_id=$3::uuid
AND EXISTS(SELECT 1 FROM gateway_security_event_verification_challenges challenge
WHERE challenge.issuer=$1 AND challenge.audience=$2 AND challenge.state_hash=$4 AND challenge.expires_at >= $5)`,
issuer, audience, streamID, stateHash, now)
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, errors.New("invalid verification state")
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_verification_challenges
WHERE issuer=$1 AND audience=$2 AND state_hash=$3`, issuer, audience, stateHash); err != nil {
return false, err
}
return true, tx.Commit(ctx)
}
func (s *Store) RecordSecurityEventReceipt(ctx context.Context, issuer, audience, jti, eventType, subject string) (bool, error) {
tag, err := s.pool.Exec(ctx, `
INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash)
VALUES($1,$2::uuid,$3,$4,$5)
ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience, eventType, shortSecurityEventHash(subject))
return tag.RowsAffected() == 1, err
}
// ApplySecurityEventStreamUpdated records a transmitter-originated stream
// status change and immediately moves OIDC traffic to introspection. Push is
// trusted again only after the normal consecutive-verification recovery path.
func (s *Store) ApplySecurityEventStreamUpdated(ctx context.Context, issuer, audience, jti, streamID, status, reason string, now time.Time) (bool, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash)
VALUES($1,$2::uuid,$3,$4,$5)
ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience,
"https://schemas.openid.net/secevent/ssf/event-type/stream-updated", shortSecurityEventHash(streamID))
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, tx.Commit(ctx)
}
tag, err = tx.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET stream_status=$5,mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$4),
consecutive_verifications=0,last_error_category=$6,updated_at=now()
WHERE issuer=$1 AND audience=$2 AND stream_id=$3::uuid`, issuer, audience, streamID, now, status, "stream_"+status)
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, errors.New("security event stream state is missing")
}
if _, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
Category: "identity", Action: "identity.ssf_stream.updated", ActorSource: "ssf",
TargetType: "ssf_stream", TargetID: shortSecurityEventHash(streamID),
AfterState: map[string]any{"status": status},
Metadata: map[string]any{"issuer": issuer, "audience": audience, "reasonCategory": shortSecurityEventHash(reason)},
}); err != nil {
return false, err
}
return true, tx.Commit(ctx)
}
func (s *Store) RecordSecurityEventHeartbeatFailure(ctx context.Context, issuer, audience, category string) error {
_, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state
SET last_error_category=$3,updated_at=now() WHERE issuer=$1 AND audience=$2`, issuer, audience, category)
return err
}
func (s *Store) AdvanceSecurityEventStreamState(ctx context.Context, issuer, audience string, now time.Time, staleAfter time.Duration) error {
if staleAfter <= 0 {
return errors.New("security event stale threshold must be positive")
}
_, err := s.pool.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$3),
consecutive_verifications=0,last_error_category='verification_stale',updated_at=now()
WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback'
AND COALESCE(last_verification_at,created_at) < $3 - make_interval(secs => $4::double precision)`,
issuer, audience, now, staleAfter.Seconds())
return err
}
func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, tenantID, subject string, issuedAt, now time.Time, staleAfter time.Duration) (SecurityEventEvaluation, error) {
var mode, streamStatus string
var lastVerification, bootstrapUntil *time.Time
var createdAt time.Time
var revokedAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT state.mode,state.stream_status,state.last_verification_at,state.bootstrap_until,state.created_at,watermark.revoked_at
FROM gateway_security_event_stream_state state
LEFT JOIN gateway_oidc_revocation_watermarks watermark
ON watermark.issuer=$3 AND watermark.tenant_id=$4 AND watermark.subject=$5
WHERE state.issuer=$1 AND state.audience=$2`, transmitterIssuer, audience, subjectIssuer, tenantID, subject).Scan(
&mode, &streamStatus, &lastVerification, &bootstrapUntil, &createdAt, &revokedAt,
)
if err != nil {
return SecurityEventEvaluation{}, err
}
evaluation := SecurityEventEvaluation{Mode: mode, Revoked: revokedAt != nil && !issuedAt.After(*revokedAt)}
stale := (lastVerification != nil && now.Sub(*lastVerification) > staleAfter) ||
(lastVerification == nil && (mode != "bootstrap" || now.Sub(createdAt) > staleAfter))
if stale {
evaluation.Mode, evaluation.RequireIntrospection = "introspection_fallback", true
_, err := s.pool.Exec(ctx, `
UPDATE gateway_security_event_stream_state
SET mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$3),consecutive_verifications=0,
last_error_category='verification_stale',updated_at=now()
WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback'`, transmitterIssuer, audience, now)
return evaluation, err
}
if mode == "bootstrap" {
if streamStatus != "enabled" || bootstrapUntil == nil || now.Before(*bootstrapUntil) {
evaluation.RequireIntrospection = true
return evaluation, nil
}
evaluation.Mode = "push_healthy"
_, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET mode='push_healthy',updated_at=now()
WHERE issuer=$1 AND audience=$2 AND mode='bootstrap'`, transmitterIssuer, audience)
return evaluation, err
}
evaluation.RequireIntrospection = mode == "introspection_fallback"
return evaluation, nil
}
func (s *Store) SecurityEventMetrics(ctx context.Context, issuer, audience string) (string, *time.Time, error) {
var mode string
var lastVerificationAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT mode,last_verification_at
FROM gateway_security_event_stream_state
WHERE issuer=$1 AND audience=$2`, issuer, audience).Scan(&mode, &lastVerificationAt)
return mode, lastVerificationAt, err
}
func shortSecurityEventHash(value string) string {
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:])[:16]
}

View File

@ -0,0 +1,132 @@
package store
import (
"context"
"crypto/sha256"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run security event PostgreSQL integration tests")
}
ctx := context.Background()
probe, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
var databaseName string
if err := probe.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
probe.Close()
t.Fatal(err)
}
probe.Close()
if !strings.Contains(strings.ToLower(databaseName), "test") {
t.Fatalf("refusing to migrate non-test database %q", databaseName)
}
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
issuer := "https://auth.test.example/ssf"
subjectIssuer := "https://auth.test/realms/tenant"
audience := "urn:easyai:ssf:receiver:" + uuid.NewString()
streamID, tenantID, subject := uuid.NewString(), uuid.NewString(), uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_receipts WHERE issuer=$1`, issuer)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_oidc_revocation_watermarks WHERE issuer=$1`, subjectIssuer)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_stream_state WHERE issuer=$1`, issuer)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_audit_logs WHERE actor_source='ssf' AND target_id=ANY($1)`, []string{shortSecurityEventHash(subject), shortSecurityEventHash(streamID)})
})
if err := db.EnsureSecurityEventStreamState(ctx, issuer, audience, streamID); err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Truncate(time.Second)
evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "bootstrap" {
t.Fatalf("bootstrap evaluation=%#v error=%v", evaluation, err)
}
confirmAt := func(label string, at time.Time) {
t.Helper()
stateHash := sha256.Sum256([]byte(label))
if err := db.BeginSecurityEventVerification(ctx, issuer, audience, stateHash[:], at); err != nil {
t.Fatal(err)
}
matched, err := db.ConfirmSecurityEventVerification(ctx, issuer, audience, streamID, uuid.NewString(), stateHash[:], at)
if err != nil || !matched {
t.Fatalf("confirm matched=%v error=%v", matched, err)
}
}
inserted, err := db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, uuid.NewString(), streamID, "enabled", "onboarding", now)
if err != nil || !inserted {
t.Fatalf("enable stream inserted=%v error=%v", inserted, err)
}
confirmAt("first-verification-state", now)
confirmAt("second-verification-state", now.Add(time.Minute))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(time.Minute), 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("bootstrap overlap evaluation=%#v error=%v", evaluation, err)
}
confirmAt("post-bootstrap-verification-state", now.Add(361*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(361*time.Second), 180*time.Second)
if err != nil || evaluation.RequireIntrospection || evaluation.Mode != "push_healthy" {
t.Fatalf("healthy evaluation=%#v error=%v", evaluation, err)
}
streamUpdateJTI := uuid.NewString()
inserted, err = db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, streamUpdateJTI, streamID, "paused", "maintenance", now.Add(362*time.Second))
if err != nil || !inserted {
t.Fatalf("stream update inserted=%v error=%v", inserted, err)
}
inserted, err = db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, streamUpdateJTI, streamID, "paused", "maintenance", now)
if err != nil || inserted {
t.Fatalf("duplicate stream update inserted=%v error=%v", inserted, err)
}
confirmAt("paused-verification-one", now.Add(363*time.Second))
confirmAt("paused-verification-two", now.Add(364*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(364*time.Second), 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("stream update fallback=%#v error=%v", evaluation, err)
}
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET stream_status='enabled',mode='push_healthy',fallback_since=NULL,
consecutive_verifications=0,last_verification_at=$3 WHERE issuer=$1 AND audience=$2`, issuer, audience, now)
revokedAt := now.Add(-10 * time.Second)
input := ApplySessionRevokedInput{Issuer: issuer, Audience: audience, JTI: uuid.NewString(), TransactionID: uuid.NewString(), SubjectIssuer: subjectIssuer, TenantID: tenantID, Subject: subject, EventTimestamp: revokedAt, InitiatingEntity: "admin"}
result, err := db.ApplySessionRevoked(ctx, input)
if err != nil || !result.WatermarkMoved || result.AuditID == "" {
t.Fatalf("apply result=%#v error=%v", result, err)
}
duplicate, err := db.ApplySessionRevoked(ctx, input)
if err != nil || !duplicate.Duplicate {
t.Fatalf("duplicate result=%#v error=%v", duplicate, err)
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, revokedAt, now, 180*time.Second)
if !evaluation.Revoked {
t.Fatal("token at watermark was accepted")
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", tenantID, subject, revokedAt, now, 180*time.Second)
if evaluation.Revoked {
t.Fatal("watermark crossed OIDC issuer boundary")
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
if evaluation.Revoked {
t.Fatal("token after watermark was rejected")
}
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET last_verification_at=$3
WHERE issuer=$1 AND audience=$2`, issuer, audience, now.Add(-181*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("fallback evaluation=%#v error=%v", evaluation, err)
}
}

View File

@ -0,0 +1,10 @@
package store
import "testing"
func TestSecurityEventHashesAreStableAndSanitized(t *testing.T) {
first := shortSecurityEventHash("sensitive-subject")
if first != shortSecurityEventHash("sensitive-subject") || len(first) != 16 || first == "sensitive-subject" {
t.Fatalf("unsafe or unstable hash: %q", first)
}
}

View File

@ -0,0 +1,70 @@
CREATE TABLE IF NOT EXISTS gateway_security_event_receipts (
issuer text NOT NULL,
jti uuid NOT NULL,
audience text NOT NULL,
event_type text NOT NULL,
transaction_id text,
tenant_id text,
subject_hash text,
event_timestamp timestamptz,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (issuer, jti)
);
CREATE INDEX IF NOT EXISTS idx_gateway_security_event_receipts_received
ON gateway_security_event_receipts(received_at DESC);
CREATE TABLE IF NOT EXISTS gateway_oidc_revocation_watermarks (
issuer text NOT NULL,
tenant_id text NOT NULL,
subject text NOT NULL,
revoked_at timestamptz NOT NULL,
source_jti uuid NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (issuer, tenant_id, subject)
);
CREATE INDEX IF NOT EXISTS idx_gateway_oidc_revocation_watermarks_lookup
ON gateway_oidc_revocation_watermarks(issuer, tenant_id, subject, revoked_at);
CREATE TABLE IF NOT EXISTS gateway_security_event_stream_state (
issuer text NOT NULL,
audience text NOT NULL,
stream_id uuid NOT NULL,
stream_status text NOT NULL DEFAULT 'unknown',
mode text NOT NULL DEFAULT 'bootstrap',
last_verification_at timestamptz,
bootstrap_until timestamptz,
consecutive_verifications integer NOT NULL DEFAULT 0,
pending_state_hash bytea,
pending_state_created_at timestamptz,
fallback_since timestamptz,
last_error_category text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (issuer, audience),
UNIQUE (stream_id),
CONSTRAINT gateway_security_event_stream_state_mode
CHECK (mode IN ('bootstrap','push_healthy','introspection_fallback')),
CONSTRAINT gateway_security_event_stream_state_status
CHECK (stream_status IN ('unknown','enabled','paused','disabled')),
CONSTRAINT gateway_security_event_stream_state_pending_hash
CHECK (pending_state_hash IS NULL OR octet_length(pending_state_hash) = 32)
);
CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges (
issuer text NOT NULL,
audience text NOT NULL,
state_hash bytea NOT NULL,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY (issuer, audience, state_hash),
FOREIGN KEY (issuer, audience)
REFERENCES gateway_security_event_stream_state(issuer, audience) ON DELETE CASCADE,
CONSTRAINT gateway_security_event_verification_challenge_hash
CHECK (octet_length(state_hash) = 32)
);
CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry
ON gateway_security_event_verification_challenges(expires_at);

View File

@ -14,6 +14,22 @@ x-api-environment: &api-environment
OIDC_REQUIRED_SCOPES: ${OIDC_REQUIRED_SCOPES:-gateway.access}
OIDC_JWKS_CACHE_TTL_SECONDS: ${OIDC_JWKS_CACHE_TTL_SECONDS:-300}
OIDC_ACCEPT_LEGACY_HS256: ${OIDC_ACCEPT_LEGACY_HS256:-true}
OIDC_INTROSPECTION_ENABLED: ${OIDC_INTROSPECTION_ENABLED:-false}
OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-}
OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-}
OIDC_SECURITY_EVENTS_ENABLED: ${OIDC_SECURITY_EVENTS_ENABLED:-false}
OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER: ${OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER:-}
OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE: ${OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE:-}
OIDC_SECURITY_EVENTS_BEARER_SECRET: ${OIDC_SECURITY_EVENTS_BEARER_SECRET:-}
OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT: ${OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT:-}
OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT: ${OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT:-}
OIDC_SECURITY_EVENTS_STREAM_ID: ${OIDC_SECURITY_EVENTS_STREAM_ID:-}
OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL: ${OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL:-}
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID:-}
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET:-}
OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60}
OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180}
OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60}
OIDC_JIT_PROVISIONING_ENABLED: ${OIDC_JIT_PROVISIONING_ENABLED:-false}
OIDC_GATEWAY_TENANT_KEY: ${OIDC_GATEWAY_TENANT_KEY:-}
OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true}

View File

@ -10,6 +10,29 @@ server {
return 308 /gateway-api/;
}
# RFC 8935 receiver: exact path, no redirect, short bounded upstream timeout.
location = /api/v1/security-events/ssf {
client_max_body_size 64k;
client_body_buffer_size 64k;
client_body_timeout 5s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 3s;
proxy_read_timeout 10s;
proxy_send_timeout 10s;
proxy_redirect off;
proxy_pass http://api:8088;
}
# Metrics stay on the API's monitoring network and are not exposed by Web.
location = /gateway-api/metrics {
return 404;
}
location /gateway-api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@ -0,0 +1,56 @@
# SSF/CAEP 实时会话撤销运行手册
Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token并处理 OpenID CAEP `session-revoked`。默认关闭;关闭时不会注册接收路由或启动状态机,也不会改变 OIDC、BFF、API Key 或 Legacy JWT 行为。
## 启用前提
- Auth Center 已创建属于当前 Application/租户的 paused StreamAudience 为 `urn:easyai:ssf:receiver:{application-public-id}`
- Gateway OIDC `iss`、`tid` 与 Stream 绑定完全一致Stream ID 为公开 UUID不使用认证内核 ID。
- 接收 Bearer、Management Client Secret、RFC 7662 Client Secret 仅从 Git 忽略的 `.env`、Kubernetes Secret 或 Secret Manager 注入。
- `OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT` 必须是外部可访问的精确 HTTPS 地址;本地测试环境才允许 localhost HTTP。
- Management Client 至少有 `ssf.stream.read ssf.stream.verify`,不能使用浏览器 PKCE Client。
完整配置键见仓库根目录 `.env.example`。启用时 `OIDC_SECURITY_EVENTS_ENABLED=true`,且 RFC 7662 Client ID/Secret 必须同时存在,否则启动失败。
## 启用顺序
1. 运行数据库迁移,部署仍保持功能关闭。
2. 在 Secret Store 创建接收 Bearer、Management Client Secret 和内省 Client Secret不要把值放进日志或工单。
3. 启用 Gateway。启动模式为 `bootstrap`,所有 OIDC Token 都先内省。
4. 在 Auth Center 对 paused Stream 发起 Verification确认 Gateway 返回空 Body 的 `202`
5. 启用 Stream。首次 Verification 成功后仍内省 360 秒,覆盖启用前已签发的最长 300 秒 Token。
6. 观察 `easyai_gateway_ssf_mode{mode="push_healthy"}` 变为 1再执行测试用户撤销。
健康时 Gateway 每 60 秒请求一次 Verification不按业务 QPS 请求 Auth Center。180 秒没有匹配的有效 Verification 会进入 `introspection_fallback`;只有最近收到的 Stream 状态为 `enabled` 时,连续两次 Verification 成功才会恢复,`paused`/`disabled` 状态下心跳不会错误恢复推送信任。降级期间内省不可用会对 OIDC 返回可审计的 503API Key 继续工作。
## 零停机 Bearer 轮换
1. 在 Gateway 写入 `OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT`,同时接受 current/next。
2. 更新 Auth Center 的 Stream `credential_ref` 指向 next。
3. 发起 Verification并确认投递和状态机健康。
4. 等待至少 180 秒后,将 next 提升为 current 并清空旧值。
Gateway 使用常量时间比较两个值。响应、日志、审计和指标都不得包含 Authorization Header 或原始 SET。
## 监控与告警
`GET /metrics` 输出以下低基数指标;生产入口应仅允许监控网络访问该路径:
- `easyai_gateway_ssf_receipts_total{outcome}`accepted/rejected/duplicate。
- `easyai_gateway_ssf_sessions_deleted_total`:被撤销删除的 BFF Session。
- `easyai_gateway_ssf_watermark_rejections_total`:本地水位拒绝的 Token。
- `easyai_gateway_ssf_processing_duration_seconds`Receiver 数据库事务处理直方图,用于 P99 告警。
- `easyai_gateway_ssf_verification_age_seconds``easyai_gateway_ssf_mode{mode}`
- `easyai_gateway_ssf_heartbeat_requests_total{outcome}`
- `easyai_gateway_oidc_introspection_total{outcome}`
- `easyai_gateway_jwks_refresh_failures_total{source}`SSF/OIDC JWKS 刷新失败。
至少配置以下告警Verification age 超过 120 秒、fallback 超过 5 分钟、内省 failed 增长、SET rejected 增长、撤销端到端 P99 超过 3 秒。日志只能携带脱敏 Trace ID、Audit ID 和错误类别。
## 回滚
先在 Auth Center 暂停 Stream再保持 Gateway 启用但持续使用 RFC 7662确认没有遗漏撤销后才关闭接收功能。保留迁移表和 Secret不做破坏性数据库回滚。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。
## 验收证据
真实链路验收只能使用明确标记为测试用途的 Application且必须在自动化测试通过后执行。报告需包含脱敏 Trace、Claims 结构、截图、Audit ID、投递延迟、fallback 与恢复证据。涉及人工、真实设备或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`Mock、契约和安全测试不得跳过。