diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b941c8..afd03e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,5 +6,29 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -Initial development toward `v0.1.0` — the typed RFC 8417 Security Event Token -(SET) claims-set envelope, validation, encoding, and the event-type registry. +## [0.1.0] - 2026-06-21 + +### Added + +- Typed `SET` envelope for the RFC 8417 §2.2 claims set — `iss`, `iat`, `jti`, + `aud`, `sub_id`, `txn`, `toe`, and the required `events` object — with the + `NumericDate` (RFC 7519 seconds-since-epoch) and string-or-array `Audience` + wire types. The `sub_id` Subject Identifier is held as a + `subjectid.SubjectIdentifier` via + [`go-subjectid`](https://github.com/hstern/go-subjectid) (RFC 9493). +- Raw `Events` container over `map[string]json.RawMessage`, preserving each + event payload's bytes so an unrecognized event-type URI round-trips + byte-for-byte (`Raw`, `Len`). +- `Parse` (liberal decode of already-verified, already-base64url-decoded + claims-set bytes; no JOSE), `SET.Validate` (the §2.2 required-claim MUSTs — + `iss`/`iat`/`jti` present and `events` non-empty — as joined typed + `*ValidationError`s wrapping `errors.Is`-matchable sentinels), and + `SET.Encode` (strict required-claim check at the marshal boundary; emits the + claims-set bytes a signer wraps in a JWS, without signing). +- Event-type registry — the `Event` interface, `RegisterEventType` / + `LookupEventType`, and `Events.Typed` — through which event vocabularies + (such as OpenID CAEP and RISC) plug in typed decoders while unknown event + types stay raw. + +[Unreleased]: https://github.com/hstern/go-secevent/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/hstern/go-secevent/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 16b1e2a..0193e13 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ library owns only the claims set: it receives the already-verified, decoded claims-set bytes, parses them into a typed `SET`, and encodes them back. **A SET is not an access token** and must never be treated as an -authorization or authentication assertion (RFC 8417 §4). +authorization or authentication assertion (RFC 8417 §4). `Validate` checks +only that the required claims are present; a validated SET carries no "still +good for auth" semantics. It is not a JWT/JOSE stack. The following are deliberately out of scope and belong in dedicated libraries: @@ -34,18 +36,138 @@ belong in dedicated libraries: Subject Identifiers (`sub_id`, RFC 9493) are handled by [`go-subjectid`](https://github.com/hstern/go-subjectid). -## Status - -**Pre-release (`v0.x`).** Under active development toward `v0.1.0`; the public -API may change within the `v0.x` series per [SemVer](https://semver.org/). -Runtime dependencies: the standard library plus `go-subjectid`. - ## Install ```bash go get github.com/hstern/go-secevent ``` +## Quickstart + +### Parse and inspect typed events + +`Parse` takes the already-verified, already-base64url-decoded claims-set +bytes (a JOSE/transport layer produces them) and decodes liberally. `Validate` +checks the §2.2 required-claim MUSTs. `Events.Typed` decodes a known event +through the registry; an event type this build has not imported stays raw and +round-trips byte-for-byte. + +```go +payload := []byte(`{ + "iss": "https://idp.example.com/", + "iat": 1615305600, + "jti": "set-0001", + "aud": "https://receiver.example.com/", + "events": { + "https://schemas.openid.net/secevent/caep/event-type/session-revoked": { + "event_timestamp": 1615305500 + } + } +}`) + +set, err := secevent.Parse(payload) +if err != nil { + return err +} +if err := set.Validate(); err != nil { + return err // e.g. errors.Is(err, secevent.ErrNoEvents) +} + +for uri := range set.Events.Raw() { + event, ok, err := set.Events.Typed(uri) + switch { + case err != nil: + // a registered decoder rejected the payload + case ok: + // event is the typed value for a registered vocabulary + _ = event.EventTypeURI() + default: + // no decoder registered: the payload stays raw at set.Events.Raw()[uri] + } +} +``` + +### Build and encode a SET + +`Encode` is the strict half of the library's "liberal unmarshal, strict +marshal" contract: it calls `Validate` first and refuses to emit a SET that +is missing a required claim. It does not sign — it emits the claims-set bytes +a signer wraps in a JWS. + +```go +subject, err := subjectid.Parse([]byte( + `{"format":"iss_sub","iss":"https://idp.example.com/","sub":"user-7f3e2a"}`, +)) +if err != nil { + return err +} + +set := &secevent.SET{ + Issuer: "https://idp.example.com/", + IssuedAt: time.Unix(1615305600, 0), + JWTID: "set-0002", + Audience: secevent.Audience{"https://receiver.example.com/"}, + Subject: subject, + Events: secevent.Events{ + "https://schemas.openid.net/secevent/caep/event-type/session-revoked": json.RawMessage(`{"initiating_entity":"policy"}`), + }, +} + +payload, err := set.Encode() +if err != nil { + return err // a required claim was unset +} +// payload is the compact JSON claims set, ready for a JOSE signer. +``` + +Subject Identifiers come from +[`go-subjectid`](https://github.com/hstern/go-subjectid): the `sub_id` claim +is held as a `subjectid.SubjectIdentifier`, parsed and validated there. + +### Register an event type + +An event vocabulary (such as OpenID CAEP or RISC) implements the `Event` +interface for its payload and registers a decoder for its event-type URI, +customarily from an `init` function so a side-effect import wires the whole +vocabulary in. Registration is process-wide and permanent. + +```go +const sessionRevokedURI = "https://schemas.openid.net/secevent/caep/event-type/session-revoked" + +type SessionRevoked struct { + InitiatingEntity string `json:"initiating_entity"` +} + +func (SessionRevoked) EventTypeURI() string { return sessionRevokedURI } + +func init() { + secevent.RegisterEventType(sessionRevokedURI, func(raw json.RawMessage) (secevent.Event, error) { + var e SessionRevoked + if err := json.Unmarshal(raw, &e); err != nil { + return nil, err + } + return e, nil + }) +} + +// Once registered, Events.Typed decodes the member into the concrete type: +// +// event, ok, err := set.Events.Typed(sessionRevokedURI) +// if ok { +// revoked := event.(SessionRevoked) +// _ = revoked.InitiatingEntity +// } +``` + +See the [package examples](https://pkg.go.dev/github.com/hstern/go-secevent#pkg-examples) +for runnable versions of each flow. + +## Status + +**Pre-release (`v0.x`).** Under active development toward `v0.1.0`; the public +API may change within the `v0.x` series per [SemVer](https://semver.org/). +Runtime dependencies: the standard library plus `go-subjectid`. + ## License [Apache-2.0](LICENSE). diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..e5c1def --- /dev/null +++ b/example_test.go @@ -0,0 +1,209 @@ +// Copyright 2026 The go-secevent Authors +// SPDX-License-Identifier: Apache-2.0 + +package secevent_test + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "time" + + secevent "github.com/hstern/go-secevent" + subjectid "github.com/hstern/go-subjectid" +) + +// ExampleParse decodes an already-verified, already-base64url-decoded SET +// claims set, validates the RFC 8417 §2.2 MUSTs, and walks its events — +// leaving an event type this build does not recognize as raw bytes. +func ExampleParse() { + // The bytes a JOSE/transport layer hands over after verifying the JWS and + // base64url-decoding the payload. Parse never sees the compact JWS itself. + // The event-type URI below is one this build has no decoder for, so it + // stays raw — exactly how a forward-compatible consumer treats an event + // vocabulary it has not imported. + payload := []byte(`{ + "iss": "https://idp.example.com/", + "iat": 1615305600, + "jti": "set-0001", + "aud": "https://receiver.example.com/", + "events": { + "https://example.com/secevent/parse-example/event": {"k": "v"} + } + }`) + + set, err := secevent.Parse(payload) + if err != nil { + fmt.Println("parse:", err) + return + } + if err := set.Validate(); err != nil { + fmt.Println("invalid:", err) + return + } + + fmt.Println("issuer:", set.Issuer) + fmt.Println("events:", set.Events.Len()) + + // Walk every event. With no decoder registered for this URI, Typed reports + // (nil, false, nil) and the payload stays reachable as raw bytes via Raw. + for uri := range set.Events.Raw() { + event, ok, err := set.Events.Typed(uri) + switch { + case err != nil: + fmt.Println("decode error:", err) + case ok: + fmt.Printf("typed: %s\n", event.EventTypeURI()) + default: + fmt.Printf("raw: %s = %s\n", uri, set.Events.Raw()[uri]) + } + } + + // Output: + // issuer: https://idp.example.com/ + // events: 1 + // raw: https://example.com/secevent/parse-example/event = {"k": "v"} +} + +// Example_encode builds a SET from typed Go values and encodes it to the JSON +// claims-set payload a signer would wrap in a JWS. Encode enforces the §2.2 +// required-claim MUSTs at the marshal boundary. +func Example_encode() { + subject, err := subjectid.Parse([]byte( + `{"format":"iss_sub","iss":"https://idp.example.com/","sub":"user-7f3e2a"}`, + )) + if err != nil { + fmt.Println("subject:", err) + return + } + + set := &secevent.SET{ + Issuer: "https://idp.example.com/", + IssuedAt: time.Unix(1615305600, 0), + JWTID: "set-0002", + Audience: secevent.Audience{"https://receiver.example.com/"}, + Subject: subject, + Events: secevent.Events{ + "https://schemas.openid.net/secevent/caep/event-type/session-revoked": json.RawMessage(`{"initiating_entity":"policy"}`), + }, + } + + payload, err := set.Encode() + if err != nil { + fmt.Println("encode:", err) + return + } + fmt.Printf("%s\n", payload) + + // Output: + // {"iss":"https://idp.example.com/","iat":1615305600,"jti":"set-0002","aud":"https://receiver.example.com/","sub_id":{"format":"iss_sub","iss":"https://idp.example.com/","sub":"user-7f3e2a"},"events":{"https://schemas.openid.net/secevent/caep/event-type/session-revoked":{"initiating_entity":"policy"}}} +} + +// docExampleEventURI is the event-type URI this example's event vocabulary +// claims. It is deliberately unique to this documentation example so it never +// collides with the CAEP/RISC URIs or other test fixtures registered elsewhere +// in the test binary — the registry has no unregister. +const docExampleEventURI = "https://example.com/secevent/doc-example/event" + +// docExampleEvent is a minimal typed event payload, the kind an event +// vocabulary (CAEP, RISC, …) defines and registers a decoder for. It satisfies +// the secevent.Event interface by reporting its event-type URI. +type docExampleEvent struct { + Reason string `json:"reason"` +} + +func (docExampleEvent) EventTypeURI() string { return docExampleEventURI } + +// Example_registerEventType shows the registry seam end to end: implement the +// Event interface for a vocabulary's payload, register a decoder for its +// event-type URI, then recover the typed value from a parsed SET via Typed. +func Example_registerEventType() { + // An event vocabulary wires its decoder in once, customarily from an init + // function so a side-effect import registers the whole vocabulary. + secevent.RegisterEventType(docExampleEventURI, func(raw json.RawMessage) (secevent.Event, error) { + var e docExampleEvent + if err := json.Unmarshal(raw, &e); err != nil { + return nil, err + } + return e, nil + }) + + set, err := secevent.Parse([]byte(`{ + "iss": "https://idp.example.com/", + "iat": 1615305600, + "jti": "set-0003", + "events": { + "https://example.com/secevent/doc-example/event": {"reason": "policy"} + } + }`)) + if err != nil { + fmt.Println("parse:", err) + return + } + + event, ok, err := set.Events.Typed(docExampleEventURI) + if err != nil { + fmt.Println("typed:", err) + return + } + if !ok { + fmt.Println("no decoder registered") + return + } + + fmt.Printf("decoded %T: reason=%q\n", event, event.(docExampleEvent).Reason) + + // Output: + // decoded secevent_test.docExampleEvent: reason="policy" +} + +// Example_registerEventType_unknown contrasts the registered case above: an +// event-type URI with no registered decoder is not an error. Typed reports +// (nil, false, nil) and the payload stays reachable as raw bytes, the +// byte-stable forward-compatibility contract unknown event types depend on. +func Example_registerEventType_unknown() { + set, err := secevent.Parse([]byte(`{ + "iss": "https://idp.example.com/", + "iat": 1615305600, + "jti": "set-0004", + "events": { + "https://example.com/secevent/never-registered/event": {"k": "v"}, + "https://example.com/secevent/also-unknown/event": {"k": "w"} + } + }`)) + if err != nil { + fmt.Println("parse:", err) + return + } + + // Sort the URIs for deterministic output; map iteration order is random. + for _, uri := range slices.Sorted(maps.Keys(set.Events.Raw())) { + _, ok, err := set.Events.Typed(uri) + fmt.Printf("%s ok=%t err=%v raw=%s\n", uri, ok, err, set.Events.Raw()[uri]) + } + + // Output: + // https://example.com/secevent/also-unknown/event ok=false err= raw={"k": "w"} + // https://example.com/secevent/never-registered/event ok=false err= raw={"k": "v"} +} + +// Example_validateBoundary shows the §4 boundary in error form: Validate +// reports the missing §2.2 MUSTs and only those, matchable with errors.Is — +// it never asserts anything about authentication or authorization. +func Example_validateBoundary() { + set, _ := secevent.Parse([]byte(`{"iat":1615305600,"events":{}}`)) + err := set.Validate() + + fmt.Println("missing iss: ", errors.Is(err, secevent.ErrMissingIssuer)) + fmt.Println("missing jti: ", errors.Is(err, secevent.ErrMissingJWTID)) + fmt.Println("no events: ", errors.Is(err, secevent.ErrNoEvents)) + fmt.Println("iat present: ", !errors.Is(err, secevent.ErrMissingIssuedAt)) + + // Output: + // missing iss: true + // missing jti: true + // no events: true + // iat present: true +} diff --git a/secevent.go b/secevent.go index a3f9b08..9e34cec 100644 --- a/secevent.go +++ b/secevent.go @@ -28,9 +28,9 @@ const SpecVersion = "RFC 8417" // envelope that carries one or more security-event payloads; the events // themselves are decoded through the event-type registry. // -// This type models the §2.2 claims as Go fields. Wiring the full claims set -// across the JSON boundary (Parse and Encode) and the §2.2/§2.3 validation -// MUSTs are later building blocks. +// This type models the §2.2 claims as Go fields. Parse decodes the claims set +// across the JSON boundary into a SET, Validate checks the §2.2/§2.3 +// required-claim MUSTs, and Encode marshals a SET back to the claims-set bytes. type SET struct { // Issuer (iss) identifies the principal that issued the SET. REQUIRED // (RFC 8417 §2.2).