feat: 接入 SSF 实时会话撤销

This commit is contained in:
2026-07-14 17:06:41 +08:00
parent 30ad0e9f2c
commit f30aaeb2d4
28 changed files with 2597 additions and 91 deletions
+185
View File
@@ -0,0 +1,185 @@
package securityevents
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type Repository interface {
ApplySessionRevoked(context.Context, store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error)
ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error)
ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error)
RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error)
}
type Handler struct {
verifier *Verifier
repository Repository
issuer string
audience string
streamID string
bearerSecret string
nextSecret string
clock func() time.Time
metrics *Metrics
}
func (h *Handler) SetMetrics(metrics *Metrics) { h.metrics = metrics }
func NewHandler(verifier *Verifier, repository Repository, issuer, audience, streamID, bearerSecret, nextSecret string) (*Handler, error) {
if verifier == nil || repository == nil || issuer == "" || audience == "" || streamID == "" || len(bearerSecret) < 16 {
return nil, errors.New("SSF receiver dependencies and bearer secret are required")
}
return &Handler{verifier: verifier, repository: repository, issuer: issuer, audience: audience, streamID: streamID, bearerSecret: bearerSecret, nextSecret: nextSecret, clock: time.Now}, nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
started := h.clock()
outcome := "rejected"
var sessionsDeleted int64
defer func() {
if h.metrics != nil {
h.metrics.ObserveReceipt(outcome, h.clock().Sub(started), sessionsDeleted)
}
}()
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !h.validAuthorization(r.Header.Get("Authorization")) {
w.Header().Set("WWW-Authenticate", `Bearer realm="ssf-receiver"`)
writeSETError(w, http.StatusUnauthorized, "authentication_failed")
return
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/secevent+jwt" || r.Header.Get("Content-Encoding") != "" {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
payload, err := io.ReadAll(r.Body)
if err != nil || len(payload) == 0 {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
event, err := h.verifier.Verify(r.Context(), string(payload))
if err != nil {
var protocol *protocolError
if errors.As(err, &protocol) {
writeSETError(w, http.StatusBadRequest, protocol.Code)
} else {
writeSETError(w, http.StatusBadRequest, "invalid_request")
}
return
}
switch event.EventType {
case SessionRevokedEventType:
var result store.ApplySecurityEventResult
result, err = h.repository.ApplySessionRevoked(r.Context(), store.ApplySessionRevokedInput{
Issuer: event.Issuer, Audience: event.Audience, JTI: event.JTI, TransactionID: event.TransactionID,
SubjectIssuer: event.SubjectIssuer, TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp,
InitiatingEntity: event.InitiatingEntity,
})
if err == nil {
sessionsDeleted = result.SessionsDeleted
if result.Duplicate {
outcome = "duplicate"
} else {
outcome = "accepted"
}
}
case VerificationEventType:
var confirmed bool
if event.State == "" {
confirmed, err = h.repository.RecordSecurityEventReceipt(r.Context(), h.issuer, h.audience, event.JTI, VerificationEventType, h.streamID)
} else {
stateHash := sha256.Sum256([]byte(event.State))
confirmed, err = h.repository.ConfirmSecurityEventVerification(r.Context(), h.issuer, h.audience, h.streamID, event.JTI, stateHash[:], h.clock().UTC())
}
if err != nil && strings.Contains(err.Error(), "invalid verification state") {
writeSETError(w, http.StatusBadRequest, "invalid_request")
return
}
if err == nil {
if confirmed {
outcome = "accepted"
if h.metrics != nil {
h.metrics.ObserveVerificationAccepted()
}
} else {
outcome = "duplicate"
}
}
case StreamUpdatedEventType:
var inserted bool
inserted, err = h.repository.ApplySecurityEventStreamUpdated(
r.Context(), h.issuer, h.audience, event.JTI, h.streamID, event.StreamStatus, event.Reason, h.clock().UTC(),
)
if err == nil {
if inserted {
outcome = "accepted"
} else {
outcome = "duplicate"
}
}
}
if err != nil {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (h *Handler) validAuthorization(value string) bool {
if constantBearerCompare(value, h.bearerSecret) {
return true
}
return h.nextSecret != "" && constantBearerCompare(value, h.nextSecret)
}
func constantBearerCompare(header, secret string) bool {
providedHash := sha256.Sum256([]byte(header))
expectedHash := sha256.Sum256([]byte("Bearer " + secret))
return subtle.ConstantTimeCompare(providedHash[:], expectedHash[:]) == 1
}
func writeSETError(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Language", "en")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{
"err": code,
"description": setErrorDescription(code),
})
}
func setErrorDescription(code string) string {
switch code {
case "invalid_request":
return "The SET request is malformed or contains invalid claims."
case "invalid_key":
return "The SET signing key or signature is invalid."
case "invalid_issuer":
return "The SET issuer is not accepted by this receiver."
case "invalid_audience":
return "The SET audience does not identify this receiver."
case "authentication_failed":
return "Receiver authentication failed."
case "access_denied":
return "The SET is not permitted for this receiver."
default:
return "The SET request could not be processed."
}
}
@@ -0,0 +1,269 @@
package securityevents
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type fakeRepository struct {
input *store.ApplySessionRevokedInput
dedup map[string]struct{}
confirmed bool
}
func (f *fakeRepository) ApplySessionRevoked(_ context.Context, input store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) {
if f.dedup == nil {
f.dedup = make(map[string]struct{})
}
if _, exists := f.dedup[input.JTI]; exists {
return store.ApplySecurityEventResult{Duplicate: true}, nil
}
f.dedup[input.JTI] = struct{}{}
f.input = &input
return store.ApplySecurityEventResult{WatermarkMoved: true, SessionsDeleted: 2}, nil
}
func (f *fakeRepository) ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error) {
f.confirmed = true
return true, nil
}
func (*fakeRepository) RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error) {
return true, nil
}
func (*fakeRepository) ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error) {
return true, nil
}
func TestReceiverAcceptsValidSessionRevokedAndDuplicate(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
var transmitter *httptest.Server
transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/ssf-configuration/ssf":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json"})
case "/ssf/jwks.json":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{
"kty": "EC", "kid": "current", "use": "sig", "alg": "ES256", "crv": "P-256",
"x": coordinate(privateKey.X), "y": coordinate(privateKey.Y),
}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer transmitter.Close()
tenantID, subjectID, applicationID, streamID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
audience := "urn:easyai:ssf:receiver:" + applicationID
verifier, err := NewVerifier(VerifierConfig{
TransmitterIssuer: transmitter.URL + "/ssf", Audience: audience,
SubjectIssuer: "https://auth.example/issuer/tenant-a", TenantID: tenantID, StreamID: streamID,
ClockSkew: time.Minute,
})
if err != nil {
t.Fatal(err)
}
repository := &fakeRepository{}
handler, err := NewHandler(verifier, repository, transmitter.URL+"/ssf", audience, streamID, "receiver-bearer-secret", "next-receiver-secret")
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Truncate(time.Second)
jti := uuid.NewString()
set := signSET(t, privateKey, map[string]any{
"iss": transmitter.URL + "/ssf", "aud": audience, "iat": now.Unix(), "jti": jti, "txn": uuid.NewString(),
"sub_id": map[string]any{
"format": "complex",
"user": map[string]any{"format": "iss_sub", "iss": "https://auth.example/issuer/tenant-a", "sub": subjectID},
"tenant": map[string]any{"format": "opaque", "id": tenantID},
},
"events": map[string]any{SessionRevokedEventType: map[string]any{"event_timestamp": now.Unix(), "initiating_entity": "admin"}},
})
for _, secret := range []string{"receiver-bearer-secret", "next-receiver-secret"} {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer "+secret)
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted || response.Body.Len() != 0 {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
if repository.input == nil || repository.input.Subject != subjectID || repository.input.TenantID != tenantID ||
repository.input.SubjectIssuer != "https://auth.example/issuer/tenant-a" {
t.Fatalf("applied event = %#v", repository.input)
}
}
func TestReceiverRejectsAuthenticationMediaTypeAndForbiddenClaims(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, "https://auth.example/ssf", verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader("token"))
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("missing bearer status=%d", response.Code)
}
assertSETError(t, response, "authentication_failed")
set := signSET(t, privateKey, map[string]any{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "sub": "forbidden", "exp": time.Now().Add(time.Hour).Unix(), "events": map[string]any{},
})
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("wrong media status=%d", response.Code)
}
assertSETError(t, response, "invalid_request")
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request") {
t.Fatalf("forbidden claims status=%d body=%s", response.Code, response.Body.String())
}
future := time.Now().Add(10 * time.Minute)
futureSET := signSET(t, privateKey, map[string]any{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "txn": uuid.NewString(),
"sub_id": map[string]any{
"format": "complex",
"user": map[string]any{"format": "iss_sub", "iss": verifier.config.SubjectIssuer, "sub": uuid.NewString()},
"tenant": map[string]any{"format": "opaque", "id": verifier.config.TenantID},
},
"events": map[string]any{SessionRevokedEventType: map[string]any{
"event_timestamp": future.Unix(), "initiating_entity": "admin",
}},
})
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(futureSET))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("future event timestamp status=%d", response.Code)
}
}
func TestReceiverReturnsRFC8935IssuerAudienceAndKeyErrors(t *testing.T) {
trustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
untrustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &trustedKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
baseClaims := jwt.MapClaims{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(),
"jti": uuid.NewString(), "events": map[string]any{},
}
tests := []struct {
name string
key *ecdsa.PrivateKey
mutate func(jwt.MapClaims)
code string
}{
{name: "issuer", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["iss"] = "https://wrong.example/ssf" }, code: "invalid_issuer"},
{name: "audience", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["aud"] = "urn:wrong" }, code: "invalid_audience"},
{name: "signature", key: untrustedKey, mutate: func(jwt.MapClaims) {}, code: "invalid_key"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
claims := jwt.MapClaims{}
for key, value := range baseClaims {
claims[key] = value
}
test.mutate(claims)
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(signSET(t, test.key, claims)))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
assertSETError(t, response, test.code)
})
}
}
func TestReceiverAcceptsStreamUpdatedEvent(t *testing.T) {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
verifier := &Verifier{config: VerifierConfig{
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app",
SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute,
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "")
set := signSET(t, privateKey, jwt.MapClaims{
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(), "jti": uuid.NewString(),
"sub_id": map[string]any{"format": "opaque", "id": verifier.config.StreamID},
"events": map[string]any{StreamUpdatedEventType: map[string]any{"status": "paused", "reason": "maintenance"}},
})
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set))
request.Header.Set("Authorization", "Bearer receiver-bearer-secret")
request.Header.Set("Content-Type", "application/secevent+jwt")
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted || response.Body.Len() != 0 {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
func assertSETError(t *testing.T, response *httptest.ResponseRecorder, code string) {
t.Helper()
if response.Header().Get("Content-Language") != "en" {
t.Fatalf("Content-Language=%q", response.Header().Get("Content-Language"))
}
var payload map[string]string
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("invalid error body: %v", err)
}
if payload["err"] != code || payload["description"] == "" {
t.Fatalf("error payload=%#v", payload)
}
}
func signSET(t *testing.T, key *ecdsa.PrivateKey, claims jwt.MapClaims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["typ"], token.Header["kid"] = "secevent+jwt", "current"
raw, err := token.SignedString(key)
if err != nil {
t.Fatal(err)
}
return raw
}
func coordinate(value *big.Int) string {
payload := make([]byte, 32)
value.FillBytes(payload)
return base64.RawURLEncoding.EncodeToString(payload)
}
+168
View File
@@ -0,0 +1,168 @@
package securityevents
import (
"context"
"fmt"
"net/http"
"sync/atomic"
"time"
)
type MetricsSnapshotProvider interface {
SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error)
}
type Metrics struct {
accepted atomic.Uint64
rejected atomic.Uint64
duplicate atomic.Uint64
sessionsDeleted atomic.Uint64
watermarkRejected atomic.Uint64
verificationAccepted atomic.Uint64
heartbeatAccepted atomic.Uint64
heartbeatFailed atomic.Uint64
introspectionActive atomic.Uint64
introspectionInactive atomic.Uint64
introspectionFailed atomic.Uint64
jwksSSFFailed atomic.Uint64
jwksOIDCFailed atomic.Uint64
processingCount atomic.Uint64
processingNanos atomic.Uint64
processingBuckets [6]atomic.Uint64
}
var processingDurationBounds = [...]time.Duration{
10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond,
500 * time.Millisecond, time.Second, 3 * time.Second,
}
func (m *Metrics) ObserveReceipt(outcome string, duration time.Duration, sessionsDeleted int64) {
switch outcome {
case "accepted":
m.accepted.Add(1)
case "duplicate":
m.duplicate.Add(1)
default:
m.rejected.Add(1)
}
if sessionsDeleted > 0 {
m.sessionsDeleted.Add(uint64(sessionsDeleted))
}
if duration < 0 {
duration = 0
}
m.processingCount.Add(1)
m.processingNanos.Add(uint64(duration))
for index, bound := range processingDurationBounds {
if duration <= bound {
m.processingBuckets[index].Add(1)
}
}
}
func (m *Metrics) ObserveWatermarkRejection() { m.watermarkRejected.Add(1) }
func (m *Metrics) ObserveVerificationAccepted() { m.verificationAccepted.Add(1) }
func (m *Metrics) ObserveHeartbeat(outcome string) {
if outcome == "accepted" {
m.heartbeatAccepted.Add(1)
return
}
m.heartbeatFailed.Add(1)
}
func (m *Metrics) ObserveIntrospection(outcome string) {
switch outcome {
case "active":
m.introspectionActive.Add(1)
case "inactive":
m.introspectionInactive.Add(1)
default:
m.introspectionFailed.Add(1)
}
}
func (m *Metrics) ObserveJWKSRefreshFailure(source string) {
if source == "ssf" {
m.jwksSSFFailed.Add(1)
return
}
m.jwksOIDCFailed.Add(1)
}
func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience string, enabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mode := "disabled"
var lastVerificationAt *time.Time
if enabled {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
var err error
mode, lastVerificationAt, err = provider.SecurityEventMetrics(ctx, issuer, audience)
if err != nil {
http.Error(w, "metrics unavailable", http.StatusServiceUnavailable)
return
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
outcomeCounters(w, "easyai_gateway_ssf_receipts_total", "Received SETs by bounded outcome.", []outcomeValue{
{"accepted", m.accepted.Load()}, {"rejected", m.rejected.Load()}, {"duplicate", m.duplicate.Load()},
})
plainCounter(w, "easyai_gateway_ssf_sessions_deleted_total", "OIDC browser sessions deleted by accepted session-revoked events.", m.sessionsDeleted.Load())
plainCounter(w, "easyai_gateway_ssf_watermark_rejections_total", "OIDC access tokens rejected by a local revocation watermark.", m.watermarkRejected.Load())
plainCounter(w, "easyai_gateway_ssf_verifications_total", "Valid verification SETs accepted by the receiver.", m.verificationAccepted.Load())
outcomeCounters(w, "easyai_gateway_ssf_heartbeat_requests_total", "Verification requests by bounded outcome.", []outcomeValue{
{"accepted", m.heartbeatAccepted.Load()}, {"failed", m.heartbeatFailed.Load()},
})
outcomeCounters(w, "easyai_gateway_oidc_introspection_total", "OIDC introspection requests by bounded outcome.", []outcomeValue{
{"active", m.introspectionActive.Load()}, {"inactive", m.introspectionInactive.Load()}, {"failed", m.introspectionFailed.Load()},
})
outcomeCounters(w, "easyai_gateway_jwks_refresh_failures_total", "JWKS refresh failures by bounded source.", []outcomeValue{
{"ssf", m.jwksSSFFailed.Load()}, {"oidc", m.jwksOIDCFailed.Load()},
})
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_processing_duration_seconds SSF receiver transaction processing time.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_processing_duration_seconds histogram")
for index, bound := range processingDurationBounds {
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_bucket{le=\"%.2f\"} %d\n", bound.Seconds(), m.processingBuckets[index].Load())
}
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_bucket{le=\"+Inf\"} %d\n", m.processingCount.Load())
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_sum %.6f\n", float64(m.processingNanos.Load())/float64(time.Second))
fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_count %d\n", m.processingCount.Load())
verificationAge := float64(0)
if lastVerificationAt != nil {
verificationAge = time.Since(*lastVerificationAt).Seconds()
if verificationAge < 0 {
verificationAge = 0
}
}
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_verification_age_seconds Seconds since the last matched verification SET.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_verification_age_seconds gauge")
fmt.Fprintf(w, "easyai_gateway_ssf_verification_age_seconds %.6f\n", verificationAge)
fmt.Fprintln(w, "# HELP easyai_gateway_ssf_mode Current SSF receiver mode as a one-hot gauge.")
fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_mode gauge")
for _, modeName := range []string{"disabled", "bootstrap", "push_healthy", "introspection_fallback"} {
value := 0
if mode == modeName {
value = 1
}
fmt.Fprintf(w, "easyai_gateway_ssf_mode{mode=\"%s\"} %d\n", modeName, value)
}
})
}
type outcomeValue struct {
name string
value uint64
}
func outcomeCounters(w http.ResponseWriter, name, help string, values []outcomeValue) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n", name, help, name)
for _, value := range values {
fmt.Fprintf(w, "%s{outcome=\"%s\"} %d\n", name, value.name, value.value)
}
}
func plainCounter(w http.ResponseWriter, name, help string, value uint64) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", name, help, name, name, value)
}
@@ -0,0 +1,50 @@
package securityevents
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type metricsSnapshot struct {
mode string
last time.Time
}
func (m metricsSnapshot) SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) {
return m.mode, &m.last, nil
}
func TestMetricsExposeBoundedOutcomesAndState(t *testing.T) {
metrics := &Metrics{}
metrics.ObserveReceipt("accepted", 20*time.Millisecond, 2)
metrics.ObserveReceipt("duplicate", 10*time.Millisecond, 0)
metrics.ObserveWatermarkRejection()
metrics.ObserveHeartbeat("accepted")
metrics.ObserveIntrospection("failed")
metrics.ObserveJWKSRefreshFailure("ssf")
recorder := httptest.NewRecorder()
metrics.Handler(metricsSnapshot{mode: "push_healthy", last: time.Now().Add(-time.Minute)}, "issuer", "audience", true).
ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("metrics status=%d", recorder.Code)
}
for _, expected := range []string{
`easyai_gateway_ssf_receipts_total{outcome="accepted"} 1`,
`easyai_gateway_ssf_receipts_total{outcome="duplicate"} 1`,
`easyai_gateway_ssf_sessions_deleted_total 2`,
`easyai_gateway_ssf_watermark_rejections_total 1`,
`easyai_gateway_ssf_processing_duration_seconds_bucket{le="0.05"} 2`,
`easyai_gateway_ssf_mode{mode="push_healthy"} 1`,
`easyai_gateway_oidc_introspection_total{outcome="failed"} 1`,
`easyai_gateway_jwks_refresh_failures_total{outcome="ssf"} 1`,
} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("missing metric %q in:\n%s", expected, recorder.Body.String())
}
}
}
+211
View File
@@ -0,0 +1,211 @@
package securityevents
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type StateRepository interface {
EnsureSecurityEventStreamState(context.Context, string, string, string) error
BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error
RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error
AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error
EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error)
}
type ServiceConfig struct {
Issuer string
Audience string
StreamID string
ManagementTokenURL string
ManagementClientID string
ManagementSecret string
HeartbeatInterval time.Duration
StaleAfter time.Duration
HTTPClient *http.Client
}
type Service struct {
repository StateRepository
config ServiceConfig
client *http.Client
clock func() time.Time
tokenMu sync.Mutex
token string
tokenUntil time.Time
metrics *Metrics
}
func (s *Service) SetMetrics(metrics *Metrics) { s.metrics = metrics }
func NewService(repository StateRepository, config ServiceConfig) (*Service, error) {
if repository == nil || config.Issuer == "" || config.Audience == "" || config.StreamID == "" ||
config.ManagementTokenURL == "" || config.ManagementClientID == "" || config.ManagementSecret == "" {
return nil, errors.New("SSF heartbeat configuration is incomplete")
}
if config.HeartbeatInterval <= 0 || config.StaleAfter < 2*config.HeartbeatInterval {
return nil, errors.New("SSF heartbeat timing is invalid")
}
client := config.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DisableCompression = true
client = &http.Client{Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
}
return &Service{repository: repository, config: config, client: client, clock: time.Now}, nil
}
func (s *Service) Start(ctx context.Context) error {
if err := s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID); err != nil {
return err
}
go s.run(ctx)
return nil
}
func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
result, err := s.repository.EvaluateOIDCSecurityEvent(
ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject,
identity.IssuedAt, s.clock().UTC(), s.config.StaleAfter,
)
if err != nil {
return auth.OIDCSecurityEventEvaluation{}, err
}
if result.Revoked && s.metrics != nil {
s.metrics.ObserveWatermarkRejection()
}
return auth.OIDCSecurityEventEvaluation{Revoked: result.Revoked, RequireIntrospection: result.RequireIntrospection}, nil
}
func (s *Service) run(ctx context.Context) {
s.sendVerification(ctx)
ticker := time.NewTicker(s.config.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sendVerification(ctx)
}
}
}
func (s *Service) sendVerification(ctx context.Context) {
outcome := "failed"
defer func() {
if s.metrics != nil {
s.metrics.ObserveHeartbeat(outcome)
}
}()
if err := s.repository.AdvanceSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.clock().UTC(), s.config.StaleAfter); err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_advance_failed")
}
state, err := randomVerificationState()
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_generation_failed")
return
}
hash := sha256.Sum256([]byte(state))
now := s.clock().UTC()
if err := s.repository.BeginSecurityEventVerification(ctx, s.config.Issuer, s.config.Audience, hash[:], now); err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_persistence_failed")
return
}
token, err := s.managementToken(ctx)
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "management_token_failed")
return
}
payload, _ := json.Marshal(map[string]string{"stream_id": s.config.StreamID, "state": state})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.config.Issuer, "/")+"/v1/verify", bytes.NewReader(payload))
if err != nil {
return
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := s.client.Do(request)
if err != nil {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "verification_request_failed")
return
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
if response.StatusCode != http.StatusNoContent {
_ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, fmt.Sprintf("verification_http_%d", response.StatusCode))
return
}
outcome = "accepted"
}
func (s *Service) managementToken(ctx context.Context) (string, error) {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
if s.token != "" && s.clock().Before(s.tokenUntil) {
return s.token, nil
}
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"ssf.stream.read ssf.stream.verify"}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.ManagementTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
request.SetBasicAuth(s.config.ManagementClientID, s.config.ManagementSecret)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := s.client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return "", fmt.Errorf("management token endpoint returned HTTP %d", response.StatusCode)
}
payload, err := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
if err != nil || len(payload) > 64*1024 {
return "", errors.New("management token response is too large")
}
var result struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
if json.Unmarshal(payload, &result) != nil || result.AccessToken == "" || !strings.EqualFold(result.TokenType, "Bearer") {
return "", errors.New("management token response is invalid")
}
if result.ExpiresIn <= 0 {
result.ExpiresIn = 60
}
cacheFor := time.Duration(result.ExpiresIn) * time.Second
if cacheFor > 30*time.Second {
cacheFor -= 30 * time.Second
}
s.token, s.tokenUntil = result.AccessToken, s.clock().Add(cacheFor)
return s.token, nil
}
func randomVerificationState() (string, error) {
payload := make([]byte, 32)
if _, err := rand.Read(payload); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
@@ -0,0 +1,86 @@
package securityevents
import (
"context"
"crypto/sha256"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type fakeStateRepository struct {
mutex sync.Mutex
stateHash []byte
verification chan struct{}
evaluation store.SecurityEventEvaluation
}
func (*fakeStateRepository) EnsureSecurityEventStreamState(context.Context, string, string, string) error {
return nil
}
func (f *fakeStateRepository) BeginSecurityEventVerification(_ context.Context, _, _ string, hash []byte, _ time.Time) error {
f.mutex.Lock()
f.stateHash = append([]byte(nil), hash...)
f.mutex.Unlock()
return nil
}
func (*fakeStateRepository) RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error {
return nil
}
func (*fakeStateRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error {
return nil
}
func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
return f.evaluation, nil
}
func TestServiceRequestsVerificationWithMachineTokenAndPersistsStateFirst(t *testing.T) {
repository := &fakeStateRepository{verification: make(chan struct{}, 1)}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != "gateway-ssf" || secret != "management-secret" {
t.Fatal("machine client authentication is missing")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"management-token","token_type":"Bearer","expires_in":300}`))
case "/ssf/v1/verify":
if r.Header.Get("Authorization") != "Bearer management-token" {
t.Fatal("verification bearer is missing")
}
repository.mutex.Lock()
persisted := len(repository.stateHash) == sha256.Size
repository.mutex.Unlock()
if !persisted {
t.Fatal("verification state was not persisted before request")
}
w.WriteHeader(http.StatusNoContent)
repository.verification <- struct{}{}
}
}))
defer server.Close()
service, err := NewService(repository, ServiceConfig{
Issuer: server.URL + "/ssf", Audience: "urn:easyai:ssf:receiver:app", StreamID: "00000000-0000-4000-8000-000000000001",
ManagementTokenURL: server.URL + "/token", ManagementClientID: "gateway-ssf", ManagementSecret: "management-secret",
HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := service.Start(ctx); err != nil {
t.Fatal(err)
}
select {
case <-repository.verification:
case <-time.After(2 * time.Second):
t.Fatal("verification was not requested")
}
}
@@ -0,0 +1,412 @@
package securityevents
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
SessionRevokedEventType = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
VerificationEventType = "https://schemas.openid.net/secevent/ssf/event-type/verification"
StreamUpdatedEventType = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated"
)
type Event struct {
Issuer string
Audience string
JTI string
TransactionID string
IssuedAt time.Time
EventType string
EventTimestamp time.Time
SubjectIssuer string
Subject string
TenantID string
InitiatingEntity string
StreamID string
State string
StreamStatus string
Reason string
}
type VerifierConfig struct {
TransmitterIssuer string
Audience string
SubjectIssuer string
TenantID string
StreamID string
ClockSkew time.Duration
HTTPClient *http.Client
}
type Verifier struct {
config VerifierConfig
client *http.Client
mu sync.Mutex
keys map[string]*ecdsa.PublicKey
expiresAt time.Time
metrics *Metrics
clock func() time.Time
}
func (v *Verifier) SetMetrics(metrics *Metrics) { v.metrics = metrics }
type transmitterMetadata struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
}
type jwkSet struct {
Keys []struct {
KID string `json:"kid"`
KTY string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
} `json:"keys"`
}
type protocolError struct {
Code string
}
func (e *protocolError) Error() string { return e.Code }
func NewVerifier(config VerifierConfig) (*Verifier, error) {
config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/")
config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/")
if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" {
return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant are required")
}
if _, err := uuid.Parse(config.StreamID); err != nil {
return nil, errors.New("SSF stream id must be a UUID")
}
if config.ClockSkew < 0 || config.ClockSkew > 5*time.Minute {
return nil, errors.New("SSF clock skew is invalid")
}
client := config.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
transport.DisableCompression = true
client = &http.Client{Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
}
return &Verifier{config: config, client: client, keys: make(map[string]*ecdsa.PublicKey), clock: time.Now}, nil
}
func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) {
parser := jwt.NewParser(jwt.WithValidMethods([]string{"ES256"}))
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
if err != nil || unverified == nil || unverified.Header["typ"] != "secevent+jwt" || unverified.Header["alg"] != "ES256" {
return Event{}, &protocolError{Code: "invalid_request"}
}
unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims)
if !ok {
return Event{}, &protocolError{Code: "invalid_request"}
}
if stringClaim(unverifiedClaims, "iss") != v.config.TransmitterIssuer {
return Event{}, &protocolError{Code: "invalid_issuer"}
}
if !claimHasAudience(unverifiedClaims["aud"], v.config.Audience) {
return Event{}, &protocolError{Code: "invalid_audience"}
}
kid, _ := unverified.Header["kid"].(string)
if kid == "" {
return Event{}, &protocolError{Code: "invalid_key"}
}
key, err := v.key(ctx, kid)
if err != nil {
return Event{}, &protocolError{Code: "invalid_key"}
}
token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { return key, nil },
jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(v.config.TransmitterIssuer),
jwt.WithAudience(v.config.Audience), jwt.WithIssuedAt(), jwt.WithLeeway(v.config.ClockSkew))
if err != nil || !token.Valid {
if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
return Event{}, &protocolError{Code: "invalid_key"}
}
return Event{}, &protocolError{Code: "invalid_request"}
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, present := claims["sub"]; present {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, present := claims["exp"]; present {
return Event{}, &protocolError{Code: "invalid_request"}
}
jti := stringClaim(claims, "jti")
if _, err := uuid.Parse(jti); err != nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
issuedAt, err := claims.GetIssuedAt()
if err != nil || issuedAt == nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
events, ok := claims["events"].(map[string]any)
if !ok || len(events) != 1 {
return Event{}, &protocolError{Code: "invalid_request"}
}
event := Event{Issuer: v.config.TransmitterIssuer, Audience: v.config.Audience, JTI: jti, TransactionID: stringClaim(claims, "txn"), IssuedAt: issuedAt.Time}
if payload, present := events[SessionRevokedEventType]; present {
value, ok := payload.(map[string]any)
if !ok || event.TransactionID == "" {
return Event{}, &protocolError{Code: "invalid_request"}
}
if _, err := uuid.Parse(event.TransactionID); err != nil {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"])
if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) ||
!validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
if payload, present := events[VerificationEventType]; present {
value, ok := payload.(map[string]any)
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType = VerificationEventType
if rawState, present := value["state"]; present {
var valid bool
event.State, valid = rawState.(string)
if !valid || len(event.State) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
}
if len(event.State) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
if payload, present := events[StreamUpdatedEventType]; present {
value, ok := payload.(map[string]any)
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
return Event{}, &protocolError{Code: "invalid_request"}
}
event.EventType = StreamUpdatedEventType
event.StreamStatus, event.Reason = stringValue(value["status"]), stringValue(value["reason"])
if event.StreamStatus != "enabled" && event.StreamStatus != "paused" && event.StreamStatus != "disabled" {
return Event{}, &protocolError{Code: "invalid_request"}
}
if len(event.Reason) > 1024 {
return Event{}, &protocolError{Code: "invalid_request"}
}
return event, nil
}
return Event{}, &protocolError{Code: "invalid_request"}
}
func claimHasAudience(raw any, expected string) bool {
switch value := raw.(type) {
case string:
return value == expected
case []string:
for _, audience := range value {
if audience == expected {
return true
}
}
case []any:
for _, item := range value {
if audience, ok := item.(string); ok && audience == expected {
return true
}
}
}
return false
}
func (v *Verifier) now() time.Time {
if v.clock != nil {
return v.clock().UTC()
}
return time.Now().UTC()
}
func (v *Verifier) readSessionSubject(raw any, event *Event) bool {
subject, ok := raw.(map[string]any)
if !ok || stringValue(subject["format"]) != "complex" {
return false
}
user, userOK := subject["user"].(map[string]any)
tenant, tenantOK := subject["tenant"].(map[string]any)
if !userOK || !tenantOK || stringValue(user["format"]) != "iss_sub" || stringValue(tenant["format"]) != "opaque" {
return false
}
event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"])
if event.SubjectIssuer != v.config.SubjectIssuer || event.TenantID != v.config.TenantID {
return false
}
_, err := uuid.Parse(event.Subject)
return err == nil
}
func (v *Verifier) readStreamSubject(raw any, event *Event) bool {
subject, ok := raw.(map[string]any)
if !ok || stringValue(subject["format"]) != "opaque" || stringValue(subject["id"]) != v.config.StreamID {
return false
}
event.StreamID = v.config.StreamID
return true
}
func (v *Verifier) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) {
v.mu.Lock()
if key := v.keys[kid]; key != nil && time.Now().Before(v.expiresAt) {
v.mu.Unlock()
return key, nil
}
v.mu.Unlock()
if err := v.refresh(ctx); err != nil {
if v.metrics != nil {
v.metrics.ObserveJWKSRefreshFailure("ssf")
}
return nil, err
}
v.mu.Lock()
defer v.mu.Unlock()
key := v.keys[kid]
if key == nil {
return nil, errors.New("SSF signing key is unavailable")
}
return key, nil
}
func (v *Verifier) refresh(ctx context.Context) error {
v.mu.Lock()
defer v.mu.Unlock()
configurationURL, err := ssfConfigurationURL(v.config.TransmitterIssuer)
if err != nil {
return err
}
var metadata transmitterMetadata
if err := v.fetchJSON(ctx, configurationURL, &metadata); err != nil {
return err
}
if strings.TrimRight(metadata.Issuer, "/") != v.config.TransmitterIssuer || !sameOrigin(metadata.JWKSURI, v.config.TransmitterIssuer) {
return errors.New("SSF metadata is not bound to configured issuer")
}
var set jwkSet
if err := v.fetchJSON(ctx, metadata.JWKSURI, &set); err != nil {
return err
}
keys := make(map[string]*ecdsa.PublicKey)
for _, item := range set.Keys {
if item.KID == "" || item.KTY != "EC" || item.Alg != "ES256" || item.Crv != "P-256" || item.Use != "sig" {
continue
}
x, xErr := decodeCoordinate(item.X)
y, yErr := decodeCoordinate(item.Y)
if xErr == nil && yErr == nil && elliptic.P256().IsOnCurve(x, y) {
keys[item.KID] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
}
}
if len(keys) == 0 {
return errors.New("SSF JWKS contains no ES256 keys")
}
v.keys, v.expiresAt = keys, time.Now().Add(5*time.Minute)
return nil
}
func (v *Verifier) fetchJSON(ctx context.Context, endpoint string, target any) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
response, err := v.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("SSF endpoint returned HTTP %d", response.StatusCode)
}
payload, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
if err != nil || len(payload) > 1<<20 {
return errors.New("SSF endpoint response is too large")
}
if err := json.Unmarshal(payload, target); err != nil {
return errors.New("SSF endpoint returned invalid JSON")
}
return nil
}
func ssfConfigurationURL(issuer string) (string, error) {
parsed, err := url.Parse(issuer)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", errors.New("SSF issuer is invalid")
}
path := strings.TrimSuffix(parsed.EscapedPath(), "/")
parsed.Path, parsed.RawPath = "/.well-known/ssf-configuration"+path, ""
parsed.RawQuery, parsed.Fragment = "", ""
return parsed.String(), nil
}
func sameOrigin(candidate, issuer string) bool {
candidateURL, candidateErr := url.Parse(candidate)
issuerURL, issuerErr := url.Parse(issuer)
return candidateErr == nil && issuerErr == nil && candidateURL.Scheme == issuerURL.Scheme && strings.EqualFold(candidateURL.Host, issuerURL.Host) && candidateURL.User == nil && candidateURL.Fragment == ""
}
func absoluteHTTPURL(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && parsed.IsAbs() && parsed.Hostname() != "" && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.User == nil && parsed.Fragment == ""
}
func decodeCoordinate(raw string) (*big.Int, error) {
value, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || len(value) != 32 {
return nil, errors.New("invalid EC coordinate")
}
return new(big.Int).SetBytes(value), nil
}
func stringClaim(claims jwt.MapClaims, name string) string { return stringValue(claims[name]) }
func stringValue(value any) string {
result, _ := value.(string)
return result
}
func unixClaim(value any) time.Time {
switch typed := value.(type) {
case float64:
return time.Unix(int64(typed), 0).UTC()
case json.Number:
integer, err := typed.Int64()
if err == nil {
return time.Unix(integer, 0).UTC()
}
}
return time.Time{}
}
func validInitiator(value string) bool {
return value == "admin" || value == "user" || value == "policy" || value == "system"
}