easyai-ai-gateway/apps/api/internal/securityevents/handler_test.go

270 lines
12 KiB
Go

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)
}