feat: 接入 SSF 实时会话撤销
This commit is contained in:
parent
30ad0e9f2c
commit
f30aaeb2d4
16
.env.example
16
.env.example
@ -35,6 +35,22 @@ OIDC_ACCEPT_LEGACY_HS256=true
|
||||
OIDC_INTROSPECTION_ENABLED=false
|
||||
OIDC_INTROSPECTION_CLIENT_ID=
|
||||
OIDC_INTROSPECTION_CLIENT_SECRET=
|
||||
# Optional SSF/CAEP real-time revocation receiver. Enabling it also requires
|
||||
# the RFC 7662 credentials above so bootstrap and fail-closed fallback work.
|
||||
OIDC_SECURITY_EVENTS_ENABLED=false
|
||||
OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER=https://auth.51easyai.com/ssf
|
||||
OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE=
|
||||
OIDC_SECURITY_EVENTS_BEARER_SECRET=
|
||||
# During zero-downtime rotation, set next before removing current.
|
||||
OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT=
|
||||
OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT=https://api.51easyai.com/api/v1/security-events/ssf
|
||||
OIDC_SECURITY_EVENTS_STREAM_ID=
|
||||
OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL=
|
||||
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID=
|
||||
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET=
|
||||
OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60
|
||||
OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS=180
|
||||
OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60
|
||||
# Controlled JIT is opt-in. When enabled, bind the validated Auth Center tid to
|
||||
# one existing active Gateway tenant; tokens never create Gateway tenants.
|
||||
OIDC_JIT_PROVISIONING_ENABLED=false
|
||||
|
||||
@ -56,6 +56,8 @@ OIDC_SESSION_ENCRYPTION_KEY=<base64 编码的独立 32 字节随机密钥>
|
||||
|
||||
Web Console 使用公共 Client + PKCE + Gateway BFF Session:Gateway 服务端换码并加密保存 Access/Refresh/ID Token,浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新;Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret,也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。
|
||||
|
||||
可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token,并删除同一租户 Subject 的 BFF Session。推送不健康时自动切换 RFC 7662,内省也不可用时 OIDC Fail Closed;API Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。
|
||||
|
||||
`pnpm dev` 会先创建数据库并执行 migration,然后并行启动:
|
||||
|
||||
- `api:dev`:通过 `scripts/go-watch.mjs` 运行 Go API,监听 `.go`、`go.mod`、`go.sum` 变化并自动重启后端进程;watcher 会按进程组终止旧的 `go run` 和其子进程,避免热更新时残留进程占用 API 端口。
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
22
apps/api/internal/httpapi/security_events.go
Normal file
22
apps/api/internal/httpapi/security_events.go
Normal file
@ -0,0 +1,22 @@
|
||||
package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
// receiveSecurityEvent accepts an RFC 8417 Security Event Token over RFC 8935 Push.
|
||||
//
|
||||
// @Summary Receive an SSF Security Event Token
|
||||
// @Description Optional endpoint. It validates a stream-specific Bearer before parsing and verifying an ES256 SET. A committed event and a duplicate jti both return an empty 202 response.
|
||||
// @Tags Security Events
|
||||
// @Accept application/secevent+jwt
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer stream-specific-secret"
|
||||
// @Param set body string true "Compact signed Security Event Token"
|
||||
// @Success 202
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/security-events/ssf [post]
|
||||
func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) {
|
||||
s.securityEventReceiver.ServeHTTP(w, r)
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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
apps/api/internal/securityevents/handler.go
Normal file
185
apps/api/internal/securityevents/handler.go
Normal 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."
|
||||
}
|
||||
}
|
||||
269
apps/api/internal/securityevents/handler_test.go
Normal file
269
apps/api/internal/securityevents/handler_test.go
Normal file
@ -0,0 +1,269 @@
|
||||
package securityevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
input *store.ApplySessionRevokedInput
|
||||
dedup map[string]struct{}
|
||||
confirmed bool
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ApplySessionRevoked(_ context.Context, input store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) {
|
||||
if f.dedup == nil {
|
||||
f.dedup = make(map[string]struct{})
|
||||
}
|
||||
if _, exists := f.dedup[input.JTI]; exists {
|
||||
return store.ApplySecurityEventResult{Duplicate: true}, nil
|
||||
}
|
||||
f.dedup[input.JTI] = struct{}{}
|
||||
f.input = &input
|
||||
return store.ApplySecurityEventResult{WatermarkMoved: true, SessionsDeleted: 2}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error) {
|
||||
f.confirmed = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (*fakeRepository) RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (*fakeRepository) ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestReceiverAcceptsValidSessionRevokedAndDuplicate(t *testing.T) {
|
||||
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
var transmitter *httptest.Server
|
||||
transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/ssf-configuration/ssf":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json"})
|
||||
case "/ssf/jwks.json":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{
|
||||
"kty": "EC", "kid": "current", "use": "sig", "alg": "ES256", "crv": "P-256",
|
||||
"x": coordinate(privateKey.X), "y": coordinate(privateKey.Y),
|
||||
}}})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer transmitter.Close()
|
||||
tenantID, subjectID, applicationID, streamID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
audience := "urn:easyai:ssf:receiver:" + applicationID
|
||||
verifier, err := NewVerifier(VerifierConfig{
|
||||
TransmitterIssuer: transmitter.URL + "/ssf", Audience: audience,
|
||||
SubjectIssuer: "https://auth.example/issuer/tenant-a", TenantID: tenantID, StreamID: streamID,
|
||||
ClockSkew: time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &fakeRepository{}
|
||||
handler, err := NewHandler(verifier, repository, transmitter.URL+"/ssf", audience, streamID, "receiver-bearer-secret", "next-receiver-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
jti := uuid.NewString()
|
||||
set := signSET(t, privateKey, map[string]any{
|
||||
"iss": transmitter.URL + "/ssf", "aud": audience, "iat": now.Unix(), "jti": jti, "txn": uuid.NewString(),
|
||||
"sub_id": map[string]any{
|
||||
"format": "complex",
|
||||
"user": map[string]any{"format": "iss_sub", "iss": "https://auth.example/issuer/tenant-a", "sub": subjectID},
|
||||
"tenant": map[string]any{"format": "opaque", "id": tenantID},
|
||||
},
|
||||
"events": map[string]any{SessionRevokedEventType: map[string]any{"event_timestamp": now.Unix(), "initiating_entity": "admin"}},
|
||||
})
|
||||
for _, secret := range []string{"receiver-bearer-secret", "next-receiver-secret"} {
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", strings.NewReader(set))
|
||||
request.Header.Set("Authorization", "Bearer "+secret)
|
||||
request.Header.Set("Content-Type", "application/secevent+jwt")
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusAccepted || response.Body.Len() != 0 {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
if repository.input == nil || repository.input.Subject != subjectID || repository.input.TenantID != tenantID ||
|
||||
repository.input.SubjectIssuer != "https://auth.example/issuer/tenant-a" {
|
||||
t.Fatalf("applied event = %#v", repository.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRejectsAuthenticationMediaTypeAndForbiddenClaims(t *testing.T) {
|
||||
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
verifier := &Verifier{config: VerifierConfig{
|
||||
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
|
||||
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
|
||||
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
|
||||
handler, _ := NewHandler(verifier, &fakeRepository{}, "https://auth.example/ssf", verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader("token"))
|
||||
request.Header.Set("Content-Type", "application/secevent+jwt")
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing bearer status=%d", response.Code)
|
||||
}
|
||||
assertSETError(t, response, "authentication_failed")
|
||||
|
||||
set := signSET(t, privateKey, map[string]any{
|
||||
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
|
||||
"jti": uuid.NewString(), "sub": "forbidden", "exp": time.Now().Add(time.Hour).Unix(), "events": map[string]any{},
|
||||
})
|
||||
response = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
|
||||
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("wrong media status=%d", response.Code)
|
||||
}
|
||||
assertSETError(t, response, "invalid_request")
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
|
||||
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
|
||||
request.Header.Set("Content-Type", "application/secevent+jwt")
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request") {
|
||||
t.Fatalf("forbidden claims status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
future := time.Now().Add(10 * time.Minute)
|
||||
futureSET := signSET(t, privateKey, map[string]any{
|
||||
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
|
||||
"jti": uuid.NewString(), "txn": uuid.NewString(),
|
||||
"sub_id": map[string]any{
|
||||
"format": "complex",
|
||||
"user": map[string]any{"format": "iss_sub", "iss": verifier.config.SubjectIssuer, "sub": uuid.NewString()},
|
||||
"tenant": map[string]any{"format": "opaque", "id": verifier.config.TenantID},
|
||||
},
|
||||
"events": map[string]any{SessionRevokedEventType: map[string]any{
|
||||
"event_timestamp": future.Unix(), "initiating_entity": "admin",
|
||||
}},
|
||||
})
|
||||
response = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(futureSET))
|
||||
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
|
||||
request.Header.Set("Content-Type", "application/secevent+jwt")
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("future event timestamp status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverReturnsRFC8935IssuerAudienceAndKeyErrors(t *testing.T) {
|
||||
trustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
untrustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
verifier := &Verifier{config: VerifierConfig{
|
||||
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
|
||||
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
|
||||
}, keys: map[string]*ecdsa.PublicKey{"current": &trustedKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
|
||||
handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
|
||||
|
||||
baseClaims := jwt.MapClaims{
|
||||
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
|
||||
"jti": uuid.NewString(), "events": map[string]any{},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
key *ecdsa.PrivateKey
|
||||
mutate func(jwt.MapClaims)
|
||||
code string
|
||||
}{
|
||||
{name: "issuer", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["iss"] = "https://wrong.example/ssf" }, code: "invalid_issuer"},
|
||||
{name: "audience", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["aud"] = "urn:wrong" }, code: "invalid_audience"},
|
||||
{name: "signature", key: untrustedKey, mutate: func(jwt.MapClaims) {}, code: "invalid_key"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
claims := jwt.MapClaims{}
|
||||
for key, value := range baseClaims {
|
||||
claims[key] = value
|
||||
}
|
||||
test.mutate(claims)
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(signSET(t, test.key, claims)))
|
||||
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
|
||||
request.Header.Set("Content-Type", "application/secevent+jwt")
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
assertSETError(t, response, test.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverAcceptsStreamUpdatedEvent(t *testing.T) {
|
||||
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
verifier := &Verifier{config: VerifierConfig{
|
||||
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
|
||||
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
|
||||
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
|
||||
handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
|
||||
set := signSET(t, privateKey, jwt.MapClaims{
|
||||
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(), "jti": uuid.NewString(),
|
||||
"sub_id": map[string]any{"format": "opaque", "id": verifier.config.StreamID},
|
||||
"events": map[string]any{StreamUpdatedEventType: map[string]any{"status": "paused", "reason": "maintenance"}},
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
|
||||
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
|
||||
request.Header.Set("Content-Type", "application/secevent+jwt")
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusAccepted || response.Body.Len() != 0 {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertSETError(t *testing.T, response *httptest.ResponseRecorder, code string) {
|
||||
t.Helper()
|
||||
if response.Header().Get("Content-Language") != "en" {
|
||||
t.Fatalf("Content-Language=%q", response.Header().Get("Content-Language"))
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("invalid error body: %v", err)
|
||||
}
|
||||
if payload["err"] != code || payload["description"] == "" {
|
||||
t.Fatalf("error payload=%#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func signSET(t *testing.T, key *ecdsa.PrivateKey, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
token.Header["typ"], token.Header["kid"] = "secevent+jwt", "current"
|
||||
raw, err := token.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func coordinate(value *big.Int) string {
|
||||
payload := make([]byte, 32)
|
||||
value.FillBytes(payload)
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
168
apps/api/internal/securityevents/metrics.go
Normal file
168
apps/api/internal/securityevents/metrics.go
Normal 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)
|
||||
}
|
||||
50
apps/api/internal/securityevents/metrics_test.go
Normal file
50
apps/api/internal/securityevents/metrics_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package securityevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type metricsSnapshot struct {
|
||||
mode string
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func (m metricsSnapshot) SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) {
|
||||
return m.mode, &m.last, nil
|
||||
}
|
||||
|
||||
func TestMetricsExposeBoundedOutcomesAndState(t *testing.T) {
|
||||
metrics := &Metrics{}
|
||||
metrics.ObserveReceipt("accepted", 20*time.Millisecond, 2)
|
||||
metrics.ObserveReceipt("duplicate", 10*time.Millisecond, 0)
|
||||
metrics.ObserveWatermarkRejection()
|
||||
metrics.ObserveHeartbeat("accepted")
|
||||
metrics.ObserveIntrospection("failed")
|
||||
metrics.ObserveJWKSRefreshFailure("ssf")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
metrics.Handler(metricsSnapshot{mode: "push_healthy", last: time.Now().Add(-time.Minute)}, "issuer", "audience", true).
|
||||
ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("metrics status=%d", recorder.Code)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`easyai_gateway_ssf_receipts_total{outcome="accepted"} 1`,
|
||||
`easyai_gateway_ssf_receipts_total{outcome="duplicate"} 1`,
|
||||
`easyai_gateway_ssf_sessions_deleted_total 2`,
|
||||
`easyai_gateway_ssf_watermark_rejections_total 1`,
|
||||
`easyai_gateway_ssf_processing_duration_seconds_bucket{le="0.05"} 2`,
|
||||
`easyai_gateway_ssf_mode{mode="push_healthy"} 1`,
|
||||
`easyai_gateway_oidc_introspection_total{outcome="failed"} 1`,
|
||||
`easyai_gateway_jwks_refresh_failures_total{outcome="ssf"} 1`,
|
||||
} {
|
||||
if !strings.Contains(recorder.Body.String(), expected) {
|
||||
t.Fatalf("missing metric %q in:\n%s", expected, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
211
apps/api/internal/securityevents/service.go
Normal file
211
apps/api/internal/securityevents/service.go
Normal 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
|
||||
}
|
||||
86
apps/api/internal/securityevents/service_test.go
Normal file
86
apps/api/internal/securityevents/service_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package securityevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type fakeStateRepository struct {
|
||||
mutex sync.Mutex
|
||||
stateHash []byte
|
||||
verification chan struct{}
|
||||
evaluation store.SecurityEventEvaluation
|
||||
}
|
||||
|
||||
func (*fakeStateRepository) EnsureSecurityEventStreamState(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStateRepository) BeginSecurityEventVerification(_ context.Context, _, _ string, hash []byte, _ time.Time) error {
|
||||
f.mutex.Lock()
|
||||
f.stateHash = append([]byte(nil), hash...)
|
||||
f.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (*fakeStateRepository) RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (*fakeStateRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
|
||||
return f.evaluation, nil
|
||||
}
|
||||
|
||||
func TestServiceRequestsVerificationWithMachineTokenAndPersistsStateFirst(t *testing.T) {
|
||||
repository := &fakeStateRepository{verification: make(chan struct{}, 1)}
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/token":
|
||||
clientID, secret, ok := r.BasicAuth()
|
||||
if !ok || clientID != "gateway-ssf" || secret != "management-secret" {
|
||||
t.Fatal("machine client authentication is missing")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"management-token","token_type":"Bearer","expires_in":300}`))
|
||||
case "/ssf/v1/verify":
|
||||
if r.Header.Get("Authorization") != "Bearer management-token" {
|
||||
t.Fatal("verification bearer is missing")
|
||||
}
|
||||
repository.mutex.Lock()
|
||||
persisted := len(repository.stateHash) == sha256.Size
|
||||
repository.mutex.Unlock()
|
||||
if !persisted {
|
||||
t.Fatal("verification state was not persisted before request")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
repository.verification <- struct{}{}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
service, err := NewService(repository, ServiceConfig{
|
||||
Issuer: server.URL + "/ssf", Audience: "urn:easyai:ssf:receiver:app", StreamID: "00000000-0000-4000-8000-000000000001",
|
||||
ManagementTokenURL: server.URL + "/token", ManagementClientID: "gateway-ssf", ManagementSecret: "management-secret",
|
||||
HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := service.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-repository.verification:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("verification was not requested")
|
||||
}
|
||||
}
|
||||
412
apps/api/internal/securityevents/verifier.go
Normal file
412
apps/api/internal/securityevents/verifier.go
Normal file
@ -0,0 +1,412 @@
|
||||
package securityevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionRevokedEventType = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
|
||||
VerificationEventType = "https://schemas.openid.net/secevent/ssf/event-type/verification"
|
||||
StreamUpdatedEventType = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Issuer string
|
||||
Audience string
|
||||
JTI string
|
||||
TransactionID string
|
||||
IssuedAt time.Time
|
||||
EventType string
|
||||
EventTimestamp time.Time
|
||||
SubjectIssuer string
|
||||
Subject string
|
||||
TenantID string
|
||||
InitiatingEntity string
|
||||
StreamID string
|
||||
State string
|
||||
StreamStatus string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type VerifierConfig struct {
|
||||
TransmitterIssuer string
|
||||
Audience string
|
||||
SubjectIssuer string
|
||||
TenantID string
|
||||
StreamID string
|
||||
ClockSkew time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
config VerifierConfig
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
keys map[string]*ecdsa.PublicKey
|
||||
expiresAt time.Time
|
||||
metrics *Metrics
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
func (v *Verifier) SetMetrics(metrics *Metrics) { v.metrics = metrics }
|
||||
|
||||
type transmitterMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
}
|
||||
|
||||
type jwkSet struct {
|
||||
Keys []struct {
|
||||
KID string `json:"kid"`
|
||||
KTY string `json:"kty"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
type protocolError struct {
|
||||
Code string
|
||||
}
|
||||
|
||||
func (e *protocolError) Error() string { return e.Code }
|
||||
|
||||
func NewVerifier(config VerifierConfig) (*Verifier, error) {
|
||||
config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/")
|
||||
config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/")
|
||||
if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" {
|
||||
return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant are required")
|
||||
}
|
||||
if _, err := uuid.Parse(config.StreamID); err != nil {
|
||||
return nil, errors.New("SSF stream id must be a UUID")
|
||||
}
|
||||
if config.ClockSkew < 0 || config.ClockSkew > 5*time.Minute {
|
||||
return nil, errors.New("SSF clock skew is invalid")
|
||||
}
|
||||
client := config.HTTPClient
|
||||
if client == nil {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
transport.DisableCompression = true
|
||||
client = &http.Client{Timeout: 10 * time.Second,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
}
|
||||
return &Verifier{config: config, client: client, keys: make(map[string]*ecdsa.PublicKey), clock: time.Now}, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) {
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{"ES256"}))
|
||||
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
|
||||
if err != nil || unverified == nil || unverified.Header["typ"] != "secevent+jwt" || unverified.Header["alg"] != "ES256" {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
if stringClaim(unverifiedClaims, "iss") != v.config.TransmitterIssuer {
|
||||
return Event{}, &protocolError{Code: "invalid_issuer"}
|
||||
}
|
||||
if !claimHasAudience(unverifiedClaims["aud"], v.config.Audience) {
|
||||
return Event{}, &protocolError{Code: "invalid_audience"}
|
||||
}
|
||||
kid, _ := unverified.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
return Event{}, &protocolError{Code: "invalid_key"}
|
||||
}
|
||||
key, err := v.key(ctx, kid)
|
||||
if err != nil {
|
||||
return Event{}, &protocolError{Code: "invalid_key"}
|
||||
}
|
||||
token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { return key, nil },
|
||||
jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(v.config.TransmitterIssuer),
|
||||
jwt.WithAudience(v.config.Audience), jwt.WithIssuedAt(), jwt.WithLeeway(v.config.ClockSkew))
|
||||
if err != nil || !token.Valid {
|
||||
if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
|
||||
return Event{}, &protocolError{Code: "invalid_key"}
|
||||
}
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
if _, present := claims["sub"]; present {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
if _, present := claims["exp"]; present {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
jti := stringClaim(claims, "jti")
|
||||
if _, err := uuid.Parse(jti); err != nil {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
issuedAt, err := claims.GetIssuedAt()
|
||||
if err != nil || issuedAt == nil {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
events, ok := claims["events"].(map[string]any)
|
||||
if !ok || len(events) != 1 {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
event := Event{Issuer: v.config.TransmitterIssuer, Audience: v.config.Audience, JTI: jti, TransactionID: stringClaim(claims, "txn"), IssuedAt: issuedAt.Time}
|
||||
if payload, present := events[SessionRevokedEventType]; present {
|
||||
value, ok := payload.(map[string]any)
|
||||
if !ok || event.TransactionID == "" {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
if _, err := uuid.Parse(event.TransactionID); err != nil {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"])
|
||||
if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) ||
|
||||
!validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
if payload, present := events[VerificationEventType]; present {
|
||||
value, ok := payload.(map[string]any)
|
||||
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
event.EventType = VerificationEventType
|
||||
if rawState, present := value["state"]; present {
|
||||
var valid bool
|
||||
event.State, valid = rawState.(string)
|
||||
if !valid || len(event.State) > 1024 {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
}
|
||||
if len(event.State) > 1024 {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
if payload, present := events[StreamUpdatedEventType]; present {
|
||||
value, ok := payload.(map[string]any)
|
||||
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
event.EventType = StreamUpdatedEventType
|
||||
event.StreamStatus, event.Reason = stringValue(value["status"]), stringValue(value["reason"])
|
||||
if event.StreamStatus != "enabled" && event.StreamStatus != "paused" && event.StreamStatus != "disabled" {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
if len(event.Reason) > 1024 {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
|
||||
func claimHasAudience(raw any, expected string) bool {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return value == expected
|
||||
case []string:
|
||||
for _, audience := range value {
|
||||
if audience == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
if audience, ok := item.(string); ok && audience == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (v *Verifier) now() time.Time {
|
||||
if v.clock != nil {
|
||||
return v.clock().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func (v *Verifier) readSessionSubject(raw any, event *Event) bool {
|
||||
subject, ok := raw.(map[string]any)
|
||||
if !ok || stringValue(subject["format"]) != "complex" {
|
||||
return false
|
||||
}
|
||||
user, userOK := subject["user"].(map[string]any)
|
||||
tenant, tenantOK := subject["tenant"].(map[string]any)
|
||||
if !userOK || !tenantOK || stringValue(user["format"]) != "iss_sub" || stringValue(tenant["format"]) != "opaque" {
|
||||
return false
|
||||
}
|
||||
event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"])
|
||||
if event.SubjectIssuer != v.config.SubjectIssuer || event.TenantID != v.config.TenantID {
|
||||
return false
|
||||
}
|
||||
_, err := uuid.Parse(event.Subject)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (v *Verifier) readStreamSubject(raw any, event *Event) bool {
|
||||
subject, ok := raw.(map[string]any)
|
||||
if !ok || stringValue(subject["format"]) != "opaque" || stringValue(subject["id"]) != v.config.StreamID {
|
||||
return false
|
||||
}
|
||||
event.StreamID = v.config.StreamID
|
||||
return true
|
||||
}
|
||||
|
||||
func (v *Verifier) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) {
|
||||
v.mu.Lock()
|
||||
if key := v.keys[kid]; key != nil && time.Now().Before(v.expiresAt) {
|
||||
v.mu.Unlock()
|
||||
return key, nil
|
||||
}
|
||||
v.mu.Unlock()
|
||||
if err := v.refresh(ctx); err != nil {
|
||||
if v.metrics != nil {
|
||||
v.metrics.ObserveJWKSRefreshFailure("ssf")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
key := v.keys[kid]
|
||||
if key == nil {
|
||||
return nil, errors.New("SSF signing key is unavailable")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) refresh(ctx context.Context) error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
configurationURL, err := ssfConfigurationURL(v.config.TransmitterIssuer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var metadata transmitterMetadata
|
||||
if err := v.fetchJSON(ctx, configurationURL, &metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimRight(metadata.Issuer, "/") != v.config.TransmitterIssuer || !sameOrigin(metadata.JWKSURI, v.config.TransmitterIssuer) {
|
||||
return errors.New("SSF metadata is not bound to configured issuer")
|
||||
}
|
||||
var set jwkSet
|
||||
if err := v.fetchJSON(ctx, metadata.JWKSURI, &set); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := make(map[string]*ecdsa.PublicKey)
|
||||
for _, item := range set.Keys {
|
||||
if item.KID == "" || item.KTY != "EC" || item.Alg != "ES256" || item.Crv != "P-256" || item.Use != "sig" {
|
||||
continue
|
||||
}
|
||||
x, xErr := decodeCoordinate(item.X)
|
||||
y, yErr := decodeCoordinate(item.Y)
|
||||
if xErr == nil && yErr == nil && elliptic.P256().IsOnCurve(x, y) {
|
||||
keys[item.KID] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return errors.New("SSF JWKS contains no ES256 keys")
|
||||
}
|
||||
v.keys, v.expiresAt = keys, time.Now().Add(5*time.Minute)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Verifier) fetchJSON(ctx context.Context, endpoint string, target any) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := v.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("SSF endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
||||
if err != nil || len(payload) > 1<<20 {
|
||||
return errors.New("SSF endpoint response is too large")
|
||||
}
|
||||
if err := json.Unmarshal(payload, target); err != nil {
|
||||
return errors.New("SSF endpoint returned invalid JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ssfConfigurationURL(issuer string) (string, error) {
|
||||
parsed, err := url.Parse(issuer)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", errors.New("SSF issuer is invalid")
|
||||
}
|
||||
path := strings.TrimSuffix(parsed.EscapedPath(), "/")
|
||||
parsed.Path, parsed.RawPath = "/.well-known/ssf-configuration"+path, ""
|
||||
parsed.RawQuery, parsed.Fragment = "", ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func sameOrigin(candidate, issuer string) bool {
|
||||
candidateURL, candidateErr := url.Parse(candidate)
|
||||
issuerURL, issuerErr := url.Parse(issuer)
|
||||
return candidateErr == nil && issuerErr == nil && candidateURL.Scheme == issuerURL.Scheme && strings.EqualFold(candidateURL.Host, issuerURL.Host) && candidateURL.User == nil && candidateURL.Fragment == ""
|
||||
}
|
||||
|
||||
func absoluteHTTPURL(raw string) bool {
|
||||
parsed, err := url.Parse(raw)
|
||||
return err == nil && parsed.IsAbs() && parsed.Hostname() != "" && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.User == nil && parsed.Fragment == ""
|
||||
}
|
||||
|
||||
func decodeCoordinate(raw string) (*big.Int, error) {
|
||||
value, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil || len(value) != 32 {
|
||||
return nil, errors.New("invalid EC coordinate")
|
||||
}
|
||||
return new(big.Int).SetBytes(value), nil
|
||||
}
|
||||
|
||||
func stringClaim(claims jwt.MapClaims, name string) string { return stringValue(claims[name]) }
|
||||
|
||||
func stringValue(value any) string {
|
||||
result, _ := value.(string)
|
||||
return result
|
||||
}
|
||||
|
||||
func unixClaim(value any) time.Time {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return time.Unix(int64(typed), 0).UTC()
|
||||
case json.Number:
|
||||
integer, err := typed.Int64()
|
||||
if err == nil {
|
||||
return time.Unix(integer, 0).UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func validInitiator(value string) bool {
|
||||
return value == "admin" || value == "user" || value == "policy" || value == "system"
|
||||
}
|
||||
341
apps/api/internal/store/security_events.go
Normal file
341
apps/api/internal/store/security_events.go
Normal 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]
|
||||
}
|
||||
132
apps/api/internal/store/security_events_integration_test.go
Normal file
132
apps/api/internal/store/security_events_integration_test.go
Normal file
@ -0,0 +1,132 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run security event PostgreSQL integration tests")
|
||||
}
|
||||
ctx := context.Background()
|
||||
probe, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var databaseName string
|
||||
if err := probe.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
|
||||
probe.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe.Close()
|
||||
if !strings.Contains(strings.ToLower(databaseName), "test") {
|
||||
t.Fatalf("refusing to migrate non-test database %q", databaseName)
|
||||
}
|
||||
applyOIDCJITTestMigrations(t, ctx, databaseURL)
|
||||
db, err := Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
issuer := "https://auth.test.example/ssf"
|
||||
subjectIssuer := "https://auth.test/realms/tenant"
|
||||
audience := "urn:easyai:ssf:receiver:" + uuid.NewString()
|
||||
streamID, tenantID, subject := uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_receipts WHERE issuer=$1`, issuer)
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_oidc_revocation_watermarks WHERE issuer=$1`, subjectIssuer)
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_stream_state WHERE issuer=$1`, issuer)
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_audit_logs WHERE actor_source='ssf' AND target_id=ANY($1)`, []string{shortSecurityEventHash(subject), shortSecurityEventHash(streamID)})
|
||||
})
|
||||
if err := db.EnsureSecurityEventStreamState(ctx, issuer, audience, streamID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
|
||||
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "bootstrap" {
|
||||
t.Fatalf("bootstrap evaluation=%#v error=%v", evaluation, err)
|
||||
}
|
||||
|
||||
confirmAt := func(label string, at time.Time) {
|
||||
t.Helper()
|
||||
stateHash := sha256.Sum256([]byte(label))
|
||||
if err := db.BeginSecurityEventVerification(ctx, issuer, audience, stateHash[:], at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matched, err := db.ConfirmSecurityEventVerification(ctx, issuer, audience, streamID, uuid.NewString(), stateHash[:], at)
|
||||
if err != nil || !matched {
|
||||
t.Fatalf("confirm matched=%v error=%v", matched, err)
|
||||
}
|
||||
}
|
||||
inserted, err := db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, uuid.NewString(), streamID, "enabled", "onboarding", now)
|
||||
if err != nil || !inserted {
|
||||
t.Fatalf("enable stream inserted=%v error=%v", inserted, err)
|
||||
}
|
||||
confirmAt("first-verification-state", now)
|
||||
confirmAt("second-verification-state", now.Add(time.Minute))
|
||||
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(time.Minute), 180*time.Second)
|
||||
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
|
||||
t.Fatalf("bootstrap overlap evaluation=%#v error=%v", evaluation, err)
|
||||
}
|
||||
confirmAt("post-bootstrap-verification-state", now.Add(361*time.Second))
|
||||
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(361*time.Second), 180*time.Second)
|
||||
if err != nil || evaluation.RequireIntrospection || evaluation.Mode != "push_healthy" {
|
||||
t.Fatalf("healthy evaluation=%#v error=%v", evaluation, err)
|
||||
}
|
||||
streamUpdateJTI := uuid.NewString()
|
||||
inserted, err = db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, streamUpdateJTI, streamID, "paused", "maintenance", now.Add(362*time.Second))
|
||||
if err != nil || !inserted {
|
||||
t.Fatalf("stream update inserted=%v error=%v", inserted, err)
|
||||
}
|
||||
inserted, err = db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, streamUpdateJTI, streamID, "paused", "maintenance", now)
|
||||
if err != nil || inserted {
|
||||
t.Fatalf("duplicate stream update inserted=%v error=%v", inserted, err)
|
||||
}
|
||||
confirmAt("paused-verification-one", now.Add(363*time.Second))
|
||||
confirmAt("paused-verification-two", now.Add(364*time.Second))
|
||||
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(364*time.Second), 180*time.Second)
|
||||
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
|
||||
t.Fatalf("stream update fallback=%#v error=%v", evaluation, err)
|
||||
}
|
||||
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET stream_status='enabled',mode='push_healthy',fallback_since=NULL,
|
||||
consecutive_verifications=0,last_verification_at=$3 WHERE issuer=$1 AND audience=$2`, issuer, audience, now)
|
||||
|
||||
revokedAt := now.Add(-10 * time.Second)
|
||||
input := ApplySessionRevokedInput{Issuer: issuer, Audience: audience, JTI: uuid.NewString(), TransactionID: uuid.NewString(), SubjectIssuer: subjectIssuer, TenantID: tenantID, Subject: subject, EventTimestamp: revokedAt, InitiatingEntity: "admin"}
|
||||
result, err := db.ApplySessionRevoked(ctx, input)
|
||||
if err != nil || !result.WatermarkMoved || result.AuditID == "" {
|
||||
t.Fatalf("apply result=%#v error=%v", result, err)
|
||||
}
|
||||
duplicate, err := db.ApplySessionRevoked(ctx, input)
|
||||
if err != nil || !duplicate.Duplicate {
|
||||
t.Fatalf("duplicate result=%#v error=%v", duplicate, err)
|
||||
}
|
||||
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, revokedAt, now, 180*time.Second)
|
||||
if !evaluation.Revoked {
|
||||
t.Fatal("token at watermark was accepted")
|
||||
}
|
||||
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", tenantID, subject, revokedAt, now, 180*time.Second)
|
||||
if evaluation.Revoked {
|
||||
t.Fatal("watermark crossed OIDC issuer boundary")
|
||||
}
|
||||
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
|
||||
if evaluation.Revoked {
|
||||
t.Fatal("token after watermark was rejected")
|
||||
}
|
||||
|
||||
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET last_verification_at=$3
|
||||
WHERE issuer=$1 AND audience=$2`, issuer, audience, now.Add(-181*time.Second))
|
||||
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
|
||||
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
|
||||
t.Fatalf("fallback evaluation=%#v error=%v", evaluation, err)
|
||||
}
|
||||
}
|
||||
10
apps/api/internal/store/security_events_test.go
Normal file
10
apps/api/internal/store/security_events_test.go
Normal file
@ -0,0 +1,10 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSecurityEventHashesAreStableAndSanitized(t *testing.T) {
|
||||
first := shortSecurityEventHash("sensitive-subject")
|
||||
if first != shortSecurityEventHash("sensitive-subject") || len(first) != 16 || first == "sensitive-subject" {
|
||||
t.Fatalf("unsafe or unstable hash: %q", first)
|
||||
}
|
||||
}
|
||||
70
apps/api/migrations/0062_oidc_security_events.sql
Normal file
70
apps/api/migrations/0062_oidc_security_events.sql
Normal file
@ -0,0 +1,70 @@
|
||||
CREATE TABLE IF NOT EXISTS gateway_security_event_receipts (
|
||||
issuer text NOT NULL,
|
||||
jti uuid NOT NULL,
|
||||
audience text NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
transaction_id text,
|
||||
tenant_id text,
|
||||
subject_hash text,
|
||||
event_timestamp timestamptz,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
processed_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (issuer, jti)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_security_event_receipts_received
|
||||
ON gateway_security_event_receipts(received_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_oidc_revocation_watermarks (
|
||||
issuer text NOT NULL,
|
||||
tenant_id text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
revoked_at timestamptz NOT NULL,
|
||||
source_jti uuid NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (issuer, tenant_id, subject)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_oidc_revocation_watermarks_lookup
|
||||
ON gateway_oidc_revocation_watermarks(issuer, tenant_id, subject, revoked_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_security_event_stream_state (
|
||||
issuer text NOT NULL,
|
||||
audience text NOT NULL,
|
||||
stream_id uuid NOT NULL,
|
||||
stream_status text NOT NULL DEFAULT 'unknown',
|
||||
mode text NOT NULL DEFAULT 'bootstrap',
|
||||
last_verification_at timestamptz,
|
||||
bootstrap_until timestamptz,
|
||||
consecutive_verifications integer NOT NULL DEFAULT 0,
|
||||
pending_state_hash bytea,
|
||||
pending_state_created_at timestamptz,
|
||||
fallback_since timestamptz,
|
||||
last_error_category text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (issuer, audience),
|
||||
UNIQUE (stream_id),
|
||||
CONSTRAINT gateway_security_event_stream_state_mode
|
||||
CHECK (mode IN ('bootstrap','push_healthy','introspection_fallback')),
|
||||
CONSTRAINT gateway_security_event_stream_state_status
|
||||
CHECK (stream_status IN ('unknown','enabled','paused','disabled')),
|
||||
CONSTRAINT gateway_security_event_stream_state_pending_hash
|
||||
CHECK (pending_state_hash IS NULL OR octet_length(pending_state_hash) = 32)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges (
|
||||
issuer text NOT NULL,
|
||||
audience text NOT NULL,
|
||||
state_hash bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (issuer, audience, state_hash),
|
||||
FOREIGN KEY (issuer, audience)
|
||||
REFERENCES gateway_security_event_stream_state(issuer, audience) ON DELETE CASCADE,
|
||||
CONSTRAINT gateway_security_event_verification_challenge_hash
|
||||
CHECK (octet_length(state_hash) = 32)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry
|
||||
ON gateway_security_event_verification_challenges(expires_at);
|
||||
@ -14,6 +14,22 @@ x-api-environment: &api-environment
|
||||
OIDC_REQUIRED_SCOPES: ${OIDC_REQUIRED_SCOPES:-gateway.access}
|
||||
OIDC_JWKS_CACHE_TTL_SECONDS: ${OIDC_JWKS_CACHE_TTL_SECONDS:-300}
|
||||
OIDC_ACCEPT_LEGACY_HS256: ${OIDC_ACCEPT_LEGACY_HS256:-true}
|
||||
OIDC_INTROSPECTION_ENABLED: ${OIDC_INTROSPECTION_ENABLED:-false}
|
||||
OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-}
|
||||
OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-}
|
||||
OIDC_SECURITY_EVENTS_ENABLED: ${OIDC_SECURITY_EVENTS_ENABLED:-false}
|
||||
OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER: ${OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER:-}
|
||||
OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE: ${OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE:-}
|
||||
OIDC_SECURITY_EVENTS_BEARER_SECRET: ${OIDC_SECURITY_EVENTS_BEARER_SECRET:-}
|
||||
OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT: ${OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT:-}
|
||||
OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT: ${OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT:-}
|
||||
OIDC_SECURITY_EVENTS_STREAM_ID: ${OIDC_SECURITY_EVENTS_STREAM_ID:-}
|
||||
OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL: ${OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL:-}
|
||||
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID:-}
|
||||
OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET:-}
|
||||
OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60}
|
||||
OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180}
|
||||
OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60}
|
||||
OIDC_JIT_PROVISIONING_ENABLED: ${OIDC_JIT_PROVISIONING_ENABLED:-false}
|
||||
OIDC_GATEWAY_TENANT_KEY: ${OIDC_GATEWAY_TENANT_KEY:-}
|
||||
OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true}
|
||||
|
||||
@ -10,6 +10,29 @@ server {
|
||||
return 308 /gateway-api/;
|
||||
}
|
||||
|
||||
# RFC 8935 receiver: exact path, no redirect, short bounded upstream timeout.
|
||||
location = /api/v1/security-events/ssf {
|
||||
client_max_body_size 64k;
|
||||
client_body_buffer_size 64k;
|
||||
client_body_timeout 5s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 10s;
|
||||
proxy_send_timeout 10s;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://api:8088;
|
||||
}
|
||||
|
||||
# Metrics stay on the API's monitoring network and are not exposed by Web.
|
||||
location = /gateway-api/metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /gateway-api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
56
docs/security/ssf-session-revocation.md
Normal file
56
docs/security/ssf-session-revocation.md
Normal file
@ -0,0 +1,56 @@
|
||||
# SSF/CAEP 实时会话撤销运行手册
|
||||
|
||||
Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token,并处理 OpenID CAEP `session-revoked`。默认关闭;关闭时不会注册接收路由或启动状态机,也不会改变 OIDC、BFF、API Key 或 Legacy JWT 行为。
|
||||
|
||||
## 启用前提
|
||||
|
||||
- Auth Center 已创建属于当前 Application/租户的 paused Stream,Audience 为 `urn:easyai:ssf:receiver:{application-public-id}`。
|
||||
- Gateway OIDC `iss`、`tid` 与 Stream 绑定完全一致;Stream ID 为公开 UUID,不使用认证内核 ID。
|
||||
- 接收 Bearer、Management Client Secret、RFC 7662 Client Secret 仅从 Git 忽略的 `.env`、Kubernetes Secret 或 Secret Manager 注入。
|
||||
- `OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT` 必须是外部可访问的精确 HTTPS 地址;本地测试环境才允许 localhost HTTP。
|
||||
- Management Client 至少有 `ssf.stream.read ssf.stream.verify`,不能使用浏览器 PKCE Client。
|
||||
|
||||
完整配置键见仓库根目录 `.env.example`。启用时 `OIDC_SECURITY_EVENTS_ENABLED=true`,且 RFC 7662 Client ID/Secret 必须同时存在,否则启动失败。
|
||||
|
||||
## 启用顺序
|
||||
|
||||
1. 运行数据库迁移,部署仍保持功能关闭。
|
||||
2. 在 Secret Store 创建接收 Bearer、Management Client Secret 和内省 Client Secret;不要把值放进日志或工单。
|
||||
3. 启用 Gateway。启动模式为 `bootstrap`,所有 OIDC Token 都先内省。
|
||||
4. 在 Auth Center 对 paused Stream 发起 Verification,确认 Gateway 返回空 Body 的 `202`。
|
||||
5. 启用 Stream。首次 Verification 成功后仍内省 360 秒,覆盖启用前已签发的最长 300 秒 Token。
|
||||
6. 观察 `easyai_gateway_ssf_mode{mode="push_healthy"}` 变为 1,再执行测试用户撤销。
|
||||
|
||||
健康时 Gateway 每 60 秒请求一次 Verification,不按业务 QPS 请求 Auth Center。180 秒没有匹配的有效 Verification 会进入 `introspection_fallback`;只有最近收到的 Stream 状态为 `enabled` 时,连续两次 Verification 成功才会恢复,`paused`/`disabled` 状态下心跳不会错误恢复推送信任。降级期间内省不可用会对 OIDC 返回可审计的 503,API Key 继续工作。
|
||||
|
||||
## 零停机 Bearer 轮换
|
||||
|
||||
1. 在 Gateway 写入 `OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT`,同时接受 current/next。
|
||||
2. 更新 Auth Center 的 Stream `credential_ref` 指向 next。
|
||||
3. 发起 Verification,并确认投递和状态机健康。
|
||||
4. 等待至少 180 秒后,将 next 提升为 current 并清空旧值。
|
||||
|
||||
Gateway 使用常量时间比较两个值。响应、日志、审计和指标都不得包含 Authorization Header 或原始 SET。
|
||||
|
||||
## 监控与告警
|
||||
|
||||
`GET /metrics` 输出以下低基数指标;生产入口应仅允许监控网络访问该路径:
|
||||
|
||||
- `easyai_gateway_ssf_receipts_total{outcome}`:accepted/rejected/duplicate。
|
||||
- `easyai_gateway_ssf_sessions_deleted_total`:被撤销删除的 BFF Session。
|
||||
- `easyai_gateway_ssf_watermark_rejections_total`:本地水位拒绝的 Token。
|
||||
- `easyai_gateway_ssf_processing_duration_seconds`:Receiver 数据库事务处理直方图,用于 P99 告警。
|
||||
- `easyai_gateway_ssf_verification_age_seconds` 和 `easyai_gateway_ssf_mode{mode}`。
|
||||
- `easyai_gateway_ssf_heartbeat_requests_total{outcome}`。
|
||||
- `easyai_gateway_oidc_introspection_total{outcome}`。
|
||||
- `easyai_gateway_jwks_refresh_failures_total{source}`:SSF/OIDC JWKS 刷新失败。
|
||||
|
||||
至少配置以下告警:Verification age 超过 120 秒、fallback 超过 5 分钟、内省 failed 增长、SET rejected 增长、撤销端到端 P99 超过 3 秒。日志只能携带脱敏 Trace ID、Audit ID 和错误类别。
|
||||
|
||||
## 回滚
|
||||
|
||||
先在 Auth Center 暂停 Stream,再保持 Gateway 启用但持续使用 RFC 7662,确认没有遗漏撤销后才关闭接收功能。保留迁移表和 Secret,不做破坏性数据库回滚。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。
|
||||
|
||||
## 验收证据
|
||||
|
||||
真实链路验收只能使用明确标记为测试用途的 Application,且必须在自动化测试通过后执行。报告需包含脱敏 Trace、Claims 结构、截图、Audit ID、投递延迟、fallback 与恢复证据。涉及人工、真实设备或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`,Mock、契约和安全测试不得跳过。
|
||||
Loading…
Reference in New Issue
Block a user