Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
136 changes: 129 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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).
209 changes: 209 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
@@ -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=<nil> raw={"k": "w"}
// https://example.com/secevent/never-registered/event ok=false err=<nil> 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
}
Loading
Loading