diff --git a/.gitignore b/.gitignore index 40c9916..fe235e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ config.yaml -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json + +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 4ef3d21..f1b4a81 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,23 @@ On startup, the service: On shutdown (SIGINT/SIGTERM), the stream is deleted from the transmitter before the process exits. +## Supported Events + +`ssf-forwarder` supports all CAEP event types defined in the [CAEP specification](https://openid.net/specs/openid-caep-1_0.html). + +| Event type | URI | +|---|---| +| Session Revoked | `https://schemas.openid.net/secevent/caep/event-type/session-revoked` | +| Token Claims Change | `https://schemas.openid.net/secevent/caep/event-type/token-claims-change` | +| Credential Change | `https://schemas.openid.net/secevent/caep/event-type/credential-change` | +| Assurance Level Change | `https://schemas.openid.net/secevent/caep/event-type/assurance-level-change` | +| Device Compliance Change | `https://schemas.openid.net/secevent/caep/event-type/device-compliance-change` | +| Session Established | `https://schemas.openid.net/secevent/caep/event-type/session-established` | +| Session Presented | `https://schemas.openid.net/secevent/caep/event-type/session-presented` | +| Risk Level Change | `https://schemas.openid.net/secevent/caep/event-type/risk-level-change` | +| Verification | `https://schemas.openid.net/secevent/ssf/event-type/verification` | +| Stream Updated | `https://schemas.openid.net/secevent/ssf/event-type/stream-updated` | + ## Development ```sh diff --git a/cmd/ssf-forwarder/main.go b/cmd/ssf-forwarder/main.go index 3941c90..83e5cee 100644 --- a/cmd/ssf-forwarder/main.go +++ b/cmd/ssf-forwarder/main.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + _ "github.com/twosense/ssf-forwarder/internal/caepext" // Register custom CAEP event parsers "github.com/twosense/ssf-forwarder/internal/config" "github.com/twosense/ssf-forwarder/internal/handler" ) diff --git a/config.sample.yaml b/config.example.yaml similarity index 100% rename from config.sample.yaml rename to config.example.yaml diff --git a/docker-compose.yml b/docker-compose.yml index 93d7b66..8196159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: ssf-forwarder: - image: ghcr.io/twosense/ssf-forwarder:latest + # In a production environment, you will want to reference the image directly: + # image: ghcr.io/twosense/ssf-forwarder:latest + # For development, you can build the image locally: + build: . ports: - "8080:8080" volumes: diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 1d1aad9..27258d8 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -22,10 +22,10 @@ docker run --rm \ ## Docker Compose -Copy the sample config and fill in your values: +Copy the example config and fill in your values: ```sh -cp config.sample.yaml config.yaml +cp config.example.yaml config.yaml ``` Then start the service: diff --git a/internal/caepext/risklevelchange.go b/internal/caepext/risklevelchange.go new file mode 100644 index 0000000..3993e99 --- /dev/null +++ b/internal/caepext/risklevelchange.go @@ -0,0 +1,128 @@ +// Package caepext registers custom CAEP event type parsers not yet included +// in the upstream caep.dev/secevent library. +package caepext + +import ( + "encoding/json" + + "github.com/sgnl-ai/caep.dev/secevent/pkg/event" + "github.com/sgnl-ai/caep.dev/secevent/pkg/schemes/caep" +) + +const EventTypeRiskLevelChange event.EventType = "https://schemas.openid.net/secevent/caep/event-type/risk-level-change" + +// RiskLevel represents the risk level values defined in Section 3.8.1 of the CAEP specification. +type RiskLevel string + +const ( + RiskLevelLow RiskLevel = "LOW" + RiskLevelMedium RiskLevel = "MEDIUM" + RiskLevelHigh RiskLevel = "HIGH" +) + +// Principal identifies the type of entity involved in a risk event. +// The spec defines well-known values but permits any string. +type Principal string + +const ( + PrincipalUser Principal = "USER" + PrincipalDevice Principal = "DEVICE" + PrincipalSession Principal = "SESSION" + PrincipalTenant Principal = "TENANT" + PrincipalOrgUnit Principal = "ORG_UNIT" + PrincipalGroup Principal = "GROUP" +) + +// RiskLevelChangePayload holds the event-specific claims for a risk-level-change event. +type RiskLevelChangePayload struct { + CurrentLevel RiskLevel `json:"current_level"` + PreviousLevel *RiskLevel `json:"previous_level,omitempty"` + Principal Principal `json:"principal"` + RiskReason string `json:"risk_reason,omitempty"` +} + +// RiskLevelChangeEvent represents a CAEP risk-level-change event. +type RiskLevelChangeEvent struct { + caep.BaseCAEPEvent + RiskLevelChangePayload +} + +var validRiskLevels = map[RiskLevel]bool{ + RiskLevelLow: true, + RiskLevelMedium: true, + RiskLevelHigh: true, +} + +func (e *RiskLevelChangeEvent) Validate() error { + if err := e.ValidateMetadata(); err != nil { + return err + } + + if e.Principal == "" { + return event.NewError(event.ErrCodeMissingField, "missing required claim: principal", "principal", "") + } + + if e.CurrentLevel == "" { + return event.NewError(event.ErrCodeMissingField, "missing required claim: current_level", "current_level", "") + } + + if !validRiskLevels[e.CurrentLevel] { + return event.NewError(event.ErrCodeInvalidValue, "current_level must be LOW, MEDIUM, or HIGH", "current_level", string(e.CurrentLevel)) + } + + if e.PreviousLevel != nil && !validRiskLevels[*e.PreviousLevel] { + return event.NewError(event.ErrCodeInvalidValue, "previous_level must be LOW, MEDIUM, or HIGH", "previous_level", string(*e.PreviousLevel)) + } + + return nil +} + +func (e *RiskLevelChangeEvent) Payload() any { + payload := e.RiskLevelChangePayload + + if e.Metadata != nil { + return struct { + RiskLevelChangePayload + *caep.EventMetadata + }{ + RiskLevelChangePayload: payload, + EventMetadata: e.Metadata, + } + } + + return payload +} + +func (e *RiskLevelChangeEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(e.Payload()) +} + +func (e *RiskLevelChangeEvent) UnmarshalJSON(data []byte) error { + var payload struct { + RiskLevelChangePayload + *caep.EventMetadata + } + + if err := json.Unmarshal(data, &payload); err != nil { + return event.NewError(event.ErrCodeParseError, "failed to parse risk-level-change event data", "", err.Error()) + } + + e.SetType(EventTypeRiskLevelChange) + e.RiskLevelChangePayload = payload.RiskLevelChangePayload + e.Metadata = payload.EventMetadata + + return e.Validate() +} + +func parseRiskLevelChangeEvent(data []byte) (event.Event, error) { + var e RiskLevelChangeEvent + if err := json.Unmarshal(data, &e); err != nil { + return nil, event.NewError(event.ErrCodeParseError, "failed to parse risk-level-change event", "", err.Error()) + } + + return &e, nil +} + +func init() { + event.RegisterEventParser(EventTypeRiskLevelChange, parseRiskLevelChangeEvent) +} diff --git a/internal/caepext/risklevelchange_test.go b/internal/caepext/risklevelchange_test.go new file mode 100644 index 0000000..747163e --- /dev/null +++ b/internal/caepext/risklevelchange_test.go @@ -0,0 +1,136 @@ +package caepext + +import ( + "encoding/json" + "testing" + + "github.com/sgnl-ai/caep.dev/secevent/pkg/schemes/caep" +) + +func TestRiskLevelChangeValidate(t *testing.T) { + prevLow := RiskLevelLow + prevInvalid := RiskLevel("medium") + negTS := int64(-1) + + meta := caep.NewEventMetadata() + meta.EventTimestamp = &negTS + + tests := []struct { + name string + event RiskLevelChangeEvent + wantErr bool + }{ + { + name: "valid minimal", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: RiskLevelLow, Principal: PrincipalUser}}, + }, + { + name: "valid with optional fields", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{ + CurrentLevel: RiskLevelHigh, + PreviousLevel: &prevLow, + Principal: PrincipalDevice, + RiskReason: "PASSWORD_FOUND_IN_DATA_BREACH", + }}, + }, + { + name: "missing principal", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: RiskLevelLow}}, + wantErr: true, + }, + { + name: "missing current_level", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{Principal: PrincipalUser}}, + wantErr: true, + }, + { + name: "invalid current_level", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: "medium", Principal: PrincipalUser}}, + wantErr: true, + }, + { + name: "invalid previous_level", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{ + CurrentLevel: RiskLevelLow, + PreviousLevel: &prevInvalid, + Principal: PrincipalUser, + }}, + wantErr: true, + }, + { + name: "invalid metadata timestamp", + event: func() RiskLevelChangeEvent { + e := RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: RiskLevelLow, Principal: PrincipalUser}} + e.Metadata = meta + return e + }(), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.event.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRiskLevelChangeRoundtrip(t *testing.T) { + data, err := json.Marshal(map[string]any{ + "current_level": "HIGH", + "previous_level": "LOW", + "principal": "DEVICE", + "risk_reason": "SUSPICIOUS_ACTIVITY", + "event_timestamp": int64(1615304991), + }) + if err != nil { + t.Fatalf("marshaling input: %v", err) + } + + var e RiskLevelChangeEvent + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("UnmarshalJSON: %v", err) + } + + if e.Type() != EventTypeRiskLevelChange { + t.Errorf("Type() = %q, want %q", e.Type(), EventTypeRiskLevelChange) + } + if e.CurrentLevel != RiskLevelHigh { + t.Errorf("CurrentLevel = %q, want %q", e.CurrentLevel, RiskLevelHigh) + } + if e.PreviousLevel == nil || *e.PreviousLevel != RiskLevelLow { + t.Errorf("PreviousLevel = %v, want %q", e.PreviousLevel, RiskLevelLow) + } + if e.Principal != PrincipalDevice { + t.Errorf("Principal = %q, want %q", e.Principal, PrincipalDevice) + } + if e.RiskReason != "SUSPICIOUS_ACTIVITY" { + t.Errorf("RiskReason = %q, want %q", e.RiskReason, "SUSPICIOUS_ACTIVITY") + } +} + +func TestRiskLevelChangeUnmarshalValidates(t *testing.T) { + invalid, _ := json.Marshal(map[string]any{"current_level": "medium", "principal": "USER"}) + var e RiskLevelChangeEvent + if err := json.Unmarshal(invalid, &e); err == nil { + t.Error("expected error for invalid current_level, got nil") + } +} + +func TestRiskLevelChangeParser(t *testing.T) { + data, _ := json.Marshal(map[string]any{ + "current_level": "LOW", + "principal": "USER", + }) + + e, err := parseRiskLevelChangeEvent(data) + if err != nil { + t.Fatalf("parseRiskLevelChangeEvent: %v", err) + } + if e.Type() != EventTypeRiskLevelChange { + t.Errorf("Type() = %q, want %q", e.Type(), EventTypeRiskLevelChange) + } +} diff --git a/internal/caepext/sessionestablished.go b/internal/caepext/sessionestablished.go new file mode 100644 index 0000000..4981f54 --- /dev/null +++ b/internal/caepext/sessionestablished.go @@ -0,0 +1,79 @@ +package caepext + +import ( + "encoding/json" + + "github.com/sgnl-ai/caep.dev/secevent/pkg/event" + "github.com/sgnl-ai/caep.dev/secevent/pkg/schemes/caep" +) + +const EventTypeSessionEstablished event.EventType = "https://schemas.openid.net/secevent/caep/event-type/session-established" + +// SessionEstablishedPayload holds the event-specific claims for a session-established event. +// All claims are optional per Section 3.6.1 of the CAEP specification. +type SessionEstablishedPayload struct { + FpUA string `json:"fp_ua,omitempty"` + ACR string `json:"acr,omitempty"` + AMR []string `json:"amr,omitempty"` + ExtID string `json:"ext_id,omitempty"` +} + +// SessionEstablishedEvent represents a CAEP session-established event. +type SessionEstablishedEvent struct { + caep.BaseCAEPEvent + SessionEstablishedPayload +} + +func (e *SessionEstablishedEvent) Validate() error { + return e.ValidateMetadata() +} + +func (e *SessionEstablishedEvent) Payload() any { + payload := e.SessionEstablishedPayload + + if e.Metadata != nil { + return struct { + SessionEstablishedPayload + *caep.EventMetadata + }{ + SessionEstablishedPayload: payload, + EventMetadata: e.Metadata, + } + } + + return payload +} + +func (e *SessionEstablishedEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(e.Payload()) +} + +func (e *SessionEstablishedEvent) UnmarshalJSON(data []byte) error { + var payload struct { + SessionEstablishedPayload + *caep.EventMetadata + } + + if err := json.Unmarshal(data, &payload); err != nil { + return event.NewError(event.ErrCodeParseError, "failed to parse session-established event data", "", err.Error()) + } + + e.SetType(EventTypeSessionEstablished) + e.SessionEstablishedPayload = payload.SessionEstablishedPayload + e.Metadata = payload.EventMetadata + + return e.Validate() +} + +func parseSessionEstablishedEvent(data []byte) (event.Event, error) { + var e SessionEstablishedEvent + if err := json.Unmarshal(data, &e); err != nil { + return nil, event.NewError(event.ErrCodeParseError, "failed to parse session-established event", "", err.Error()) + } + + return &e, nil +} + +func init() { + event.RegisterEventParser(EventTypeSessionEstablished, parseSessionEstablishedEvent) +} diff --git a/internal/caepext/sessionestablished_test.go b/internal/caepext/sessionestablished_test.go new file mode 100644 index 0000000..40eb4df --- /dev/null +++ b/internal/caepext/sessionestablished_test.go @@ -0,0 +1,83 @@ +package caepext + +import ( + "encoding/json" + "testing" +) + +func TestSessionEstablishedRoundtrip(t *testing.T) { + tests := []struct { + name string + input map[string]any + }{ + { + name: "empty", + input: map[string]any{}, + }, + { + name: "all optional fields", + input: map[string]any{ + "fp_ua": "abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611", + "acr": "AAL2", + "amr": []any{"otp", "pwd"}, + "ext_id": "12345", + "event_timestamp": int64(1615304991), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.input) + if err != nil { + t.Fatalf("marshaling input: %v", err) + } + + var e SessionEstablishedEvent + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("UnmarshalJSON: %v", err) + } + + if e.Type() != EventTypeSessionEstablished { + t.Errorf("Type() = %q, want %q", e.Type(), EventTypeSessionEstablished) + } + + if err := e.Validate(); err != nil { + t.Errorf("Validate() = %v, want nil", err) + } + + if fp, ok := tt.input["fp_ua"].(string); ok && e.FpUA != fp { + t.Errorf("FpUA = %q, want %q", e.FpUA, fp) + } + if acr, ok := tt.input["acr"].(string); ok && e.ACR != acr { + t.Errorf("ACR = %q, want %q", e.ACR, acr) + } + if extID, ok := tt.input["ext_id"].(string); ok && e.ExtID != extID { + t.Errorf("ExtID = %q, want %q", e.ExtID, extID) + } + }) + } +} + +func TestSessionEstablishedInvalidMetadata(t *testing.T) { + data, _ := json.Marshal(map[string]any{"event_timestamp": int64(-1)}) + var e SessionEstablishedEvent + if err := json.Unmarshal(data, &e); err == nil { + t.Error("expected error for negative event_timestamp, got nil") + } +} + +func TestSessionEstablishedParser(t *testing.T) { + data, _ := json.Marshal(map[string]any{ + "fp_ua": "abc123", + "event_timestamp": int64(1615304991), + }) + + e, err := parseSessionEstablishedEvent(data) + if err != nil { + t.Fatalf("parseSessionEstablishedEvent: %v", err) + } + if e.Type() != EventTypeSessionEstablished { + t.Errorf("Type() = %q, want %q", e.Type(), EventTypeSessionEstablished) + } +} diff --git a/internal/caepext/sessionpresented.go b/internal/caepext/sessionpresented.go new file mode 100644 index 0000000..70fa978 --- /dev/null +++ b/internal/caepext/sessionpresented.go @@ -0,0 +1,77 @@ +package caepext + +import ( + "encoding/json" + + "github.com/sgnl-ai/caep.dev/secevent/pkg/event" + "github.com/sgnl-ai/caep.dev/secevent/pkg/schemes/caep" +) + +const EventTypeSessionPresented event.EventType = "https://schemas.openid.net/secevent/caep/event-type/session-presented" + +// SessionPresentedPayload holds the event-specific claims for a session-presented event. +// All claims are optional per Section 3.7.1 of the CAEP specification. +type SessionPresentedPayload struct { + FpUA string `json:"fp_ua,omitempty"` + ExtID string `json:"ext_id,omitempty"` +} + +// SessionPresentedEvent represents a CAEP session-presented event. +type SessionPresentedEvent struct { + caep.BaseCAEPEvent + SessionPresentedPayload +} + +func (e *SessionPresentedEvent) Validate() error { + return e.ValidateMetadata() +} + +func (e *SessionPresentedEvent) Payload() any { + payload := e.SessionPresentedPayload + + if e.Metadata != nil { + return struct { + SessionPresentedPayload + *caep.EventMetadata + }{ + SessionPresentedPayload: payload, + EventMetadata: e.Metadata, + } + } + + return payload +} + +func (e *SessionPresentedEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(e.Payload()) +} + +func (e *SessionPresentedEvent) UnmarshalJSON(data []byte) error { + var payload struct { + SessionPresentedPayload + *caep.EventMetadata + } + + if err := json.Unmarshal(data, &payload); err != nil { + return event.NewError(event.ErrCodeParseError, "failed to parse session-presented event data", "", err.Error()) + } + + e.SetType(EventTypeSessionPresented) + e.SessionPresentedPayload = payload.SessionPresentedPayload + e.Metadata = payload.EventMetadata + + return e.Validate() +} + +func parseSessionPresentedEvent(data []byte) (event.Event, error) { + var e SessionPresentedEvent + if err := json.Unmarshal(data, &e); err != nil { + return nil, event.NewError(event.ErrCodeParseError, "failed to parse session-presented event", "", err.Error()) + } + + return &e, nil +} + +func init() { + event.RegisterEventParser(EventTypeSessionPresented, parseSessionPresentedEvent) +} diff --git a/internal/caepext/sessionpresented_test.go b/internal/caepext/sessionpresented_test.go new file mode 100644 index 0000000..a29ef22 --- /dev/null +++ b/internal/caepext/sessionpresented_test.go @@ -0,0 +1,78 @@ +package caepext + +import ( + "encoding/json" + "testing" +) + +func TestSessionPresentedRoundtrip(t *testing.T) { + tests := []struct { + name string + input map[string]any + }{ + { + name: "empty", + input: map[string]any{}, + }, + { + name: "all optional fields", + input: map[string]any{ + "fp_ua": "abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611", + "ext_id": "12345", + "event_timestamp": int64(1615304991), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.input) + if err != nil { + t.Fatalf("marshaling input: %v", err) + } + + var e SessionPresentedEvent + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("UnmarshalJSON: %v", err) + } + + if e.Type() != EventTypeSessionPresented { + t.Errorf("Type() = %q, want %q", e.Type(), EventTypeSessionPresented) + } + + if err := e.Validate(); err != nil { + t.Errorf("Validate() = %v, want nil", err) + } + + if fp, ok := tt.input["fp_ua"].(string); ok && e.FpUA != fp { + t.Errorf("FpUA = %q, want %q", e.FpUA, fp) + } + if extID, ok := tt.input["ext_id"].(string); ok && e.ExtID != extID { + t.Errorf("ExtID = %q, want %q", e.ExtID, extID) + } + }) + } +} + +func TestSessionPresentedInvalidMetadata(t *testing.T) { + data, _ := json.Marshal(map[string]any{"event_timestamp": int64(-1)}) + var e SessionPresentedEvent + if err := json.Unmarshal(data, &e); err == nil { + t.Error("expected error for negative event_timestamp, got nil") + } +} + +func TestSessionPresentedParser(t *testing.T) { + data, _ := json.Marshal(map[string]any{ + "fp_ua": "abc123", + "event_timestamp": int64(1615304991), + }) + + e, err := parseSessionPresentedEvent(data) + if err != nil { + t.Fatalf("parseSessionPresentedEvent: %v", err) + } + if e.Type() != EventTypeSessionPresented { + t.Errorf("Type() = %q, want %q", e.Type(), EventTypeSessionPresented) + } +} diff --git a/internal/sink/webhook_test.go b/internal/sink/webhook_test.go index a2a6c2a..9e756d7 100644 --- a/internal/sink/webhook_test.go +++ b/internal/sink/webhook_test.go @@ -319,12 +319,12 @@ func (t *sequencedTransport) RoundTrip(_ *http.Request) (*http.Response, error) func TestWebhookSink_Retry_Boundaries(t *testing.T) { tests := []struct { - name string - responses []int - maxRetries int - wantErr bool - wantReqs int - wantSleeps int + name string + responses []int + maxRetries int + wantErr bool + wantReqs int + wantSleeps int }{ { name: "succeeds on last allowed attempt", diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index f13016b..2a6b86f 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -205,10 +205,8 @@ func (ft *fakeTransmitter) getPushURL() string { return ft.pushURL } -// signSET returns a signed SSF verification SET as a JWT string. -// The SET is valid for the forwarder to parse: correct issuer, a registered -// event type, and a subject. -func (ft *fakeTransmitter) signSET(t *testing.T) string { +// signJWT signs a JWT with the given payload and returns the token string. +func (ft *fakeTransmitter) signJWT(t *testing.T, payload map[string]interface{}) string { t.Helper() headerJSON, _ := json.Marshal(map[string]interface{}{ @@ -216,7 +214,27 @@ func (ft *fakeTransmitter) signSET(t *testing.T) string { "kid": ft.kid, "typ": "JWT", }) - payloadJSON, _ := json.Marshal(map[string]interface{}{ + payloadJSON, _ := json.Marshal(payload) + + h := base64.RawURLEncoding.EncodeToString(headerJSON) + p := base64.RawURLEncoding.EncodeToString(payloadJSON) + signingInput := h + "." + p + + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, ft.privateKey, crypto.SHA256, digest[:]) + if err != nil { + t.Fatalf("signing JWT: %v", err) + } + + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// signSET returns a signed SSF verification SET as a JWT string. +// The SET is valid for the forwarder to parse: correct issuer, a registered +// event type, and a subject. +func (ft *fakeTransmitter) signSET(t *testing.T) string { + t.Helper() + return ft.signJWT(t, map[string]interface{}{ "iss": ft.issuer(), "jti": fmt.Sprintf("e2e-%d", time.Now().UnixNano()), "iat": time.Now().Unix(), @@ -228,18 +246,28 @@ func (ft *fakeTransmitter) signSET(t *testing.T) string { "email": "test@example.com", }, }) +} - h := base64.RawURLEncoding.EncodeToString(headerJSON) - p := base64.RawURLEncoding.EncodeToString(payloadJSON) - signingInput := h + "." + p - - digest := sha256.Sum256([]byte(signingInput)) - sig, err := rsa.SignPKCS1v15(rand.Reader, ft.privateKey, crypto.SHA256, digest[:]) - if err != nil { - t.Fatalf("signing SET: %v", err) - } - - return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) +// signRiskLevelChangeSET returns a signed CAEP risk-level-change SET as a JWT string. +func (ft *fakeTransmitter) signRiskLevelChangeSET(t *testing.T) string { + t.Helper() + return ft.signJWT(t, map[string]interface{}{ + "iss": ft.issuer(), + "jti": fmt.Sprintf("e2e-%d", time.Now().UnixNano()), + "iat": time.Now().Unix(), + "events": map[string]interface{}{ + "https://schemas.openid.net/secevent/caep/event-type/risk-level-change": map[string]interface{}{ + "current_level": "MEDIUM", + "previous_level": "LOW", + "principal": "USER", + "event_timestamp": time.Now().UnixMilli(), + }, + }, + "sub_id": map[string]interface{}{ + "format": "email", + "email": "test@example.com", + }, + }) } // testSink is an HTTP server that records the raw bodies of all POST requests. @@ -343,9 +371,14 @@ func freePort(t *testing.T) int { // writeConfig writes a forwarder config.yaml to a temp file and returns its path. // The file is world-readable so the Docker container user can read it when mounted. -func writeConfig(t *testing.T, metadataURL, sinkURL, publicURL, listenAddr string) string { +func writeConfig(t *testing.T, metadataURL, sinkURL, publicURL, listenAddr string, eventTypes []string) string { t.Helper() + var eventsBlock strings.Builder + for _, et := range eventTypes { + fmt.Fprintf(&eventsBlock, " - %s\n", et) + } + content := fmt.Sprintf(`receiver: public_url: %q listen_addr: %q @@ -357,12 +390,11 @@ transmitter: type: bearer token: test-token events_requested: - - https://schemas.openid.net/secevent/ssf/event-type/verification - +%s sinks: - type: webhook url: %q -`, publicURL, listenAddr, metadataURL, sinkURL) +`, publicURL, listenAddr, metadataURL, eventsBlock.String(), sinkURL) f, err := os.CreateTemp("", "ssf-forwarder-config-*.yaml") if err != nil { @@ -396,6 +428,7 @@ func TestForwardsSETToWebhookSink(t *testing.T) { sink.server.URL, publicURL, listenAddr, + []string{"https://schemas.openid.net/secevent/ssf/event-type/verification"}, ) startForwarder(t, cfgPath) @@ -430,3 +463,47 @@ func TestForwardsSETToWebhookSink(t *testing.T) { t.Errorf("sink received unexpected token\ngot: %s\nwant: %s", received, token) } } + +func TestForwardsRiskLevelChangeSETToWebhookSink(t *testing.T) { + transmitter := newFakeTransmitter(t) + sink := newTestSink(t) + + port := freePort(t) + listenAddr := fmt.Sprintf("127.0.0.1:%d", port) + publicURL := fmt.Sprintf("http://127.0.0.1:%d", port) + + cfgPath := writeConfig(t, + transmitter.server.URL+"/metadata", + sink.server.URL, + publicURL, + listenAddr, + []string{"https://schemas.openid.net/secevent/caep/event-type/risk-level-change"}, + ) + + startForwarder(t, cfgPath) + + transmitter.waitForRegistration(t, 15*time.Second) + waitForServer(t, publicURL+"/events", 5*time.Second) + + token := transmitter.signRiskLevelChangeSET(t) + + resp, err := http.Post( + transmitter.getPushURL(), + "application/secevent+jwt", + strings.NewReader(token), + ) + if err != nil { + t.Fatalf("pushing SET to forwarder: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("forwarder returned %d, want 202", resp.StatusCode) + } + + received := sink.waitForToken(t, 5*time.Second) + + if received != token { + t.Errorf("sink received unexpected token\ngot: %s\nwant: %s", received, token) + } +}