feat: 接入 SSF 实时会话撤销

This commit is contained in:
2026-07-14 17:06:41 +08:00
parent 30ad0e9f2c
commit f30aaeb2d4
28 changed files with 2597 additions and 91 deletions
+74
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": [
+51
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:
+1
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
+2
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=
+2
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
+74 -15
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
}
+54
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()
+158 -61
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":
+31
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")
}
}
@@ -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)
}
+67 -15
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:
+14
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
}
+185
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."
}
}
@@ -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)
}
+168
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)
}
@@ -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())
}
}
}
+211
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
}
@@ -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")
}
}
@@ -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"
}
+341
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]
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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);