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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- `SubjectAs[T]` and `SET.IssSub` now match the value form of the `sub_id`
Subject Identifier only. go-subjectid v0.2.0 made the value form the single
dynamic type `Parse` returns and a Go-built `SET` should hold, so the
reflection-based pointer-form fallback the accessors carried is unreachable
through any supported flow; it is removed along with its `reflect` import. A
`SET` hand-built with a `*subjectid.IssSubID` Subject now reports `ok=false`
from these accessors instead of being dereferenced. Consumers reading subjects
produced by `Parse`, or building SETs with value-form subjects, are unaffected.

## [0.2.0] - 2026-06-21

### Changed
Expand Down
2 changes: 1 addition & 1 deletion encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestEncodeFullyPopulated(t *testing.T) {
IssuedAt: time.Unix(1700000000, 0).UTC(),
JWTID: "abc123",
Audience: Audience{"https://rp.example.com"},
Subject: &subjectid.IssSubID{
Subject: subjectid.IssSubID{
Iss: "https://issuer.example.com/",
Sub: "145234573",
},
Expand Down
3 changes: 1 addition & 2 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,7 @@ func Example_encode() {
}

// 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.
// iss_sub value directly, so a consumer never has to type-assert SET.Subject.
func ExampleSET_IssSub() {
// Verified, base64url-decoded claims-set bytes carrying an iss_sub subject.
payload := []byte(`{
Expand Down
6 changes: 2 additions & 4 deletions secevent.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,8 @@ type SET struct {
// RFC 9493 §3).
//
// 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.
// subjectid.IssSubID), the same form a SET built in Go holds. Use SubjectAs
// or IssSub to read the concrete subject as its typed value.
Subject subjectid.SubjectIdentifier

// TransactionID (txn) optionally correlates the SET with related events or
Expand Down
36 changes: 7 additions & 29 deletions subject.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,19 @@

package secevent

import (
"reflect"

"github.com/hstern/go-subjectid"
)
import "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 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.
// subjectid.IssSubID), the same form a SET built in Go holds, so T is the value
// type — SubjectAs[subjectid.IssSubID]. It is a typed, nil-safe read of the
// SET.Subject interface: a checked type assertion that also tolerates a nil SET
// or an absent subject.
//
// if iss, ok := secevent.SubjectAs[subjectid.IssSubID](set); ok {
// // iss is a subjectid.IssSubID regardless of how set was built
// // iss is a subjectid.IssSubID
// }
//
// For the overwhelmingly common iss_sub case, (*SET).IssSub is a shorthand.
Expand All @@ -32,30 +24,16 @@ func SubjectAs[T subjectid.SubjectIdentifier](s *SET) (T, bool) {
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 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)
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).
// format.
func (s *SET) IssSub() (subjectid.IssSubID, bool) {
return SubjectAs[subjectid.IssSubID](s)
}
25 changes: 13 additions & 12 deletions subject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,19 @@ func TestSubjectAsValueForm(t *testing.T) {
}
}

// TestSubjectAsPointerForm checks that SubjectAs and IssSub also read the
// 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}
// TestSubjectAsPointerFormUnmatched pins the value-only contract: a SET
// hand-built with the pointer form of an identifier (*subjectid.IssSubID) is
// reported as not found rather than dereferenced. go-subjectid v0.2.0 made the
// value form the single shape Parse returns and a Go-built SET should hold, so
// the accessors match the value form only.
func TestSubjectAsPointerFormUnmatched(t *testing.T) {
s := SET{Subject: &subjectid.IssSubID{Iss: "https://issuer.example.com/", Sub: "145234573"}}

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 := SubjectAs[subjectid.IssSubID](&s); ok {
t.Errorf("SubjectAs[IssSubID] with pointer-form Subject = (%+v, true), want ok false", got)
}
if got, ok := s.IssSub(); !ok || got != want {
t.Errorf("IssSub() = (%+v, %v), want (%+v, true)", got, ok, want)
if got, ok := s.IssSub(); ok {
t.Errorf("IssSub() with pointer-form Subject = (%+v, true), want ok false", got)
}
}

Expand Down Expand Up @@ -147,8 +148,8 @@ func TestSubjectAsMismatch(t *testing.T) {

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.
// the s.Subject == nil guard; the value-form type assertion then fails
// without dereferencing it, so there is no panic.
var p *subjectid.IssSubID
s := SET{Subject: p}
if got, ok := SubjectAs[subjectid.IssSubID](&s); ok {
Expand Down
Loading