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
35 changes: 35 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions secevent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions subject.go
Original file line number Diff line number Diff line change
@@ -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)
}
113 changes: 113 additions & 0 deletions subject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package secevent
import (
"encoding/json"
"testing"
"time"

"github.com/hstern/go-subjectid"
)
Expand Down Expand Up @@ -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)
}
})
}
Loading