From af7056f94076907173a341eaf0f4d1cfd3a57f27 Mon Sep 17 00:00:00 2001 From: Henry Stern Date: Sun, 21 Jun 2026 13:40:22 -0300 Subject: [PATCH] bump go-subjectid to v0.2.0 and adopt its value-form subjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-subjectid v0.2.0 is a breaking release: Parse now returns Subject Identifiers in their value form (subjectid.IssSubID) rather than the pointer form (*subjectid.IssSubID), and value receivers now satisfy the SubjectIdentifier interface. The previous pin was a v0.0.0 pseudo-version whose Parse still handed back pointers, which is the behavior the SET type and its tests were written against. Adapting the dependent code is mechanical but spreads across the parse boundary. Every test that type-asserts a parsed top-level or per-event sub_id — in conformance_test.go, interop_test.go, parse_test.go, and subject_test.go — moves from .(*subjectid.IssSubID) with a pointer dereference to .(subjectid.IssSubID) with a direct value comparison, matching what Parse now returns. The SET.Subject field doc, the SubjectAs godoc, and the ExampleSET_IssSub doc previously explained the value/pointer split by saying "Parse yields the pointer form because go-subjectid's registry constructors return pointers"; that rationale is now false, so they instead state that Parse yields the value form and that the pointer form arises only from a hand-built SET. This does not simplify SubjectAs or IssSub. v0.2.0 removes the asymmetry those accessors were first written to bridge, but a consumer can still assign a *subjectid.IssSubID to the Subject interface field, so the reflection-based pointer-peel path and its TestSubjectAsPointerForm / typed-nil coverage stay. encode_test.go keeps its &subjectid.IssSubID{} literal for the same reason: it now doubles as proof that a pointer-form subject still encodes correctly. The change is observable to consumers — code that asserted a parsed subject as *subjectid.IssSubID must now assert the value form — so it is a breaking change. Pre-1.0 SemVer signals that with a minor bump; the CHANGELOG records it under a dated [0.2.0] entry marked Breaking, and notes that consumers already reading the subject through SubjectAs or IssSub need no changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 +++++++++++++++++- conformance_test.go | 8 ++++---- example_test.go | 7 +++---- go.mod | 2 +- go.sum | 4 ++-- interop_test.go | 16 ++++++++-------- parse_test.go | 4 ++-- secevent.go | 11 +++++------ subject.go | 20 ++++++++++---------- subject_test.go | 24 ++++++++++++------------ 10 files changed, 64 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8890cb..190839c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.2.0] - 2026-06-21 + +### Changed + +- **Breaking:** Upgraded [`go-subjectid`](https://github.com/hstern/go-subjectid) + to v0.2.0, whose `Parse` now returns Subject Identifiers in their value form + (for example `subjectid.IssSubID`) rather than the pointer form. A parsed + `SET` now holds the same value form a `SET` built in Go holds, so a type + switch on `SET.Subject` no longer has to special-case the parsed shape — but + code that asserted the parsed subject as `*subjectid.IssSubID` must now assert + the value form. The `SubjectAs[T]` and `SET.IssSub` accessors are unchanged: + they still return the value form and still absorb a hand-built pointer-form + subject, so consumers already using them need no changes. The `SET.Subject` + documentation now describes the value form as the parsed type. + ## [0.1.1] - 2026-06-21 ### Added @@ -42,6 +57,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). (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.1...HEAD +[Unreleased]: https://github.com/hstern/go-secevent/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/hstern/go-secevent/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/hstern/go-secevent/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/hstern/go-secevent/releases/tag/v0.1.0 diff --git a/conformance_test.go b/conformance_test.go index c9e8639..aa93de1 100644 --- a/conformance_test.go +++ b/conformance_test.go @@ -222,12 +222,12 @@ func assertSubject(t *testing.T, set *SET, tc conformanceCase) { return } - got, ok := set.Subject.(*subjectid.IssSubID) + got, ok := set.Subject.(subjectid.IssSubID) if !ok { - t.Fatalf("Subject is %T, want *subjectid.IssSubID", set.Subject) + t.Fatalf("Subject is %T, want subjectid.IssSubID", set.Subject) } - if *got != tc.wantSubject { - t.Errorf("Subject = %+v, want %+v", *got, tc.wantSubject) + if got != tc.wantSubject { + t.Errorf("Subject = %+v, want %+v", got, tc.wantSubject) } } diff --git a/example_test.go b/example_test.go index 79d0e43..2b5c8ce 100644 --- a/example_test.go +++ b/example_test.go @@ -101,10 +101,9 @@ func Example_encode() { // {"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"}}} } -// ExampleSET_IssSub reads the typed sub_id of a parsed SET. Parse hands back -// the pointer form of the Subject Identifier (a *subjectid.IssSubID), but -// IssSub returns the iss_sub value directly, so a consumer never has to handle -// the pointer/value distinction itself. +// ExampleSET_IssSub reads the typed sub_id of a parsed SET. IssSub returns the +// iss_sub value directly regardless of whether the held identifier is the value +// or pointer form, so a consumer never has to handle the distinction itself. func ExampleSET_IssSub() { // Verified, base64url-decoded claims-set bytes carrying an iss_sub subject. payload := []byte(`{ diff --git a/go.mod b/go.mod index 6ed2f65..26e9f3b 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/hstern/go-secevent go 1.26 -require github.com/hstern/go-subjectid v0.0.0-20260525222327-b47140763585 +require github.com/hstern/go-subjectid v0.2.0 diff --git a/go.sum b/go.sum index 186f8b9..a1cc940 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/hstern/go-subjectid v0.0.0-20260525222327-b47140763585 h1:dDFXd9djpbmlAOSlzIbFunmu6YVnzMcOnxPH+ySINZ0= -github.com/hstern/go-subjectid v0.0.0-20260525222327-b47140763585/go.mod h1:KS0TexS0PZQe7CnntjRRzcufxLdMji1DMVpxiBqCvRM= +github.com/hstern/go-subjectid v0.2.0 h1:kakUgtYVbUjIIVLaJcwWnQ9QKFwIw+AQxCZKOGLiOWU= +github.com/hstern/go-subjectid v0.2.0/go.mod h1:KS0TexS0PZQe7CnntjRRzcufxLdMji1DMVpxiBqCvRM= diff --git a/interop_test.go b/interop_test.go index d3bb8d9..f43d24b 100644 --- a/interop_test.go +++ b/interop_test.go @@ -120,12 +120,12 @@ func TestInteropCAEPSessionRevoked(t *testing.T) { } wantSubject := subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"} - got, isIssSub := set.Subject.(*subjectid.IssSubID) + got, isIssSub := set.Subject.(subjectid.IssSubID) if !isIssSub { - t.Fatalf("Subject is %T, want *subjectid.IssSubID", set.Subject) + t.Fatalf("Subject is %T, want subjectid.IssSubID", set.Subject) } - if *got != wantSubject { - t.Errorf("Subject = %+v, want %+v", *got, wantSubject) + if got != wantSubject { + t.Errorf("Subject = %+v, want %+v", got, wantSubject) } event, ok, err := set.Events.Typed(caepSessionRevokedURI) @@ -189,13 +189,13 @@ func TestInteropRISCAccountDisabled(t *testing.T) { if err != nil { t.Fatalf("decode per-event subject: %v", err) } - issSub, isIssSub := subject.(*subjectid.IssSubID) + issSub, isIssSub := subject.(subjectid.IssSubID) if !isIssSub { - t.Fatalf("per-event subject is %T, want *subjectid.IssSubID", subject) + t.Fatalf("per-event subject is %T, want subjectid.IssSubID", subject) } want := subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"} - if *issSub != want { - t.Errorf("per-event subject = %+v, want %+v", *issSub, want) + if issSub != want { + t.Errorf("per-event subject = %+v, want %+v", issSub, want) } } diff --git a/parse_test.go b/parse_test.go index 2dc2cd2..910bc5b 100644 --- a/parse_test.go +++ b/parse_test.go @@ -159,9 +159,9 @@ func TestParseSubID(t *testing.T) { if got, want := set.Subject.Format(), "iss_sub"; got != want { t.Errorf("Subject.Format() = %q, want %q", got, want) } - iss, ok := set.Subject.(*subjectid.IssSubID) + iss, ok := set.Subject.(subjectid.IssSubID) if !ok { - t.Fatalf("Subject has type %T, want *subjectid.IssSubID", set.Subject) + t.Fatalf("Subject has type %T, want subjectid.IssSubID", set.Subject) } if iss.Iss != "https://issuer.example.com/" || iss.Sub != "145234573" { t.Errorf("Subject = {Iss:%q Sub:%q}, want {iss/ 145234573}", iss.Iss, iss.Sub) diff --git a/secevent.go b/secevent.go index 50fa6af..4a6f570 100644 --- a/secevent.go +++ b/secevent.go @@ -58,12 +58,11 @@ type SET struct { // field. A nil Subject means the claim is absent. OPTIONAL (RFC 8417 §2.2, // RFC 9493 §3). // - // The interface's dynamic type depends on how the SET was built: Parse - // yields the pointer form (for example *subjectid.IssSubID, because - // go-subjectid's registry constructors return pointers), whereas a SET - // built in Go naturally holds the value form (subjectid.IssSubID). Use - // SubjectAs or IssSub to read the concrete subject without handling both - // forms by hand. + // Parse yields the value form of the identifier (for example + // subjectid.IssSubID), the same form a SET built in Go naturally holds; a + // SET may also be hand-built with the pointer form (*subjectid.IssSubID). + // Use SubjectAs or IssSub to read the concrete subject without handling + // both forms by hand. Subject subjectid.SubjectIdentifier // TransactionID (txn) optionally correlates the SET with related events or diff --git a/subject.go b/subject.go index 58e6d41..f1b0eba 100644 --- a/subject.go +++ b/subject.go @@ -12,15 +12,15 @@ import ( // SubjectAs returns the SET's sub_id as the concrete Subject Identifier type T, // reporting ok=false when the subject is absent or of a different format. // -// It exists to absorb a pointer/value asymmetry in the Subject field. A SET -// produced by Parse carries the pointer form of its identifier (for example -// *subjectid.IssSubID), because go-subjectid's registry constructors return -// pointers; a SET built in Go naturally holds the value form -// (subjectid.IssSubID), because the concrete types satisfy the interface with -// value receivers. A consumer that type-asserts SET.Subject directly therefore -// has to handle both forms. SubjectAs handles them once: it accepts the value -// type as T (for example SubjectAs[subjectid.IssSubID]) and matches whether the -// held identifier is the value or the pointer form, always returning the value. +// It exists to absorb the value/pointer distinction in the Subject field. +// go-subjectid's Parse returns identifiers in their value form (for example +// subjectid.IssSubID), the same form a SET built in Go naturally holds, so the +// two share a dynamic type. A SET may nonetheless be hand-built with the +// pointer form (*subjectid.IssSubID also satisfies the interface), and a +// consumer that type-asserts SET.Subject directly would have to handle both. +// SubjectAs handles them once: it accepts the value type as T (for example +// SubjectAs[subjectid.IssSubID]) and matches whether the held identifier is the +// value or the pointer form, always returning the value. // // if iss, ok := secevent.SubjectAs[subjectid.IssSubID](set); ok { // // iss is a subjectid.IssSubID regardless of how set was built @@ -38,7 +38,7 @@ func SubjectAs[T subjectid.SubjectIdentifier](s *SET) (T, bool) { return v, true } - // The pointer form (a SET from Parse) is *T; dereference it. A type + // The pointer form (a hand-built SET) is *T; dereference it. A type // assertion to *T will not compile for an arbitrary type parameter, so // reach for reflection to peel exactly one pointer indirection. rv := reflect.ValueOf(s.Subject) diff --git a/subject_test.go b/subject_test.go index 739b7e3..81a0281 100644 --- a/subject_test.go +++ b/subject_test.go @@ -37,12 +37,12 @@ func TestSETSubjectRoundTrip(t *testing.T) { t.Fatalf("subjectid.Parse: %v", err) } - gotIssSub, ok := got.(*subjectid.IssSubID) + gotIssSub, ok := got.(subjectid.IssSubID) if !ok { - t.Fatalf("parsed Subject is %T, want *subjectid.IssSubID", got) + t.Fatalf("parsed Subject is %T, want subjectid.IssSubID", got) } - if *gotIssSub != want { - t.Errorf("Subject round-trip = %+v, want %+v", *gotIssSub, want) + if gotIssSub != want { + t.Errorf("Subject round-trip = %+v, want %+v", gotIssSub, want) } if got.Format() != "iss_sub" { t.Errorf("Format() = %q, want %q", got.Format(), "iss_sub") @@ -74,8 +74,8 @@ func TestSubjectAsValueForm(t *testing.T) { } // TestSubjectAsPointerForm checks that SubjectAs and IssSub also read the -// pointer form — the shape Parse produces, since go-subjectid's registry -// constructors return pointers. This is the asymmetry the accessor absorbs. +// pointer form — the shape a SET hand-built with *subjectid.IssSubID carries. +// This is the value/pointer distinction the accessor absorbs. func TestSubjectAsPointerForm(t *testing.T) { want := subjectid.IssSubID{Iss: "https://issuer.example.com/", Sub: "145234573"} s := SET{Subject: &want} @@ -90,8 +90,8 @@ func TestSubjectAsPointerForm(t *testing.T) { // TestSubjectAsRoundTrip is the acceptance round-trip: a SET built in Go with // the value form of the subject, encoded and parsed back, yields an equal -// subject through the accessor — even though Parse hands back the pointer form. -// A consumer never has to write a pointer/value type switch. +// subject through the accessor. A consumer never has to write a pointer/value +// type switch. func TestSubjectAsRoundTrip(t *testing.T) { want := subjectid.IssSubID{Iss: "https://issuer.example.com/", Sub: "145234573"} s := SET{ @@ -114,10 +114,10 @@ func TestSubjectAsRoundTrip(t *testing.T) { t.Fatalf("Parse: %v", err) } - // Sanity: the parsed subject is the pointer form, which is exactly why the - // accessor is needed. - if _, ok := parsed.Subject.(*subjectid.IssSubID); !ok { - t.Fatalf("parsed Subject is %T, want *subjectid.IssSubID", parsed.Subject) + // Sanity: the parsed subject is the value form go-subjectid's Parse returns, + // the same form fed in — the accessor still spares consumers the type switch. + if _, ok := parsed.Subject.(subjectid.IssSubID); !ok { + t.Fatalf("parsed Subject is %T, want subjectid.IssSubID", parsed.Subject) } got, ok := parsed.IssSub()