feat: 接入 SSF 实时会话撤销
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user