subjectid

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 21, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

README

go-subjectid

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

Status: pre-release. API surface is being shaped against RFC 9493 §3 with the goal of byte-stable round-trip on every spec example. First tag will be v0.1.0. The module path github.com/hstern/go-subjectid is stable from the first commit; do not depend on the repo until v0.1.0 is tagged.

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

Documentation

Overview

Package subjectid implements RFC 9493 Subject Identifiers for Security Event Tokens.

The package provides:

  • The sealed SubjectIdentifier interface implemented by every identifier value, defined in this file.
  • Per-format concrete types for each format in the IANA "Security Event Identifier Formats" registry, each in its own file (account.go, email.go, …).
  • An UnknownFormat carrier that preserves the wire bytes of formats the library does not natively recognize, so forward- compatibility round-trips correctly.
  • An opt-in SubjectIdentifier.Validate method per format, returning the package's sentinel errors (per-format FormatErr-built sentinels, ErrRequired for missing members, etc.). Callers branch with errors.Is; every sentinel also matches the umbrella Err.

Index

Examples

Constants

View Source
const SpecVersion = "RFC 9493"

SpecVersion identifies the RFC this package implements. RFCs have no minor or patch numbers; errata to RFC 9493 are absorbed into Go-minor releases of this module without changing the value of this constant.

Variables

View Source
var (
	// ErrJSON reports that the bytes handed to Parse are not a
	// JSON object exposing a "format" string member.
	ErrJSON = fmt.Errorf("%w: input is not a Subject Identifier JSON object", Err)

	// ErrNestedAliases reports that an aliases identifier contains
	// another aliases identifier, which RFC 9493 §3.2.8 forbids.
	ErrNestedAliases = fmt.Errorf("%w: aliases identifier must not contain a nested aliases identifier", Err)

	// ErrAliasesEmpty reports that an aliases identifier's
	// "identifiers" array was nil or zero-length. RFC 9493 §3.2.8
	// requires at least one element.
	ErrAliasesEmpty = fmt.Errorf("%w: aliases identifier must contain at least one element", Err)

	// ErrOpaqueEmpty reports that an OpaqueID's "id" member was
	// the empty string. RFC 9493 §3.2.4 imposes no internal
	// structure on opaque identifiers but §3 still forbids empty
	// values. This sentinel exists alongside ErrRequired so
	// callers can distinguish "the wire shape was missing the
	// member" (ErrRequired, raised by Parse before the member is
	// inspected) from "the member was present but the empty
	// string" (ErrOpaqueEmpty, raised by OpaqueID.Validate).
	ErrOpaqueEmpty = fmt.Errorf("%w: opaque identifier is the empty string", Err)

	// Per-format invalid-value sentinels, one per RFC 9493 format
	// whose value is structurally checked. Built via FormatErr so
	// the surface stays uniform and each wraps Err.
	ErrFormatAccount     = FormatErr("invalid acct URI")
	ErrFormatEmail       = FormatErr("invalid email addr-spec")
	ErrFormatIssSub      = FormatErr("invalid iss/sub identifier")
	ErrFormatPhoneNumber = FormatErr("invalid E.164 phone number")
	ErrFormatDID         = FormatErr("invalid DID URL")
	ErrFormatURI         = FormatErr("invalid absolute URI")

	// ErrFormatReserved is returned by [RegisterFormat] when a
	// caller attempts to register a constructor for a format name
	// that the library has already populated as a built-in.
	ErrFormatReserved = FormatErr("format name is reserved for a built-in")
)

Sentinel errors categorizing the kinds of well-formedness failure a Subject Identifier can have. Each is the errors.Is target a caller uses to branch on the failure category. Each also wraps Err so caller code that only cares whether the error is a subjectid problem can use errors.Is(err, subjectid.Err).

View Source
var Err = errors.New("subjectid")

Err is the top-level umbrella sentinel for every error this package returns. Every other sentinel (ErrJSON, ErrFormatDID, ErrFormatReserved, the per-validator format errors built via FormatErr, and the ErrRequired struct type) is detectable as a subjectid.Err via errors.Is.

Use this when handling errors from multiple sources to branch on "did this come from subjectid?" without naming each leaf sentinel:

if errors.Is(err, subjectid.Err) {
    // handle as a subjectid validation/codec failure
} else {
    // unrelated to subjectid
}

Functions

func FormatErr

func FormatErr(msg string) error

FormatErr builds a sentinel error from a descriptive message, wrapping Err so the result matches errors.Is(returned, Err).

Pass the full message the caller wants in the error text — the helper adds no "invalid" prefix or other framing. The result reads "subjectid: <msg>".

Built-in format sentinels (ErrFormatAccount, ErrFormatEmail, etc.) and ErrFormatReserved are constructed via this helper. Consumers that register their own format via RegisterFormat can use FormatErr to mint a matching sentinel so callers can branch with errors.Is in the same idiom that works for the built-ins:

var ErrFormatMyCustom = subjectid.FormatErr("invalid my-custom format")

func (m MyCustomID) Validate() error {
    if !myRegex.MatchString(m.Value) {
        return ErrFormatMyCustom
    }
    return nil
}

The returned error is a fresh value; two FormatErr calls with the same message produce distinct sentinels that do NOT compare equal under errors.Is. Store the result in a package-level var if callers need a stable comparison target.

func MissingFields

func MissingFields(names ...string) error

MissingFields constructs an ErrRequired naming the specific wire- shape member(s) that were missing or empty. Pass one name for the common single-field case, or several for composite formats like iss_sub where multiple members can be missing at once.

func RegisterFormat

func RegisterFormat(name string, ctor Constructor) error

RegisterFormat registers a Constructor for a Subject Identifier format outside the IANA built-in set.

Call once per extension format, typically at consumer init time. Re-registering an already-registered extension format silently replaces the prior constructor — a single consumer init owns each extension format by convention. Concurrent callers are serialized by an internal mutex.

Returns an error wrapping ErrFormatReserved if name matches one of the eight built-in formats: those cannot be overridden; consumers needing different per-built-in behavior should wrap the concrete type rather than re-register the format. Compare with errors.Is.

Example

ExampleRegisterFormat shows the consumer extension path. A custom format name is associated with a constructor at init time; subjectid.Parse then dispatches to it automatically for any payload carrying that format discriminator.

package main

import (
	"encoding/json"
	"fmt"

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

// exampleTenantID is a minimal extension type for the
// ExampleRegisterFormat demo. Production consumers would carry
// the same shape: embed [subjectid.Seal], implement Format,
// Validate, MarshalJSON, UnmarshalJSON, then register a
// constructor at consumer init time.
type exampleTenantID struct {
	subjectid.Seal
	Tenant string
}

func (exampleTenantID) Format() string  { return "org.example.exampletenant" }
func (exampleTenantID) Validate() error { return nil }

func (o exampleTenantID) MarshalJSON() ([]byte, error) {
	return json.Marshal(struct {
		Format string `json:"format"`
		Tenant string `json:"tenant"`
	}{Format: o.Format(), Tenant: o.Tenant})
}

func (o *exampleTenantID) UnmarshalJSON(data []byte) error {
	var v struct {
		Tenant string `json:"tenant"`
	}
	if err := json.Unmarshal(data, &v); err != nil {
		return err
	}
	o.Tenant = v.Tenant
	return nil
}

func main() {
	_ = subjectid.RegisterFormat("org.example.exampletenant",
		func() subjectid.SubjectIdentifier { return &exampleTenantID{} })

	wire := []byte(`{"format":"org.example.exampletenant","tenant":"acme-prod"}`)
	id, err := subjectid.Parse(json.RawMessage(wire))
	if err != nil {
		panic(err)
	}

	out, _ := json.Marshal(id)
	fmt.Println(string(out))
}
Output:
{"format":"org.example.exampletenant","tenant":"acme-prod"}
Example (ReservedName)

ExampleRegisterFormat_reservedName shows the IANA-name guard: re-registering one of the eight built-in format names returns an error wrapping subjectid.ErrFormatReserved, so a consumer cannot accidentally override a built-in.

package main

import (
	"encoding/json"
	"errors"
	"fmt"

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

// exampleTenantID is a minimal extension type for the
// ExampleRegisterFormat demo. Production consumers would carry
// the same shape: embed [subjectid.Seal], implement Format,
// Validate, MarshalJSON, UnmarshalJSON, then register a
// constructor at consumer init time.
type exampleTenantID struct {
	subjectid.Seal
	Tenant string
}

func (exampleTenantID) Format() string  { return "org.example.exampletenant" }
func (exampleTenantID) Validate() error { return nil }

func (o exampleTenantID) MarshalJSON() ([]byte, error) {
	return json.Marshal(struct {
		Format string `json:"format"`
		Tenant string `json:"tenant"`
	}{Format: o.Format(), Tenant: o.Tenant})
}

func (o *exampleTenantID) UnmarshalJSON(data []byte) error {
	var v struct {
		Tenant string `json:"tenant"`
	}
	if err := json.Unmarshal(data, &v); err != nil {
		return err
	}
	o.Tenant = v.Tenant
	return nil
}

func main() {
	err := subjectid.RegisterFormat("email",
		func() subjectid.SubjectIdentifier { return &exampleTenantID{} })
	fmt.Println(errors.Is(err, subjectid.ErrFormatReserved))
}
Output:
true

Types

type AccountID

type AccountID struct {
	// URI is the acct URI naming the account. It is the value of
	// the JSON "uri" member; the scheme is required to be "acct"
	// and the scheme-specific part follows the RFC 7565 §3
	// "userpart @ host" grammar, with host per RFC 3986 §3.2.2.
	URI string
}

AccountID identifies a subject by an "acct" URI per RFC 7565, as defined in RFC 9493 §3.2.1.

Wire shape:

{
  "format": "account",
  "uri":    "acct:example.user@service.example.com"
}

func (AccountID) Format

func (AccountID) Format() string

Format returns "account". See SubjectIdentifier.Format.

func (AccountID) MarshalJSON

func (a AccountID) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for AccountID. The output is the spec-order JSON object

{"format":"account","uri":"<URI>"}

— format member first, then the format-specific members in the order RFC 9493 §3.2.1 defines them. Output is canonical (no whitespace) and byte-stable for a given input.

func (*AccountID) UnmarshalJSON

func (a *AccountID) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for AccountID. It decodes the "uri" member; the "format" member and any other extra members are ignored (Postel's law on receive). Use Parse to dispatch on "format" automatically.

func (AccountID) Validate

func (a AccountID) Validate() error

Validate enforces the RFC 9493 §3.2.1 wire shape: the "uri" member must be non-empty and must match the RFC 7565 + RFC 3986 grammar from account-uri.abnf in its entirety.

Example

ExampleAccountID_Validate shows the per-format validator returning nil for a well-formed acct: URI and a wrapped subjectid.ErrFormatAccount sentinel for a malformed one.

package main

import (
	"errors"
	"fmt"

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

func main() {
	ok := subjectid.AccountID{URI: "acct:user@example.com"}
	bad := subjectid.AccountID{URI: "not-an-acct-uri"}

	fmt.Println(ok.Validate())
	fmt.Println(errors.Is(bad.Validate(), subjectid.ErrFormatAccount))
}
Output:
<nil>
true

type AliasesID

type AliasesID struct {
	// Identifiers is the heterogeneous slice of inner Subject
	// Identifiers. It is the value of the JSON "identifiers"
	// member. The slice MUST be non-empty and MUST NOT contain
	// any element whose Format returns "aliases"; both rules
	// are enforced in Validate.
	Identifiers []SubjectIdentifier
}

AliasesID is a composite identifier representing the same subject under multiple formats, as defined in RFC 9493 §3.2.8.

Wire shape:

{
  "format": "aliases",
  "identifiers": [
    {"format": "email",   "email": "user@example.com"},
    {"format": "account", "uri":   "acct:user@example.com"}
  ]
}

Per RFC 9493 §3.2.8, an aliases identifier MUST NOT itself contain a nested aliases identifier. The Go type cannot express "[]SubjectIdentifier minus AliasesID" without losing the uniformity that makes the dispatch-and-recurse codec work, so the prohibition is enforced in AliasesID.Validate (a later commit) rather than at the type level.

func (AliasesID) Format

func (AliasesID) Format() string

Format returns "aliases". See SubjectIdentifier.Format.

func (AliasesID) MarshalJSON

func (a AliasesID) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for AliasesID. The inner Identifiers slice is marshaled element-by-element; each element's own MarshalJSON is invoked, so a heterogeneous slice round-trips with each element in its own format's spec-order shape.

func (*AliasesID) UnmarshalJSON

func (a *AliasesID) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for AliasesID by reading the "identifiers" array as raw JSON elements and recursively dispatching each through Parse. The element types are arbitrary — heterogeneous slices fall out of the uniform dispatch.

An element whose "format" is not recognized by the registry becomes an UnknownFormat in the resulting slice rather than failing the whole AliasesID. An element that is not valid JSON or is missing the "format" member fails Parse and is returned as the AliasesID-level error — the spec rule that aliases contain non-null subject identifiers leaves no room for a totally unparseable inner element.

func (AliasesID) Validate

func (a AliasesID) Validate() error

Validate enforces the two RFC 9493 §3.2.8 rules on aliases: the identifiers array must be non-empty, and no element may itself be an aliases identifier (no nesting). Inner errors from per-element Validate calls are joined; callers branch via errors.Is against the package sentinels.

Example

ExampleAliasesID_Validate shows the composite rule from RFC 9493 §3.2.8: the identifiers slice must be non-empty AND no element may itself be an aliases identifier. Inner-element errors join into the returned error so errors.Is surfaces any inner-format sentinel.

package main

import (
	"fmt"

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

func main() {
	id := subjectid.AliasesID{
		Identifiers: []subjectid.SubjectIdentifier{
			subjectid.EmailID{Email: "user@example.com"},
			subjectid.AccountID{URI: "acct:user@example.com"},
		},
	}
	fmt.Println(id.Validate())
}
Output:
<nil>
Example (NestedRejected)

ExampleAliasesID_Validate_nestedRejected shows the no-nested- aliases rule firing: the outer aliases parses and validates individually-valid inner elements, but a nested AliasesID is rejected with subjectid.ErrNestedAliases.

package main

import (
	"errors"
	"fmt"

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

func main() {
	id := subjectid.AliasesID{
		Identifiers: []subjectid.SubjectIdentifier{
			subjectid.AliasesID{Identifiers: []subjectid.SubjectIdentifier{
				subjectid.EmailID{Email: "user@example.com"},
			}},
		},
	}
	fmt.Println(errors.Is(id.Validate(), subjectid.ErrNestedAliases))
}
Output:
true

type Constructor

type Constructor func() SubjectIdentifier

Constructor is the per-format factory the registry holds. Each Constructor returns a freshly-allocated zero-valued SubjectIdentifier of the matching format type; the codec invokes UnmarshalJSON on the returned value to fill it in.

Constructors registered for non-built-in formats must return a type that embeds Seal so it satisfies the sealed interface.

The returned value is conventionally a pointer so the codec can populate it via UnmarshalJSON, but Parse normalizes the result to its canonical value form before returning it. The extension type must therefore satisfy SubjectIdentifier with value receivers, so the dereferenced value still implements the interface; a type whose methods use pointer receivers would not survive that normalization.

type DIDID

type DIDID struct {
	// URL is the DID URL. It is the value of the JSON "url"
	// member; the grammar is the W3C DID Core URL —
	// did:method:method-specific-id[?query][#fragment]. The
	// library validates the top-level shell only in a later
	// commit; per-method validation (did:web, did:key, etc.) is
	// out of scope.
	URL string
}

DIDID identifies a subject by a W3C Decentralized Identifier (DID) URL, as defined in RFC 9493 §3.2.6.

Wire shape:

{
  "format": "did",
  "url":    "did:example:123456"
}

func (DIDID) Format

func (DIDID) Format() string

Format returns "did". See SubjectIdentifier.Format.

func (DIDID) MarshalJSON

func (d DIDID) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for DIDID. See RFC 9493 §3.2.6 for the wire shape.

func (*DIDID) UnmarshalJSON

func (d *DIDID) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for DIDID. See AccountID.UnmarshalJSON for the general contract.

func (DIDID) Validate

func (d DIDID) Validate() error

Validate enforces the RFC 9493 §3.2.6 wire shape: the "url" member must be non-empty and must match the W3C DID Core ABNF from did-url.abnf in its entirety. Per-method validation (did:web, did:key, etc.) is out of scope.

Example

ExampleDIDID_Validate shows the W3C DID Core URL shell check. Per-method validation (did:web, did:key, did:plc, …) is out of scope — the library validates the universal shape only.

package main

import (
	"fmt"

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

func main() {
	fmt.Println(subjectid.DIDID{URL: "did:example:123456"}.Validate())
}
Output:
<nil>

type EmailID

type EmailID struct {
	// Email is an RFC 5322 §3.4.1 addr-spec. It is the value of
	// the JSON "email" member.
	//
	// Per RFC 9493 §3.2.2, the recipient SHOULD apply its own
	// canonicalization (lowercasing the local-part, stripping
	// plus-addressing, etc.) — the library performs no
	// canonicalization on its own.
	Email string
}

EmailID identifies a subject by an email address, as defined in RFC 9493 §3.2.2.

Wire shape:

{
  "format": "email",
  "email":  "user@example.com"
}

func (EmailID) Format

func (EmailID) Format() string

Format returns "email". See SubjectIdentifier.Format.

func (EmailID) MarshalJSON

func (e EmailID) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for EmailID. See RFC 9493 §3.2.2 for the wire shape.

func (*EmailID) UnmarshalJSON

func (e *EmailID) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for EmailID. See AccountID.UnmarshalJSON for the general contract.

func (EmailID) Validate

func (e EmailID) Validate() error

Validate enforces the RFC 9493 §3.2.2 wire shape: the "email" member must be non-empty and must match the RFC 5322 §3.4.1 addr-spec grammar (narrowed per v0.1 policy — see addr-spec.abnf) in its entirety.

Example

ExampleEmailID_Validate shows the addr-spec syntax check firing for malformed input. The empty-string case returns subjectid.ErrRequired (a struct type recoverable via errors.As) rather than the format sentinel — "missing member" is a different rule than "syntax violation".

package main

import (
	"errors"
	"fmt"

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

func main() {
	bad := subjectid.EmailID{Email: ""}
	err := bad.Validate()

	var req subjectid.ErrRequired
	if errors.As(err, &req) {
		fmt.Println("missing:", req.Fields)
	}
}
Output:
missing: [email]

type ErrRequired

type ErrRequired struct {
	// Fields names the wire-shape members that were missing or
	// empty. An empty slice is used when the failing field set
	// is not known.
	Fields []string
}

ErrRequired reports that one or more required members of the wire shape were missing or empty. RFC 9493 §3 requires every format's defining members to be present and non-empty.

Fields names the specific member(s) that were missing — values like "phone_number", "url", or []string{"iss", "sub"}. Use errors.As to recover them:

var req subjectid.ErrRequired
if errors.As(err, &req) {
    log.Printf("missing fields: %v", req.Fields)
}

errors.Is also matches against the zero value, so categorical checks work without naming the fields:

if errors.Is(err, subjectid.ErrRequired{}) { ... }

And against the top-level package umbrella:

if errors.Is(err, subjectid.Err) { ... }

func (ErrRequired) Error

func (e ErrRequired) Error() string

Error implements the error interface.

func (ErrRequired) Is

func (ErrRequired) Is(target error) bool

Is matches any ErrRequired regardless of Fields (so callers can write errors.Is(err, ErrRequired{}) for a categorical check) and also matches the package umbrella Err.

type IssSubID

type IssSubID struct {
	// Iss is the JWT issuer URI. It is the value of the JSON
	// "iss" member and a JSON-string-encoded URI.
	Iss string

	// Sub is the issuer-scoped subject identifier. It is the
	// value of the JSON "sub" member; its grammar is the
	// issuer's choice.
	Sub string
}

IssSubID identifies a subject by a JWT-style (issuer, subject) pair, as defined in RFC 9493 §3.2.3. The pair is also the iss and sub claim pair from RFC 7519.

Wire shape:

{
  "format": "iss_sub",
  "iss":    "https://issuer.example.com/",
  "sub":    "145234573"
}

func (IssSubID) Format

func (IssSubID) Format() string

Format returns "iss_sub". See SubjectIdentifier.Format.

func (IssSubID) MarshalJSON

func (i IssSubID) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for IssSubID. See RFC 9493 §3.2.3 — iss precedes sub, matching the JWT claim order.

func (*IssSubID) UnmarshalJSON

func (i *IssSubID) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for IssSubID. See AccountID.UnmarshalJSON for the general contract.

func (IssSubID) Validate

func (i IssSubID) Validate() error

Validate enforces the RFC 9493 §3.2.3 wire shape: both "iss" and "sub" must be non-empty; "iss" must be an RFC 3986 absolute-URI per the iss.abnf grammar; "sub" may be any printable issuer-chosen identifier per the sub.abnf grammar.

Example

ExampleIssSubID_Validate shows the per-member check on the iss_sub composite: both "iss" and "sub" must be non-empty, and "iss" must be an RFC 3986 absolute URI.

package main

import (
	"fmt"

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

func main() {
	id := subjectid.IssSubID{
		Iss: "https://issuer.example.com/",
		Sub: "145234573",
	}
	fmt.Println(id.Validate())
}
Output:
<nil>

type OpaqueID

type OpaqueID struct {
	// ID is the opaque identifier. It is the value of the JSON
	// "id" member and has no syntax constraints beyond being a
	// non-empty JSON string. The non-empty rule is enforced by
	// Validate and reported as [ErrOpaqueEmpty].
	ID string
}

OpaqueID identifies a subject by a case-sensitive opaque string, as defined in RFC 9493 §3.2.4.

Wire shape:

{
  "format": "opaque",
  "id":     "11112222333344445555"
}

func (OpaqueID) Format

func (OpaqueID) Format() string

Format returns "opaque". See SubjectIdentifier.Format.

func (OpaqueID) MarshalJSON

func (o OpaqueID) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for OpaqueID. See RFC 9493 §3.2.4 for the wire shape.

func (*OpaqueID) UnmarshalJSON

func (o *OpaqueID) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for OpaqueID. See AccountID.UnmarshalJSON for the general contract.

func (OpaqueID) Validate

func (o OpaqueID) Validate() error

Validate enforces the RFC 9493 §3.2.4 non-empty-ID rule. The returned error is ErrOpaqueEmpty rather than ErrRequired so callers can distinguish "id was present but empty" from "id member was missing entirely from the JSON wire shape".

Example

ExampleOpaqueID_Validate shows the only failure mode: subjectid.ErrOpaqueEmpty when ID is the empty string. The opaque format imposes no syntax beyond non-emptiness.

package main

import (
	"errors"
	"fmt"

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

func main() {
	fmt.Println(errors.Is(subjectid.OpaqueID{}.Validate(), subjectid.ErrOpaqueEmpty))
}
Output:
true

type PhoneNumberID

type PhoneNumberID struct {
	// PhoneNumber is the E.164 number. It is the value of the
	// JSON "phone_number" member: a leading "+" followed by
	// 4 to 15 ASCII digits.
	PhoneNumber string
}

PhoneNumberID identifies a subject by an ITU-T E.164 phone number, as defined in RFC 9493 §3.2.5.

The Phone Number Identifier Format identifies a subject using a telephone number. Subject Identifiers in this format MUST contain a "phone_number" member whose value is a string containing the full telephone number of the subject, including an international dialing prefix, formatted according to E.164 [E164]. The "phone_number" member is REQUIRED and MUST NOT be null or empty. The Phone Number Identifier Format is identified by the name "phone_number".

Below is a non-normative example Subject Identifier in the Phone Number Identifier Format:

{
  "format": "phone_number",
  "phone_number": "+12065550100"
}

Figure 8: Example: Subject Identifier in the Phone Number Identifier Format

func (PhoneNumberID) Format

func (PhoneNumberID) Format() string

Format returns "phone_number". See SubjectIdentifier.Format.

func (PhoneNumberID) MarshalJSON

func (p PhoneNumberID) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for PhoneNumberID. See RFC 9493 §3.2.5 for the wire shape.

func (*PhoneNumberID) UnmarshalJSON

func (p *PhoneNumberID) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for PhoneNumberID. See AccountID.UnmarshalJSON for the general contract.

func (PhoneNumberID) Validate

func (p PhoneNumberID) Validate() error

Validate enforces the RFC 9493 §3.2.5 wire shape: "phone_number" must be a non-empty string matching the basic E.164 shell — "+" followed by 4 to 15 ASCII digits with no separators. Returns MissingFields("phone_number") for the empty value and ErrFormatPhoneNumber for shape violations.

Example

ExamplePhoneNumberID_Validate shows the E.164 shell check — "+" followed by 4 to 15 ASCII digits, with no separators. The library does NOT run full per-country plan validation; install a [WithPhoneNumberValidator]-style hook (see godoc) for that.

package main

import (
	"fmt"

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

func main() {
	fmt.Println(subjectid.PhoneNumberID{PhoneNumber: "+12065550100"}.Validate())
}
Output:
<nil>

type Seal

type Seal = sealMarker

Seal is the embeddable marker that lets a type defined outside this package satisfy SubjectIdentifier when registered via RegisterFormat. Embed it as an anonymous field in your custom format type to inherit the unexported sealed marker method.

type OrgTenantID struct {
    subjectid.Seal
    Tenant string
}

func (OrgTenantID) Format() string { return "org.example.tenant" }
func (OrgTenantID) Validate() error { return nil }

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

The unexported sealed method is promoted to the embedding type, so it satisfies the interface's seal without allowing arbitrary external implementations — any extension type must opt in by embedding Seal.

The built-in format types do not use Seal; they implement sealed directly because they live inside this package.

type SubjectIdentifier

type SubjectIdentifier interface {
	// Format returns the IANA "format" discriminator for this
	// identifier — "email", "iss_sub", "aliases", and so on. The
	// returned value is also the value of the "format" member in
	// the JSON wire shape.
	Format() string

	// Validate reports whether the identifier satisfies the
	// RFC 9493 well-formedness rules for its format. A nil return
	// means valid; a non-nil return is one of the package's
	// sentinel errors — the per-format [FormatErr]-built sentinel
	// (e.g. [ErrFormatPhoneNumber]), [ErrRequired] (recoverable
	// via [errors.As] to learn which field was missing), or a
	// format-specific sentinel like [ErrOpaqueEmpty] or
	// [ErrNestedAliases]. Every return value matches the umbrella
	// [Err] under [errors.Is].
	Validate() error
	// contains filtered or unexported methods
}

SubjectIdentifier is the sealed interface implemented by every RFC 9493 Subject Identifier value the package exposes.

Implementations are confined to this package by the unexported sealed marker method. Downstream code that needs a format outside the built-in set calls RegisterFormat (defined in a later commit) rather than implementing the interface directly: registration feeds the codec dispatch table, whereas a direct implementation would be invisible to it.

Canonical dynamic form: every value this package produces is the concrete value type, never a pointer to it. Parse returns IssSubID, not *IssSubID, so the dynamic type read back from Parse is identical to the struct literal a caller writes by hand. Consumers branching with a type switch or assertion should match the value forms (case IssSubID, case AliasesID, …); the pointer forms never occur on a value obtained from this package's API.

func Parse

func Parse(raw json.RawMessage) (SubjectIdentifier, error)

Parse decodes a Subject Identifier from raw JSON, dispatching on the value of the "format" member to the appropriate concrete type. The returned identifier is always in value form (e.g. IssSubID, never *IssSubID) — the canonical dynamic form for every value this package produces, matching what a caller constructs as a struct literal. See SubjectIdentifier.

  • For a built-in format (account, email, iss_sub, opaque, phone_number, did, uri, aliases), the matching per-format type is returned with its members populated from the wire.
  • For an extension format registered via RegisterFormat, the registered constructor is invoked and json.Unmarshal delegates to its UnmarshalJSON.
  • For a format string the registry does not know, an UnknownFormat is returned carrying the original bytes verbatim. The library never errors on an unrecognized format — that is the forward-compatibility contract.

Errors are reserved for two conditions only:

  • The bytes are not valid JSON, or the "format" member is not present or not a string. In the first case the error wraps ErrJSON; in the second it is ErrRequired with "format" in its Fields. Both match Err under errors.Is.
  • A registered constructor's UnmarshalJSON returns an error. The error is returned verbatim.

Extra members in the JSON object that are not defined for the format are silently dropped, per RFC 9493's "be liberal in what you accept" reading of the wire shape. Strict checking happens at marshal time, not at unmarshal.

Example

ExampleParse shows the discriminator-driven decode path: subjectid.Parse peeks at the wire's "format" member and dispatches to the matching concrete type, returning a value that satisfies subjectid.SubjectIdentifier.

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)
	}

	email := id.(subjectid.EmailID)
	fmt.Println(id.Format(), email.Email)
}
Output:
email user@example.com
Example (UnknownFormat)

ExampleParse_unknownFormat shows the forward-compatibility path: a format the library does not recognize parses into subjectid.UnknownFormat carrying the original bytes verbatim, so the payload still round-trips.

package main

import (
	"encoding/json"
	"fmt"

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

func main() {
	wire := []byte(`{"format":"org.example.future","widget_id":42}`)

	id, err := subjectid.Parse(json.RawMessage(wire))
	if err != nil {
		panic(err)
	}

	unk := id.(subjectid.UnknownFormat)
	out, _ := json.Marshal(unk)
	fmt.Println(unk.FormatName)
	fmt.Println(string(out))
}
Output:
org.example.future
{"format":"org.example.future","widget_id":42}

type URIID

type URIID struct {
	// URI is the absolute RFC 3986 URI naming the subject. It is
	// the value of the JSON "uri" member; the library validates
	// via net/url.Parse plus a strictness wrapper in a later
	// commit (absolute URI, non-empty scheme).
	URI string
}

URIID identifies a subject by any RFC 3986 URI, as defined in RFC 9493 §3.2.7. It is the "use when nothing more specific applies" fallback within the built-in set.

Wire shape:

{
  "format": "uri",
  "uri":    "urn:oasis:names:tc:saml:2.0:nameid-format:transient"
}

func (URIID) Format

func (URIID) Format() string

Format returns "uri". See SubjectIdentifier.Format.

func (URIID) MarshalJSON

func (u URIID) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for URIID. See RFC 9493 §3.2.7 for the wire shape.

func (*URIID) UnmarshalJSON

func (u *URIID) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for URIID. See AccountID.UnmarshalJSON for the general contract.

func (URIID) Validate

func (u URIID) Validate() error

Validate enforces the RFC 9493 §3.2.7 wire shape: the "uri" member must be non-empty and must match the RFC 3986 §4.3 absolute-URI grammar in its entirety.

Example

ExampleURIID_Validate shows the RFC 3986 absolute-URI check. "uri" is the fallback format for identifiers without a more specific format in the IANA registry.

package main

import (
	"fmt"

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

func main() {
	id := subjectid.URIID{URI: "urn:oasis:names:tc:saml:2.0:nameid-format:transient"}
	fmt.Println(id.Validate())
}
Output:
<nil>

type UnknownFormat

type UnknownFormat struct {
	// FormatName is the value of the JSON "format" member as
	// decoded — the discriminator the library did not recognize.
	FormatName string

	// Raw is the entire JSON object that was decoded, byte-for-
	// byte as it appeared on the wire. Re-encoding returns these
	// bytes unchanged.
	Raw json.RawMessage
}

UnknownFormat carries a Subject Identifier whose format is not in this build's built-in set and was not registered via RegisterFormat. It is the package's forward-compatibility carrier: a library compiled today, decoding a payload that uses a format the IANA registry adds in 2027, returns an UnknownFormat rather than an error so the value can still round-trip and the caller can branch on whatever subset of formats it actually understands.

The wire bytes are preserved verbatim in Raw so that re-encoding produces the original payload byte-for-byte (modulo JSON whitespace canonicalization). Raw is intentionally a json.RawMessage rather than a map[string]any: interop scenarios often pin exact JSON bytes, and a map reorders its keys on every encode.

func (UnknownFormat) Format

func (u UnknownFormat) Format() string

Format returns the unrecognized format discriminator. The returned value is the FormatName field, not a fixed string — UnknownFormat is the one type whose Format method varies per value. See SubjectIdentifier.Format.

func (UnknownFormat) MarshalJSON

func (u UnknownFormat) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for UnknownFormat by returning the Raw bytes verbatim when populated, so the wire payload round-trips through the codec. Note that encoding/json.Marshal runs json.Compact over a json.Marshaler's output, so insignificant whitespace inside Raw is normalized on the way out — the surrounding payload sees canonical JSON. Round-trip byte stability therefore holds modulo json.Compact, matching the conformance test suite's expectation.

Two edge cases:

  • Raw is non-empty but not a valid JSON object: an error is returned. UnknownFormat's contract is "round-trip valid JSON the library does not recognize"; emitting invalid JSON would silently corrupt the surrounding payload.
  • Raw is empty: a minimal {"format":"<FormatName>"} object is emitted. If FormatName is also empty an error is returned — there is no Subject Identifier to write.

Mutating FormatName after a Parse does not change the emitted bytes (Raw is authoritative when present); to change the wire format string, set Raw to nil before marshaling.

func (*UnknownFormat) UnmarshalJSON

func (u *UnknownFormat) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for UnknownFormat. Stores the "format" discriminator in FormatName and the entire input bytes verbatim in Raw so MarshalJSON (a later commit) can re-emit byte-for-byte.

UnknownFormat is normally produced by Parse for unrecognized formats; this method is provided so an external caller can also json.Unmarshal directly into an UnknownFormat target when its purpose is to round-trip the bytes whole.

func (UnknownFormat) Validate

func (UnknownFormat) Validate() error

Validate is a no-op. UnknownFormat exists precisely to round- trip values whose well-formedness rules the library does not know; performing validation on it would defeat the purpose of the carrier.

Directories

Path Synopsis
internal
specfixtures
Package specfixtures embeds the eight illustrative Subject Identifier payloads from RFC 9493 §3 as canonical (compact) JSON so the parent package's conformance tests can exercise every built-in format against the actual spec wording.
Package specfixtures embeds the eight illustrative Subject Identifier payloads from RFC 9493 §3 as canonical (compact) JSON so the parent package's conformance tests can exercise every built-in format against the actual spec wording.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL