diff --git a/example_test.go b/example_test.go index e5c1def..79d0e43 100644 --- a/example_test.go +++ b/example_test.go @@ -101,6 +101,41 @@ 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. +func ExampleSET_IssSub() { + // Verified, base64url-decoded claims-set bytes carrying an iss_sub subject. + payload := []byte(`{ + "iss": "https://idp.example.com/", + "iat": 1615305600, + "jti": "set-0003", + "sub_id": {"format": "iss_sub", "iss": "https://idp.example.com/", "sub": "user-7f3e2a"}, + "events": { + "https://example.com/secevent/iss-sub-example/event": {} + } + }`) + + set, err := secevent.Parse(payload) + if err != nil { + fmt.Println("parse:", err) + return + } + + sub, ok := set.IssSub() + if !ok { + fmt.Println("subject is absent or not iss_sub") + return + } + fmt.Println("iss:", sub.Iss) + fmt.Println("sub:", sub.Sub) + + // Output: + // iss: https://idp.example.com/ + // sub: user-7f3e2a +} + // 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 diff --git a/secevent.go b/secevent.go index 9e34cec..50fa6af 100644 --- a/secevent.go +++ b/secevent.go @@ -57,6 +57,13 @@ type SET struct { // identifier is delegated to go-subjectid; this package only holds the // 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. Subject subjectid.SubjectIdentifier // TransactionID (txn) optionally correlates the SET with related events or diff --git a/subject.go b/subject.go new file mode 100644 index 0000000..58e6d41 --- /dev/null +++ b/subject.go @@ -0,0 +1,61 @@ +// Copyright 2026 The go-secevent Authors +// SPDX-License-Identifier: Apache-2.0 + +package secevent + +import ( + "reflect" + + "github.com/hstern/go-subjectid" +) + +// 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. +// +// if iss, ok := secevent.SubjectAs[subjectid.IssSubID](set); ok { +// // iss is a subjectid.IssSubID regardless of how set was built +// } +// +// For the overwhelmingly common iss_sub case, (*SET).IssSub is a shorthand. +func SubjectAs[T subjectid.SubjectIdentifier](s *SET) (T, bool) { + var zero T + if s == nil || s.Subject == nil { + return zero, false + } + + // The value form (a SET built in Go) matches directly. + if v, ok := s.Subject.(T); ok { + return v, true + } + + // The pointer form (a SET from Parse) 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) + if rv.Kind() == reflect.Pointer && !rv.IsNil() { + if v, ok := reflect.TypeAssert[T](rv.Elem()); ok { + return v, true + } + } + + return zero, false +} + +// IssSub returns the SET's sub_id as a subjectid.IssSubID when the subject is +// present and in the iss_sub format, reporting ok=false otherwise. It is a +// shorthand for SubjectAs[subjectid.IssSubID] covering the most common subject +// format, and it transparently handles both the value and pointer forms of the +// held identifier (see SubjectAs). +func (s *SET) IssSub() (subjectid.IssSubID, bool) { + return SubjectAs[subjectid.IssSubID](s) +} diff --git a/subject_test.go b/subject_test.go index c3e8726..739b7e3 100644 --- a/subject_test.go +++ b/subject_test.go @@ -6,6 +6,7 @@ package secevent import ( "encoding/json" "testing" + "time" "github.com/hstern/go-subjectid" ) @@ -57,3 +58,115 @@ func TestSETSubjectAbsent(t *testing.T) { t.Errorf("zero SET Subject = %v, want nil", s.Subject) } } + +// TestSubjectAsValueForm checks that SubjectAs and IssSub read the value form +// of the subject — the shape a SET built in Go naturally carries. +func TestSubjectAsValueForm(t *testing.T) { + want := subjectid.IssSubID{Iss: "https://issuer.example.com/", Sub: "145234573"} + s := SET{Subject: want} + + if got, ok := SubjectAs[subjectid.IssSubID](&s); !ok || got != want { + t.Errorf("SubjectAs[IssSubID] = (%+v, %v), want (%+v, true)", got, ok, want) + } + if got, ok := s.IssSub(); !ok || got != want { + t.Errorf("IssSub() = (%+v, %v), want (%+v, true)", got, ok, want) + } +} + +// 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. +func TestSubjectAsPointerForm(t *testing.T) { + want := subjectid.IssSubID{Iss: "https://issuer.example.com/", Sub: "145234573"} + s := SET{Subject: &want} + + if got, ok := SubjectAs[subjectid.IssSubID](&s); !ok || got != want { + t.Errorf("SubjectAs[IssSubID] = (%+v, %v), want (%+v, true)", got, ok, want) + } + if got, ok := s.IssSub(); !ok || got != want { + t.Errorf("IssSub() = (%+v, %v), want (%+v, true)", got, ok, want) + } +} + +// 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. +func TestSubjectAsRoundTrip(t *testing.T) { + want := subjectid.IssSubID{Iss: "https://issuer.example.com/", Sub: "145234573"} + s := SET{ + Issuer: "https://issuer.example.com/", + IssuedAt: time.Unix(1699999999, 0).UTC(), + JWTID: "jti-round-trip", + Subject: want, // value form on the way in + Events: Events{ + "urn:example:event": json.RawMessage(`{"k":"v"}`), + }, + } + + out, err := s.Encode() + if err != nil { + t.Fatalf("Encode: %v", err) + } + + parsed, err := Parse(out) + if err != nil { + 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) + } + + got, ok := parsed.IssSub() + if !ok { + t.Fatal("IssSub() on parsed SET = ok false, want true") + } + if got != want { + t.Errorf("round-trip subject = %+v, want %+v", got, want) + } +} + +// TestSubjectAsMismatch covers the not-found branches: an absent subject, a nil +// receiver, and a subject of a different format than the one requested. +func TestSubjectAsMismatch(t *testing.T) { + t.Run("absent", func(t *testing.T) { + var s SET + if got, ok := s.IssSub(); ok { + t.Errorf("IssSub() on empty SET = (%+v, true), want ok false", got) + } + }) + + t.Run("nil receiver", func(t *testing.T) { + if got, ok := SubjectAs[subjectid.IssSubID](nil); ok { + t.Errorf("SubjectAs(nil) = (%+v, true), want ok false", got) + } + }) + + t.Run("typed nil subject", func(t *testing.T) { + // A typed nil pointer in the interface is not == nil, so it slips past + // the s.Subject == nil guard and reaches the reflection path; the + // !rv.IsNil() check is what keeps rv.Elem() from panicking. + var p *subjectid.IssSubID + s := SET{Subject: p} + if got, ok := SubjectAs[subjectid.IssSubID](&s); ok { + t.Errorf("SubjectAs with typed-nil Subject = (%+v, true), want ok false", got) + } + if got, ok := s.IssSub(); ok { + t.Errorf("IssSub with typed-nil Subject = (%+v, true), want ok false", got) + } + }) + + t.Run("wrong format", func(t *testing.T) { + s := SET{Subject: subjectid.EmailID{Email: "user@example.com"}} + if got, ok := s.IssSub(); ok { + t.Errorf("IssSub() on email subject = (%+v, true), want ok false", got) + } + // The matching format still resolves. + if got, ok := SubjectAs[subjectid.EmailID](&s); !ok || got.Email != "user@example.com" { + t.Errorf("SubjectAs[EmailID] = (%+v, %v), want the email, true", got, ok) + } + }) +}