From d155af68aa341c9c983e0cbeca8dd38f83660d24 Mon Sep 17 00:00:00 2001 From: John Tanios Date: Mon, 6 Apr 2026 23:46:24 -0600 Subject: [PATCH 1/9] feat: support `risk-level-change` events --- .gitignore | 4 ++- cmd/ssf-forwarder/main.go | 1 + docker-compose.yml | 2 +- internal/caepext/risklevelchange.go | 49 +++++++++++++++++++++++++++++ internal/sink/log.go | 27 ++++++++++------ 5 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 internal/caepext/risklevelchange.go 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/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/docker-compose.yml b/docker-compose.yml index 93d7b66..5a0b34a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: ssf-forwarder: - image: ghcr.io/twosense/ssf-forwarder:latest + build: . ports: - "8080:8080" volumes: diff --git a/internal/caepext/risklevelchange.go b/internal/caepext/risklevelchange.go new file mode 100644 index 0000000..5eca0f2 --- /dev/null +++ b/internal/caepext/risklevelchange.go @@ -0,0 +1,49 @@ +// 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" +) + +const EventTypeRiskLevelChange event.EventType = "https://schemas.openid.net/secevent/caep/event-type/risk-level-change" + +// RiskLevelChangeEvent represents a CAEP risk-level-change event. +type RiskLevelChangeEvent struct { + event.BaseEvent + payload map[string]any +} + +func (e *RiskLevelChangeEvent) Validate() error { + return nil +} + +func (e *RiskLevelChangeEvent) Payload() interface{} { + return e.payload +} + +func (e *RiskLevelChangeEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(e.payload) +} + +func (e *RiskLevelChangeEvent) UnmarshalJSON(data []byte) error { + e.SetType(EventTypeRiskLevelChange) + + return json.Unmarshal(data, &e.payload) +} + +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/sink/log.go b/internal/sink/log.go index 82751bb..59eff2f 100644 --- a/internal/sink/log.go +++ b/internal/sink/log.go @@ -2,6 +2,8 @@ package sink import ( "context" + "encoding/json" + "fmt" "log/slog" "net/http" "sort" @@ -19,22 +21,21 @@ func NewLogSink(logger *slog.Logger) *LogSink { func (ls *LogSink) Send(_ context.Context, rawToken []byte, _ http.Header) error { claims := extractClaims(string(rawToken)) - args := []any{ + ls.logger.Info("received SET", "issuer", stringClaim(claims, "iss"), "jti", stringClaim(claims, "jti"), - "iat", claims["iat"], - } + ) - if txn := stringClaim(claims, "txn"); txn != "" { - args = append(args, "txn", txn) + if events, ok := claims["events"].(map[string]any); ok { + for et, data := range events { + fmt.Printf(" event: %s\n claims:\n%s\n", et, prettyJSON(data, " ")) + } } - if types := eventTypes(claims); len(types) > 0 { - args = append(args, "event_types", types) + if subID, ok := claims["sub_id"]; ok { + fmt.Printf(" subject:\n%s\n", prettyJSON(subID, " ")) } - ls.logger.Info("received SET", args...) - return nil } @@ -46,6 +47,14 @@ func stringClaim(claims map[string]any, key string) string { return v } +func prettyJSON(v any, prefix string) string { + b, err := json.MarshalIndent(v, prefix, " ") + if err != nil { + return fmt.Sprintf("%s%v", prefix, v) + } + return prefix + string(b) +} + func eventTypes(claims map[string]any) []string { if claims == nil { return nil From b740d6faf1597b8a3f84db8c4a9e629fcde4cc5b Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 7 Apr 2026 11:21:41 -0700 Subject: [PATCH 2/9] refactor: revert changes to log sink pending discussion --- internal/sink/log.go | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/internal/sink/log.go b/internal/sink/log.go index 59eff2f..82751bb 100644 --- a/internal/sink/log.go +++ b/internal/sink/log.go @@ -2,8 +2,6 @@ package sink import ( "context" - "encoding/json" - "fmt" "log/slog" "net/http" "sort" @@ -21,21 +19,22 @@ func NewLogSink(logger *slog.Logger) *LogSink { func (ls *LogSink) Send(_ context.Context, rawToken []byte, _ http.Header) error { claims := extractClaims(string(rawToken)) - ls.logger.Info("received SET", + args := []any{ "issuer", stringClaim(claims, "iss"), "jti", stringClaim(claims, "jti"), - ) + "iat", claims["iat"], + } - if events, ok := claims["events"].(map[string]any); ok { - for et, data := range events { - fmt.Printf(" event: %s\n claims:\n%s\n", et, prettyJSON(data, " ")) - } + if txn := stringClaim(claims, "txn"); txn != "" { + args = append(args, "txn", txn) } - if subID, ok := claims["sub_id"]; ok { - fmt.Printf(" subject:\n%s\n", prettyJSON(subID, " ")) + if types := eventTypes(claims); len(types) > 0 { + args = append(args, "event_types", types) } + ls.logger.Info("received SET", args...) + return nil } @@ -47,14 +46,6 @@ func stringClaim(claims map[string]any, key string) string { return v } -func prettyJSON(v any, prefix string) string { - b, err := json.MarshalIndent(v, prefix, " ") - if err != nil { - return fmt.Sprintf("%s%v", prefix, v) - } - return prefix + string(b) -} - func eventTypes(claims map[string]any) []string { if claims == nil { return nil From d248d2d786d1d4cc58430db6b7ee54d1f1033d9b Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 7 Apr 2026 11:25:20 -0700 Subject: [PATCH 3/9] test: add E2E test for risk-level-change event type --- test/e2e/e2e_test.go | 116 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 96 insertions(+), 20 deletions(-) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index f13016b..f179db4 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,27 @@ 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", + "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 +370,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 +389,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 +427,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 +462,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) + } +} From 2e1382c38da29ee9670bccdf2331055195022db4 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 7 Apr 2026 11:30:53 -0700 Subject: [PATCH 4/9] chore: minor docker-compose changes --- config.sample.yaml => config.example.yaml | 0 docker-compose.yml | 3 +++ docs/deployment/docker.md | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) rename config.sample.yaml => config.example.yaml (100%) 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 5a0b34a..8196159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,8 @@ services: ssf-forwarder: + # 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" 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: From 44f119a5b657fdeb38525dc650921e3c2277b476 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 7 Apr 2026 11:32:28 -0700 Subject: [PATCH 5/9] chore: run goimports --- internal/caepext/risklevelchange.go | 2 +- internal/sink/webhook_test.go | 12 ++++++------ test/e2e/e2e_test.go | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/caepext/risklevelchange.go b/internal/caepext/risklevelchange.go index 5eca0f2..87fb5cd 100644 --- a/internal/caepext/risklevelchange.go +++ b/internal/caepext/risklevelchange.go @@ -20,7 +20,7 @@ func (e *RiskLevelChangeEvent) Validate() error { return nil } -func (e *RiskLevelChangeEvent) Payload() interface{} { +func (e *RiskLevelChangeEvent) Payload() any { return e.payload } 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 f179db4..3271fc1 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -257,8 +257,8 @@ func (ft *fakeTransmitter) signRiskLevelChangeSET(t *testing.T) string { "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", + "current_level": "medium", + "previous_level": "low", "event_timestamp": time.Now().UnixMilli(), }, }, From bdabb9bbb66d59cb5bac8b14e3d8cd81734a3049 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 7 Apr 2026 11:39:10 -0700 Subject: [PATCH 6/9] feat: add validation to risk-level-change events --- internal/caepext/risklevelchange.go | 23 +++++++ internal/caepext/risklevelchange_test.go | 80 ++++++++++++++++++++++++ test/e2e/e2e_test.go | 5 +- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 internal/caepext/risklevelchange_test.go diff --git a/internal/caepext/risklevelchange.go b/internal/caepext/risklevelchange.go index 87fb5cd..6c161eb 100644 --- a/internal/caepext/risklevelchange.go +++ b/internal/caepext/risklevelchange.go @@ -4,12 +4,17 @@ package caepext import ( "encoding/json" + "fmt" "github.com/sgnl-ai/caep.dev/secevent/pkg/event" ) const EventTypeRiskLevelChange event.EventType = "https://schemas.openid.net/secevent/caep/event-type/risk-level-change" +// validRiskLevels are the permitted values for current_level and previous_level +// per Section 3.8.1 of the CAEP specification. +var validRiskLevels = map[string]bool{"LOW": true, "MEDIUM": true, "HIGH": true} + // RiskLevelChangeEvent represents a CAEP risk-level-change event. type RiskLevelChangeEvent struct { event.BaseEvent @@ -17,6 +22,24 @@ type RiskLevelChangeEvent struct { } func (e *RiskLevelChangeEvent) Validate() error { + if _, ok := e.payload["principal"].(string); !ok { + return fmt.Errorf("risk-level-change: missing required claim: principal") + } + + currentLevel, ok := e.payload["current_level"].(string) + if !ok { + return fmt.Errorf("risk-level-change: missing required claim: current_level") + } + if !validRiskLevels[currentLevel] { + return fmt.Errorf("risk-level-change: current_level must be LOW, MEDIUM, or HIGH; got %q", currentLevel) + } + + if prevLevel, ok := e.payload["previous_level"].(string); ok { + if !validRiskLevels[prevLevel] { + return fmt.Errorf("risk-level-change: previous_level must be LOW, MEDIUM, or HIGH; got %q", prevLevel) + } + } + return nil } diff --git a/internal/caepext/risklevelchange_test.go b/internal/caepext/risklevelchange_test.go new file mode 100644 index 0000000..12107fe --- /dev/null +++ b/internal/caepext/risklevelchange_test.go @@ -0,0 +1,80 @@ +package caepext + +import ( + "encoding/json" + "testing" +) + +func TestRiskLevelChangeValidate(t *testing.T) { + tests := []struct { + name string + payload map[string]any + wantErr bool + }{ + { + name: "valid minimal", + payload: map[string]any{ + "current_level": "LOW", + "principal": "USER", + }, + }, + { + name: "valid with optional fields", + payload: map[string]any{ + "current_level": "HIGH", + "previous_level": "MEDIUM", + "principal": "DEVICE", + "risk_reason": "PASSWORD_FOUND_IN_DATA_BREACH", + }, + }, + { + name: "missing principal", + payload: map[string]any{"current_level": "LOW"}, + wantErr: true, + }, + { + name: "missing current_level", + payload: map[string]any{"principal": "USER"}, + wantErr: true, + }, + { + name: "current_level invalid", + payload: map[string]any{"current_level": "medium", "principal": "USER"}, + wantErr: true, + }, + { + name: "previous_level invalid", + payload: map[string]any{"current_level": "LOW", "previous_level": "high", "principal": "USER"}, + wantErr: true, + }, + { + name: "principal not a string", + payload: map[string]any{"current_level": "LOW", "principal": 42}, + wantErr: true, + }, + { + name: "current_level not a string", + payload: map[string]any{"current_level": 1, "principal": "USER"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.payload) + if err != nil { + t.Fatalf("marshaling payload: %v", err) + } + + var e RiskLevelChangeEvent + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("unmarshaling event: %v", err) + } + + err = e.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 3271fc1..2a6b86f 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -257,8 +257,9 @@ func (ft *fakeTransmitter) signRiskLevelChangeSET(t *testing.T) string { "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", + "current_level": "MEDIUM", + "previous_level": "LOW", + "principal": "USER", "event_timestamp": time.Now().UnixMilli(), }, }, From 3b5d804e18da57e25486e78a9ebfe5e1b0e5ba83 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 7 Apr 2026 12:10:05 -0700 Subject: [PATCH 7/9] feat: add support for session-established and session-presented CAEP events --- internal/caepext/sessionestablished.go | 48 ++++++++++++++ internal/caepext/sessionestablished_test.go | 72 +++++++++++++++++++++ internal/caepext/sessionpresented.go | 48 ++++++++++++++ internal/caepext/sessionpresented_test.go | 70 ++++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 internal/caepext/sessionestablished.go create mode 100644 internal/caepext/sessionestablished_test.go create mode 100644 internal/caepext/sessionpresented.go create mode 100644 internal/caepext/sessionpresented_test.go diff --git a/internal/caepext/sessionestablished.go b/internal/caepext/sessionestablished.go new file mode 100644 index 0000000..03fec2e --- /dev/null +++ b/internal/caepext/sessionestablished.go @@ -0,0 +1,48 @@ +package caepext + +import ( + "encoding/json" + + "github.com/sgnl-ai/caep.dev/secevent/pkg/event" +) + +const EventTypeSessionEstablished event.EventType = "https://schemas.openid.net/secevent/caep/event-type/session-established" + +// SessionEstablishedEvent represents a CAEP session-established event. +// All event-specific claims (fp_ua, acr, amr, ext_id) are optional per the spec. +type SessionEstablishedEvent struct { + event.BaseEvent + payload map[string]any +} + +func (e *SessionEstablishedEvent) Validate() error { + return nil +} + +func (e *SessionEstablishedEvent) Payload() any { + return e.payload +} + +func (e *SessionEstablishedEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(e.payload) +} + +func (e *SessionEstablishedEvent) UnmarshalJSON(data []byte) error { + e.SetType(EventTypeSessionEstablished) + + return json.Unmarshal(data, &e.payload) +} + +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..b33b1f5 --- /dev/null +++ b/internal/caepext/sessionestablished_test.go @@ -0,0 +1,72 @@ +package caepext + +import ( + "encoding/json" + "testing" +) + +func TestSessionEstablishedParse(t *testing.T) { + tests := []struct { + name string + payload map[string]any + }{ + { + name: "empty payload", + payload: map[string]any{}, + }, + { + name: "all optional fields", + payload: map[string]any{ + "fp_ua": "abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611", + "acr": "AAL2", + "amr": []any{"otp"}, + "ext_id": "12345", + "event_timestamp": float64(1615304991), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.payload) + if err != nil { + t.Fatalf("marshaling payload: %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) + } + + roundtripped, err := json.Marshal(&e) + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + var got, want map[string]any + json.Unmarshal(roundtripped, &got) + json.Unmarshal(data, &want) + if len(got) != len(want) { + t.Errorf("roundtrip field count: got %d, want %d", len(got), len(want)) + } + }) + } +} + +func TestSessionEstablishedRegistered(t *testing.T) { + data, _ := json.Marshal(map[string]any{"event_timestamp": float64(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..568725d --- /dev/null +++ b/internal/caepext/sessionpresented.go @@ -0,0 +1,48 @@ +package caepext + +import ( + "encoding/json" + + "github.com/sgnl-ai/caep.dev/secevent/pkg/event" +) + +const EventTypeSessionPresented event.EventType = "https://schemas.openid.net/secevent/caep/event-type/session-presented" + +// SessionPresentedEvent represents a CAEP session-presented event. +// All event-specific claims (fp_ua, ext_id) are optional per the spec. +type SessionPresentedEvent struct { + event.BaseEvent + payload map[string]any +} + +func (e *SessionPresentedEvent) Validate() error { + return nil +} + +func (e *SessionPresentedEvent) Payload() any { + return e.payload +} + +func (e *SessionPresentedEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(e.payload) +} + +func (e *SessionPresentedEvent) UnmarshalJSON(data []byte) error { + e.SetType(EventTypeSessionPresented) + + return json.Unmarshal(data, &e.payload) +} + +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..c5cb195 --- /dev/null +++ b/internal/caepext/sessionpresented_test.go @@ -0,0 +1,70 @@ +package caepext + +import ( + "encoding/json" + "testing" +) + +func TestSessionPresentedParse(t *testing.T) { + tests := []struct { + name string + payload map[string]any + }{ + { + name: "empty payload", + payload: map[string]any{}, + }, + { + name: "all optional fields", + payload: map[string]any{ + "fp_ua": "abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611", + "ext_id": "12345", + "event_timestamp": float64(1615304991), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.payload) + if err != nil { + t.Fatalf("marshaling payload: %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) + } + + roundtripped, err := json.Marshal(&e) + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + var got, want map[string]any + json.Unmarshal(roundtripped, &got) + json.Unmarshal(data, &want) + if len(got) != len(want) { + t.Errorf("roundtrip field count: got %d, want %d", len(got), len(want)) + } + }) + } +} + +func TestSessionPresentedRegistered(t *testing.T) { + data, _ := json.Marshal(map[string]any{"event_timestamp": float64(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) + } +} From 47df6c5da0fe553fcedbd896d543e4e120f38af7 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 7 Apr 2026 12:15:15 -0700 Subject: [PATCH 8/9] docs: add supported events section --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 From 01d7f2764967e953d2242b08d5761320a56c677b Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 7 Apr 2026 12:38:10 -0700 Subject: [PATCH 9/9] refactor: switch base type for CAEP events to caep.BaseCAEPEvent --- internal/caepext/risklevelchange.go | 100 +++++++++++---- internal/caepext/risklevelchange_test.go | 128 ++++++++++++++------ internal/caepext/sessionestablished.go | 49 ++++++-- internal/caepext/sessionestablished_test.go | 51 +++++--- internal/caepext/sessionpresented.go | 47 +++++-- internal/caepext/sessionpresented_test.go | 46 ++++--- 6 files changed, 306 insertions(+), 115 deletions(-) diff --git a/internal/caepext/risklevelchange.go b/internal/caepext/risklevelchange.go index 6c161eb..3993e99 100644 --- a/internal/caepext/risklevelchange.go +++ b/internal/caepext/risklevelchange.go @@ -4,64 +4,120 @@ package caepext import ( "encoding/json" - "fmt" "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" -// validRiskLevels are the permitted values for current_level and previous_level -// per Section 3.8.1 of the CAEP specification. -var validRiskLevels = map[string]bool{"LOW": true, "MEDIUM": true, "HIGH": true} +// 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 { - event.BaseEvent - payload map[string]any + caep.BaseCAEPEvent + RiskLevelChangePayload +} + +var validRiskLevels = map[RiskLevel]bool{ + RiskLevelLow: true, + RiskLevelMedium: true, + RiskLevelHigh: true, } func (e *RiskLevelChangeEvent) Validate() error { - if _, ok := e.payload["principal"].(string); !ok { - return fmt.Errorf("risk-level-change: missing required claim: principal") + if err := e.ValidateMetadata(); err != nil { + return err } - currentLevel, ok := e.payload["current_level"].(string) - if !ok { - return fmt.Errorf("risk-level-change: missing required claim: current_level") + if e.Principal == "" { + return event.NewError(event.ErrCodeMissingField, "missing required claim: principal", "principal", "") } - if !validRiskLevels[currentLevel] { - return fmt.Errorf("risk-level-change: current_level must be LOW, MEDIUM, or HIGH; got %q", currentLevel) + + if e.CurrentLevel == "" { + return event.NewError(event.ErrCodeMissingField, "missing required claim: current_level", "current_level", "") } - if prevLevel, ok := e.payload["previous_level"].(string); ok { - if !validRiskLevels[prevLevel] { - return fmt.Errorf("risk-level-change: previous_level must be LOW, MEDIUM, or HIGH; got %q", prevLevel) - } + 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 { - return e.payload + 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) + 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 json.Unmarshal(data, &e.payload) + 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 nil, event.NewError(event.ErrCodeParseError, "failed to parse risk-level-change event", "", err.Error()) } return &e, nil diff --git a/internal/caepext/risklevelchange_test.go b/internal/caepext/risklevelchange_test.go index 12107fe..747163e 100644 --- a/internal/caepext/risklevelchange_test.go +++ b/internal/caepext/risklevelchange_test.go @@ -3,78 +3,134 @@ 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 - payload map[string]any + event RiskLevelChangeEvent wantErr bool }{ { - name: "valid minimal", - payload: map[string]any{ - "current_level": "LOW", - "principal": "USER", - }, + name: "valid minimal", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: RiskLevelLow, Principal: PrincipalUser}}, }, { name: "valid with optional fields", - payload: map[string]any{ - "current_level": "HIGH", - "previous_level": "MEDIUM", - "principal": "DEVICE", - "risk_reason": "PASSWORD_FOUND_IN_DATA_BREACH", - }, + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{ + CurrentLevel: RiskLevelHigh, + PreviousLevel: &prevLow, + Principal: PrincipalDevice, + RiskReason: "PASSWORD_FOUND_IN_DATA_BREACH", + }}, }, { name: "missing principal", - payload: map[string]any{"current_level": "LOW"}, + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: RiskLevelLow}}, wantErr: true, }, { name: "missing current_level", - payload: map[string]any{"principal": "USER"}, - wantErr: true, - }, - { - name: "current_level invalid", - payload: map[string]any{"current_level": "medium", "principal": "USER"}, + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{Principal: PrincipalUser}}, wantErr: true, }, { - name: "previous_level invalid", - payload: map[string]any{"current_level": "LOW", "previous_level": "high", "principal": "USER"}, + name: "invalid current_level", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{CurrentLevel: "medium", Principal: PrincipalUser}}, wantErr: true, }, { - name: "principal not a string", - payload: map[string]any{"current_level": "LOW", "principal": 42}, + name: "invalid previous_level", + event: RiskLevelChangeEvent{RiskLevelChangePayload: RiskLevelChangePayload{ + CurrentLevel: RiskLevelLow, + PreviousLevel: &prevInvalid, + Principal: PrincipalUser, + }}, wantErr: true, }, { - name: "current_level not a string", - payload: map[string]any{"current_level": 1, "principal": "USER"}, + 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) { - data, err := json.Marshal(tt.payload) - if err != nil { - t.Fatalf("marshaling payload: %v", err) - } - - var e RiskLevelChangeEvent - if err := json.Unmarshal(data, &e); err != nil { - t.Fatalf("unmarshaling event: %v", err) - } - - err = e.Validate() + 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 index 03fec2e..4981f54 100644 --- a/internal/caepext/sessionestablished.go +++ b/internal/caepext/sessionestablished.go @@ -4,40 +4,71 @@ 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. -// All event-specific claims (fp_ua, acr, amr, ext_id) are optional per the spec. type SessionEstablishedEvent struct { - event.BaseEvent - payload map[string]any + caep.BaseCAEPEvent + SessionEstablishedPayload } func (e *SessionEstablishedEvent) Validate() error { - return nil + return e.ValidateMetadata() } func (e *SessionEstablishedEvent) Payload() any { - return e.payload + 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) + 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 json.Unmarshal(data, &e.payload) + 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 nil, event.NewError(event.ErrCodeParseError, "failed to parse session-established event", "", err.Error()) } return &e, nil diff --git a/internal/caepext/sessionestablished_test.go b/internal/caepext/sessionestablished_test.go index b33b1f5..40eb4df 100644 --- a/internal/caepext/sessionestablished_test.go +++ b/internal/caepext/sessionestablished_test.go @@ -5,32 +5,32 @@ import ( "testing" ) -func TestSessionEstablishedParse(t *testing.T) { +func TestSessionEstablishedRoundtrip(t *testing.T) { tests := []struct { - name string - payload map[string]any + name string + input map[string]any }{ { - name: "empty payload", - payload: map[string]any{}, + name: "empty", + input: map[string]any{}, }, { name: "all optional fields", - payload: map[string]any{ + input: map[string]any{ "fp_ua": "abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611", "acr": "AAL2", - "amr": []any{"otp"}, + "amr": []any{"otp", "pwd"}, "ext_id": "12345", - "event_timestamp": float64(1615304991), + "event_timestamp": int64(1615304991), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data, err := json.Marshal(tt.payload) + data, err := json.Marshal(tt.input) if err != nil { - t.Fatalf("marshaling payload: %v", err) + t.Fatalf("marshaling input: %v", err) } var e SessionEstablishedEvent @@ -46,22 +46,33 @@ func TestSessionEstablishedParse(t *testing.T) { t.Errorf("Validate() = %v, want nil", err) } - roundtripped, err := json.Marshal(&e) - if err != nil { - t.Fatalf("MarshalJSON: %v", 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) } - var got, want map[string]any - json.Unmarshal(roundtripped, &got) - json.Unmarshal(data, &want) - if len(got) != len(want) { - t.Errorf("roundtrip field count: got %d, want %d", len(got), len(want)) + if extID, ok := tt.input["ext_id"].(string); ok && e.ExtID != extID { + t.Errorf("ExtID = %q, want %q", e.ExtID, extID) } }) } } -func TestSessionEstablishedRegistered(t *testing.T) { - data, _ := json.Marshal(map[string]any{"event_timestamp": float64(1615304991)}) +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) diff --git a/internal/caepext/sessionpresented.go b/internal/caepext/sessionpresented.go index 568725d..70fa978 100644 --- a/internal/caepext/sessionpresented.go +++ b/internal/caepext/sessionpresented.go @@ -4,40 +4,69 @@ 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. -// All event-specific claims (fp_ua, ext_id) are optional per the spec. type SessionPresentedEvent struct { - event.BaseEvent - payload map[string]any + caep.BaseCAEPEvent + SessionPresentedPayload } func (e *SessionPresentedEvent) Validate() error { - return nil + return e.ValidateMetadata() } func (e *SessionPresentedEvent) Payload() any { - return e.payload + 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) + 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 json.Unmarshal(data, &e.payload) + 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 nil, event.NewError(event.ErrCodeParseError, "failed to parse session-presented event", "", err.Error()) } return &e, nil diff --git a/internal/caepext/sessionpresented_test.go b/internal/caepext/sessionpresented_test.go index c5cb195..a29ef22 100644 --- a/internal/caepext/sessionpresented_test.go +++ b/internal/caepext/sessionpresented_test.go @@ -5,30 +5,30 @@ import ( "testing" ) -func TestSessionPresentedParse(t *testing.T) { +func TestSessionPresentedRoundtrip(t *testing.T) { tests := []struct { - name string - payload map[string]any + name string + input map[string]any }{ { - name: "empty payload", - payload: map[string]any{}, + name: "empty", + input: map[string]any{}, }, { name: "all optional fields", - payload: map[string]any{ + input: map[string]any{ "fp_ua": "abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611", "ext_id": "12345", - "event_timestamp": float64(1615304991), + "event_timestamp": int64(1615304991), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data, err := json.Marshal(tt.payload) + data, err := json.Marshal(tt.input) if err != nil { - t.Fatalf("marshaling payload: %v", err) + t.Fatalf("marshaling input: %v", err) } var e SessionPresentedEvent @@ -44,22 +44,30 @@ func TestSessionPresentedParse(t *testing.T) { t.Errorf("Validate() = %v, want nil", err) } - roundtripped, err := json.Marshal(&e) - if err != nil { - t.Fatalf("MarshalJSON: %v", err) + if fp, ok := tt.input["fp_ua"].(string); ok && e.FpUA != fp { + t.Errorf("FpUA = %q, want %q", e.FpUA, fp) } - var got, want map[string]any - json.Unmarshal(roundtripped, &got) - json.Unmarshal(data, &want) - if len(got) != len(want) { - t.Errorf("roundtrip field count: got %d, want %d", len(got), len(want)) + if extID, ok := tt.input["ext_id"].(string); ok && e.ExtID != extID { + t.Errorf("ExtID = %q, want %q", e.ExtID, extID) } }) } } -func TestSessionPresentedRegistered(t *testing.T) { - data, _ := json.Marshal(map[string]any{"event_timestamp": float64(1615304991)}) +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)