Skip to content

hstern/go-subjectid

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

44 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-subjectid

A Go library implementing RFC 9493 — Subject Identifiers for Security Event Tokens.

Status: released, v0.x. Latest tag v0.2.0. The API is tagged and usable, achieving byte-stable round-trip on every RFC 9493 §3 example. While the library is in the v0.x series a minor release may still make breaking changes (per SemVer's 0.x convention) — see CHANGELOG.md before upgrading. The module path github.com/hstern/go-subjectid has been stable since the first commit. A v1.0.0 API-stability commitment will follow once the surface settles.

What it is

go-subjectid is the Go ecosystem's reference implementation of the RFC 9493 Subject Identifier types — the discriminated-union JSON values used by SET / SSF / CAEP / RISC to identify the subject of a security event.

The library handles:

  • The eight built-in formats from the IANA "Security Event Identifier Formats" registry: account, email, iss_sub, opaque, phone_number, did, uri, aliases.
  • JSON codec — discriminator-driven Unmarshal dispatch plus spec-order, byte-stable Marshal output.
  • Validation — opt-in Validate() per format, returning sentinel errors (ErrFormatXxx, ErrRequired, ErrOpaqueEmpty, ErrNestedAliases, …). Callers branch with errors.Is; every sentinel also matches the umbrella subjectid.Err. ErrRequired is a struct type — use errors.As to recover the missing field names.
  • Forward compatibility — unknown formats parse into an UnknownFormat carrier that preserves the wire bytes verbatim, so the identifier round-trips even when the library can't fully parse it.
  • ExtensionRegisterFormat lets downstream code add a new format constructor for non-registry types.

Install

go get github.com/hstern/go-subjectid@latest

Requires Go 1.26 or newer.

Build prerequisites (contributors only)

Per-format validation regexes are generated from the relevant RFCs' ABNF grammars in grammar/<rfc>/*.abnf and committed to the tree as *.rex files alongside, so the runtime tree depends only on the Go standard library. Regenerating those files needs the pandatix/go-abnf pap CLI.

go install does not work because the cmd/pap go.mod uses a local replace directive; build from source:

git clone --depth=1 https://github.com/pandatix/go-abnf.git /tmp/go-abnf
go build -C /tmp/go-abnf/cmd/pap -o "$(go env GOPATH)/bin/pap"

Then regenerate with make generate (or make -B generate to force a full rebuild). CI runs the same incantation in the pap job below and fails the build on uncommitted drift, so always commit the .rex files alongside any .abnf change.

Quickstart

Decode a Subject Identifier from JSON, validate it, and act on the result:

package main

import (
	"encoding/json"
	"fmt"

	"github.com/hstern/go-subjectid"
)

func main() {
	wire := []byte(`{"format":"email","email":"user@example.com"}`)

	id, err := subjectid.Parse(json.RawMessage(wire))
	if err != nil {
		panic(err)
	}
	if err := id.Validate(); err != nil {
		panic(err)
	}
	fmt.Printf("%s identifier: %#v\n", id.Format(), id)
}

Build a Subject Identifier value in Go and emit canonical wire bytes:

package main

import (
	"encoding/json"
	"fmt"

	"github.com/hstern/go-subjectid"
)

func main() {
	id := subjectid.IssSubID{
		Iss: "https://issuer.example.com/",
		Sub: "145234573",
	}
	if err := id.Validate(); err != nil {
		panic(err)
	}
	out, err := json.Marshal(id)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(out))
	// {"format":"iss_sub","iss":"https://issuer.example.com/","sub":"145234573"}
}

The codec is byte-stable: format is emitted first, then the format-specific members in the order RFC 9493 §3 defines them. Round-tripping a spec-example payload produces output identical to the input.

Per-format usage

Each format in the IANA "Security Event Identifier Formats" registry has its own typed Go struct. All eight implement the sealed SubjectIdentifier interface:

Format Go type Wire member(s) Spec section
account AccountID uri (acct: URI per RFC 7565) §3.2.1
email EmailID email (addr-spec per RFC 5322 §3.4.1) §3.2.2
iss_sub IssSubID iss (URI), sub §3.2.3
opaque OpaqueID id (non-empty string) §3.2.4
phone_number PhoneNumberID phone_number (E.164) §3.2.5
did DIDID url (W3C DID Core URL) §3.2.6
uri URIID uri (RFC 3986 absolute-URI) §3.2.7
aliases AliasesID identifiers (slice; no nested aliases) §3.2.8

Use the typed constructor directly when building a value; use Parse (or json.Unmarshal into a known type) when decoding from the wire. See the godoc Examples on each format type for runnable usage.

Extension formats

For format names outside the IANA built-in set, define a Go type embedding subjectid.Seal and register a constructor at consumer init time:

type OrgTenantID struct {
	subjectid.Seal
	Tenant string
}

func (OrgTenantID) Format() string                   { return "org.example.tenant" }
func (OrgTenantID) Validate() error                  { /* … */ return nil }
func (o OrgTenantID) MarshalJSON() ([]byte, error)   { /* spec-order */ }
func (o *OrgTenantID) UnmarshalJSON(b []byte) error  { /* … */ }

func init() {
	_ = subjectid.RegisterFormat("org.example.tenant",
		func() subjectid.SubjectIdentifier { return &OrgTenantID{} })
}

Re-registering an IANA built-in name returns an error wrapping subjectid.ErrFormatReserved. Unknown formats encountered on unmarshal — neither built-in nor registered — parse into an UnknownFormat carrier that preserves the wire bytes verbatim, so payloads always round-trip even when the library can't recognize every entry.

How this fits with SET / SSF / CAEP / RISC

Subject Identifiers are a building block, not a complete protocol. The RFC 9493 wire shape is referenced by, but does not stand alone in:

  • RFC 8417 — Security Event Token (SET). JWT-shaped events whose events claim values carry a subject member that is an RFC 9493 Subject Identifier. go-subjectid is the wire-layer dependency for any Go SET library; the SET envelope itself (JWT signing, audience routing, txn correlation) is the consumer's responsibility.
  • OpenID Shared Signals Framework (SSF). The transmitter
    • receiver protocols for streaming SETs. Stream Configuration uses subject in the same shape; SSF receivers consuming CAEP / RISC streams need a Subject Identifier parser at the edge.
  • OpenID Continuous Access Evaluation Protocol (CAEP) and the RISC event family. CAEP and RISC define event schemas that embed a subject member; go-subjectid decodes that member.

A future sibling library (go-ssf-shaped — name TBD) will depend on this one. Until then, consumers wiring SET / SSF stacks in Go can use this library directly at the subject boundary and supply their own JWT and HTTP layers.

Stability

v0.x is pre-release. The public API surface is expected to remain stable through v0.x minor bumps once v0.1.0 is tagged, but breaking changes may land between minor versions if a wire-fidelity issue is found. After v1.0.0, Go module SemVer applies: breaking changes require a v2 branch with /v2 import-path suffix per the standard major-version handling.

The library's wire-fidelity claim — every RFC 9493 §3 example round-trips byte-stably through the codec — is regression-tested in CI via the embedded spec fixtures in internal/specfixtures/.

Design

The library's design rationale, especially the wire-fidelity choices that bite every RFC 9493 implementer once, will be summarized in design.md ahead of the v0.1.0 tag. The headline decisions:

  • Sealed SubjectIdentifier interface with per-format concrete types — no map[string]any, no zero-valued union-fields struct.
  • encoding/json stdlib with custom UnmarshalJSON dispatch on the format discriminator.
  • Lenient on unmarshal, strict on marshal. Postel's law: decode whatever the wire gave us, validate at the marshal boundary.
  • Byte-stable output: format first, format-specific members in the order RFC 9493 §3 defines them.
  • Open-extension fields are json.RawMessage, not map[string]any — interop scenarios pin exact JSON bytes, and map reorders keys.

Compatibility

  • Spec version: const SpecVersion = "RFC 9493". RFCs don't have minor/patch numbers; errata are absorbed into Go-minor releases without changing the constant.
  • Go version: 1.26+.
  • Dependencies: standard library only, at runtime. Test dependencies, if any, are listed in go.mod.
  • Library SemVer is independent of the spec version. Major- version handling follows the go-jose branch pattern (no versioned subdirectories — vN lives in go.mod on a vN branch).

Contributing

Contributor conventions are in AGENTS.md: commit message style, code review expectations, the per-file SPDX header, and the local pre-PR checks the CI also runs.

Bugs and feature ideas are welcome via the project's issue tracker.

License

Apache-2.0. See LICENSE for the full text.

Every source file carries an SPDX identifier:

// Copyright 2026 The go-subjectid Authors
// SPDX-License-Identifier: Apache-2.0

About

Go library implementing RFC 9493 Subject Identifiers for Security Event Tokens

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors