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