270 lines
12 KiB
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)
|
|
}
|