token

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package token — the immutable claim set every concrete token format decodes into and every issuer renders from.

Package token — the copy-on-write setters that build a ClaimsValue.

Package token — range 0.2.13.* (ADR 0042 core/token block).

Package token — declares the sentinel *errs.Error verdicts an issuer or a verifier returns. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

The Public strings are the half a third party reads (ADR 0005 §4). They name WHICH check refused the token and never echo a value from it: not the audience, not the issuer, not the key id, not a claim. A rejection message that quotes the token's own contents back is a mirror an attacker can query.

Package token declares the security-token domain: the Issuer and Verifier ports, the immutable ClaimsValue every concrete format decodes into, and the typed verdicts a caller matches on. Concrete formats — JWT/JWS compact (RFC 7519) and PASETO v4 — live in internal/service/token; this package owns only the contract, so a caller can hold a Verifier without knowing which wire format produced it.

There is no registry, and that is the security decision

Every other pluggable SDK domain resolves an implementation through a process-wide registry keyed on a name. A token registry would be keyed on the "alg" header — a field the ATTACKER writes. Resolving the verifying algorithm from that field is algorithm confusion, the class of bug where an RSA/EC public key (public by definition) is fed to HMAC-SHA-256 as a shared secret and the forgery verifies. So the algorithm is bound at construction, by a constructor that only accepts the ONE key type that algorithm can use, and the header is only ever compared against that binding — never consulted to choose it. See ADR 0042.

Algorithm is a uint8 enum rather than a string for the same reason: the unsecured "none" algorithm of RFC 7519 §6 has no representation in this type, so no call site can request it and no configuration can enable it. A token whose header says "none" is refused with AlgorithmNone.

Index

Constants

View Source
const (
	// ClaimIssuer is "iss" — who minted the token.
	ClaimIssuer string = "iss"
	// ClaimSubject is "sub" — who the token is about.
	ClaimSubject string = "sub"
	// ClaimAudience is "aud" — who the token is FOR. Checked, never assumed.
	ClaimAudience string = "aud"
	// ClaimExpiry is "exp" — the instant after which the token is dead.
	ClaimExpiry string = "exp"
	// ClaimNotBefore is "nbf" — the instant before which it is not yet alive.
	ClaimNotBefore string = "nbf"
	// ClaimIssuedAt is "iat" — when it was minted.
	ClaimIssuedAt string = "iat"
	// ClaimID is "jti" — a unique id for the token.
	ClaimID string = "jti"
)

Registered claim names (RFC 7519 §4.1). PASETO v4 reuses the same seven names, with a different value encoding — see internal/service/token.

View Source
const CodeAlgorithmMismatch errs.Code = 0x00_02_0D_03 // 0.2.13.3

CodeAlgorithmMismatch identifies a token whose algorithm header names an algorithm other than the one the verifier is bound to. This is the code an algorithm-confusion attempt produces.

View Source
const CodeAlgorithmNone errs.Code = 0x00_02_0D_02 // 0.2.13.2

CodeAlgorithmNone identifies a token presenting the unsecured "none" algorithm of RFC 7519 §6 (in any capitalisation). It is always refused and there is no configuration that enables it.

View Source
const CodeAudienceMismatch errs.Code = 0x00_02_0D_08 // 0.2.13.8

CodeAudienceMismatch identifies an authenticated token whose "aud" claim does not contain the audience the verifier was configured with.

View Source
const CodeClaimNameInvalid errs.Code = 0x00_02_0D_0F // 0.2.13.15

CodeClaimNameInvalid identifies a private claim whose name is empty or shadows one of the seven registered claim names.

View Source
const CodeExpired errs.Code = 0x00_02_0D_05 // 0.2.13.5

CodeExpired identifies an authenticated token whose "exp" claim is in the past, after the configured leeway.

View Source
const CodeExpiryRequired errs.Code = 0x00_02_0D_07 // 0.2.13.7

CodeExpiryRequired identifies a token carrying no "exp" claim where the profile requires one — the default. It is also raised by an issuer asked to mint a token with neither an explicit expiry nor a configured lifetime.

View Source
const CodeIssueFailed errs.Code = 0x00_02_0D_0E // 0.2.13.14

CodeIssueFailed identifies an issuer that could not render the claims it was given — a claim the target format cannot express, or a signing fault.

View Source
const CodeIssuerMismatch errs.Code = 0x00_02_0D_09 // 0.2.13.9

CodeIssuerMismatch identifies an authenticated token whose "iss" claim is not the issuer the verifier was configured with.

View Source
const CodeKeyUnsuitable errs.Code = 0x00_02_0D_0C // 0.2.13.12

CodeKeyUnsuitable identifies a key that cannot be used for the algorithm it was handed to: the wrong length, the wrong curve, or a key type for which this domain implements no algorithm.

View Source
const CodeLifetimeTooLong errs.Code = 0x00_02_0D_10 // 0.2.13.16

CodeLifetimeTooLong identifies an authenticated token whose exp-iat span exceeds the maximum lifetime the verifier accepts.

View Source
const CodeMalformed errs.Code = 0x00_02_0D_01 // 0.2.13.1

CodeMalformed identifies a token whose structure could not be read at all: the wrong number of segments, a segment that is not unpadded base64url, a header or payload that is not a JSON object.

View Source
const CodeNotYetValid errs.Code = 0x00_02_0D_06 // 0.2.13.6

CodeNotYetValid identifies an authenticated token whose "nbf" claim is in the future, after the configured leeway.

View Source
const CodePolicyMisconfigured errs.Code = 0x00_02_0D_0D // 0.2.13.13

CodePolicyMisconfigured identifies an issuer or verifier built with a configuration it cannot honour — a negative or absurd leeway, a negative lifetime, a bound below the floor. Every call is refused without touching the token (ADR 0031).

View Source
const CodeSignatureInvalid errs.Code = 0x00_02_0D_04 // 0.2.13.4

CodeSignatureInvalid identifies a token whose signature or MAC tag did not authenticate under the bound key.

View Source
const CodeTooDeep errs.Code = 0x00_02_0D_0B // 0.2.13.11

CodeTooDeep identifies a claims payload whose JSON nesting exceeds the configured depth bound.

View Source
const CodeTooLarge errs.Code = 0x00_02_0D_0A // 0.2.13.10

CodeTooLarge identifies an input past a declared size bound: the token string, the decoded header, or the number of claims.

View Source
const MaxPrivateClaims int = 64

MaxPrivateClaims caps how many application claims one token may carry. The number is a bound, not a budget: a claim set is an authorisation statement, and a token that needs more than this many members is describing a database row rather than a subject. The cap keeps a hostile payload's decode cost linear and bounded, which is the same reason the wire size is capped.

Variables

View Source
var (
	// Malformed is returned when the input cannot be read as a token: the
	// wrong segment count, a segment that is not unpadded base64url, a header
	// or payload that is not a JSON object.
	Malformed = errs.Define(CodeMalformed, "MALFORMED",
		"The token is malformed",
		"core/token: token structure unreadable — segment count, base64url decode, or JSON shape",
		errs.WithExitCode(exitDataErr), errs.WithHTTPStatus(statusUnauthorized))

	// AlgorithmNone is returned for a token presenting the unsecured "none"
	// algorithm (RFC 7519 §6), in any capitalisation. It is unconditional:
	// this domain has no Algorithm value for "none" and no option enabling it.
	AlgorithmNone = errs.Define(CodeAlgorithmNone, "ALGORITHM_NONE",
		"The token declares the unsecured none algorithm",
		"core/token: alg=none refused unconditionally; there is no configuration that accepts it",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))

	// AlgorithmMismatch is returned when the token's algorithm header names
	// something other than the algorithm the verifier is bound to. It is the
	// verdict an algorithm-confusion attempt produces, and it is reached
	// BEFORE any key is handed to any primitive.
	AlgorithmMismatch = errs.Define(CodeAlgorithmMismatch, "ALGORITHM_MISMATCH",
		"The token algorithm is not the one this verifier accepts",
		"core/token: header alg differs from the bound algorithm; the header never selects a key",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))

	// SignatureInvalid is returned when the signature or MAC tag did not
	// authenticate the token under the bound key. It is deliberately the same
	// verdict for "wrong key" and "tampered bytes": the two are the same event.
	SignatureInvalid = errs.Define(CodeSignatureInvalid, "SIGNATURE_INVALID",
		"The token signature is not valid",
		"core/token: signature or MAC tag did not verify under the bound key",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))

	// Expired is returned when an AUTHENTICATED token's exp claim is in the
	// past. Reaching it means the signature already verified — claim verdicts
	// are never reported for a token that failed authentication.
	Expired = errs.Define(CodeExpired, "EXPIRED",
		"The token has expired",
		"core/token: exp is in the past after leeway; signature had already verified",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))

	// NotYetValid is returned when an authenticated token's nbf claim is in
	// the future after leeway.
	NotYetValid = errs.Define(CodeNotYetValid, "NOT_YET_VALID",
		"The token is not valid yet",
		"core/token: nbf is in the future after leeway",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))

	// ExpiryRequired is returned when a token carries no exp claim and the
	// profile requires one — which is the default, because RFC 7519 §4.1.4
	// makes exp OPTIONAL and a token that never expires is a password.
	ExpiryRequired = errs.Define(CodeExpiryRequired, "EXPIRY_REQUIRED",
		"The token carries no expiry",
		"core/token: exp absent and the profile requires it; set AllowMissingExpiry to opt out",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))

	// AudienceMismatch is returned when an authenticated token's aud claim
	// does not contain this recipient. A correctly signed token addressed to
	// another API is not a valid token here (RFC 8725 §3.9).
	AudienceMismatch = errs.Define(CodeAudienceMismatch, "AUDIENCE_MISMATCH",
		"The token is not addressed to this audience",
		"core/token: configured audience absent from the aud claim",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))

	// IssuerMismatch is returned when an authenticated token's iss claim is
	// not the configured issuer (RFC 8725 §3.8).
	IssuerMismatch = errs.Define(CodeIssuerMismatch, "ISSUER_MISMATCH",
		"The token issuer is not the expected one",
		"core/token: iss differs from the configured issuer",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))

	// TooLarge is returned when an input passes a declared size bound before
	// any parsing happens — the token string, the decoded header, the claim
	// count. The bound is checked FIRST so a hostile input never funds the
	// work of discovering that it is hostile.
	TooLarge = errs.Define(CodeTooLarge, "TOO_LARGE",
		"The token exceeds the accepted size",
		"core/token: input past a declared bound (token length, header size, or claim count)",
		errs.WithExitCode(exitDataErr), errs.WithHTTPStatus(statusUnauthorized))

	// TooDeep is returned when the claims payload nests deeper than the
	// configured bound. The depth is measured by a linear scan of the raw
	// bytes, before encoding/json ever sees them.
	TooDeep = errs.Define(CodeTooDeep, "TOO_DEEP",
		"The token claims are nested too deeply",
		"core/token: claim nesting past MaxClaimDepth, measured before JSON decoding",
		errs.WithExitCode(exitDataErr), errs.WithHTTPStatus(statusUnauthorized))

	// KeyUnsuitable is returned when a key cannot serve the algorithm it was
	// handed to: wrong length, wrong curve, or a key type for which this
	// domain implements nothing.
	KeyUnsuitable = errs.Define(CodeKeyUnsuitable, "KEY_UNSUITABLE",
		"The key cannot be used for this token algorithm",
		"core/token: key type, curve or length does not match the bound algorithm",
		errs.WithExitCode(exitConfig))

	// PolicyMisconfigured is returned by every call to an issuer or verifier
	// built with a configuration it cannot honour. Like its resilience
	// namesake it is permanent, not transient: the fix is at the construction
	// site, never a retry (ADR 0031).
	PolicyMisconfigured = errs.Define(CodePolicyMisconfigured, "POLICY_MISCONFIGURED",
		"The token policy is misconfigured",
		"core/token: issuer or verifier built with a configuration it cannot honour; fields name the knob",
		errs.WithExitCode(exitConfig))

	// IssueFailed is returned when an issuer cannot render the claims it was
	// given — a claim the target format cannot express, or a signing fault.
	IssueFailed = errs.Define(CodeIssueFailed, "ISSUE_FAILED",
		"The token could not be issued",
		"core/token: claims unrenderable in the target format, or the signing primitive failed",
		errs.WithExitCode(exitConfig))

	// ClaimNameInvalid is returned by ClaimsValue.WithPrivateRaw for an empty
	// name or one shadowing a registered claim.
	ClaimNameInvalid = errs.Define(CodeClaimNameInvalid, "CLAIM_NAME_INVALID",
		"The claim name is empty or reserved",
		"core/token: private claim name empty or colliding with a registered claim name",
		errs.WithExitCode(exitDataErr))

	// LifetimeTooLong is returned when an authenticated token's exp-iat span
	// exceeds the maximum the verifier accepts. A long-lived bearer token is
	// a credential with no revocation story, so a recipient is allowed to say
	// no to one even when its issuer said yes.
	LifetimeTooLong = errs.Define(CodeLifetimeTooLong, "LIFETIME_TOO_LONG",
		"The token lifetime exceeds what this recipient accepts",
		"core/token: exp-iat span past MaxLifetime",
		errs.WithExitCode(exitNoPerm), errs.WithHTTPStatus(statusUnauthorized))
)

Functions

func IsRegisteredClaim

func IsRegisteredClaim(name string) bool

IsRegisteredClaim reports whether name is one of the seven registered claim names this domain models itself.

Types

type Algorithm

type Algorithm uint8

Algorithm is the signature algorithm a token issuer or verifier is bound to. The zero value is AlgorithmUnknown and is never usable.

The set is closed and deliberately small: an algorithm reaches this enum only when the SDK's crypto domain already ships the primitive behind it. "none" is absent by construction.

const (
	// AlgorithmUnknown is the zero value — the algorithm nobody chose. Every
	// constructor refuses it rather than defaulting to one.
	AlgorithmUnknown Algorithm = iota
	// AlgorithmHS256 is JOSE "HS256": HMAC-SHA-256 over a 256-bit shared
	// secret (RFC 7518 §3.2). Symmetric — a verifier can also mint tokens.
	AlgorithmHS256
	// AlgorithmES256 is JOSE "ES256": ECDSA on NIST P-256 with SHA-256 and the
	// fixed-width R||S signature encoding of RFC 7518 §3.4.
	AlgorithmES256
	// AlgorithmEdDSA is JOSE "EdDSA": Ed25519 over the JWS signing input
	// (RFC 8037 §3.1).
	AlgorithmEdDSA
	// AlgorithmPasetoV4Public is PASETO v4.public: Ed25519 over the
	// pre-authentication encoding of the version header, payload and footer.
	// It is a distinct Algorithm from AlgorithmEdDSA even though both use
	// Ed25519, because the bytes that get signed are not the same bytes.
	AlgorithmPasetoV4Public
)

func (Algorithm) Known

func (a Algorithm) Known() bool

Known reports whether a names an algorithm this domain implements. It is the guard every constructor runs before binding a key, so an out-of-range value is refused at construction rather than at the first verification.

func (Algorithm) String

func (a Algorithm) String() string

String reports the wire name of a — the JOSE "alg" header value for the JWS algorithms, and the version+purpose header for PASETO. An unknown Algorithm renders "unknown", which matches no wire name, so a zero value can never be mistaken for a real header on either side of a comparison.

type ClaimsValue

type ClaimsValue struct {
	// contains filtered or unexported fields
}

ClaimsValue is one token's claim set: the seven registered claims as typed fields plus any number of application ("private") claims held as their raw JSON encoding.

It is an immutable value. Accessors copy every slice and map out, the With* methods return a modified copy rather than mutating the receiver, and every field is unexported so encoding/json cannot reach the contents by reflection — a claim set routinely carries personal data, and the only way it should ever be serialised is through a format package that knows what it is doing.

Private claims are RAW JSON, not decoded values: this is the core layer, and "what shape is a scope claim" is the caller's question, not the domain's. Decode one with pkg/v1/token.PrivateClaim.

func NewClaimsValue

func NewClaimsValue() ClaimsValue

NewClaimsValue returns an empty claim set to build on. The zero ClaimsValue is equally valid and equally empty; this constructor exists so a call site reads as a statement of intent rather than a struct literal.

pkg/v1/token re-exports it as the shorter NewClaims, since the Value suffix is a core-layer naming rule (KTN-STRUCT-ROLE) and not a consumer's concern.

func (ClaimsValue) Audience

func (c ClaimsValue) Audience() []string

Audience returns a copy of the "aud" claim, nil when the token carried none.

func (ClaimsValue) Expiry

func (c ClaimsValue) Expiry() time.Time

Expiry reports the "exp" claim. The zero Time means the claim was ABSENT, never "expired at the epoch" — a verifier decides what to do about absence (see VerifierConfig.AllowMissingExpiry in internal/service/token).

func (ClaimsValue) GoString

func (c ClaimsValue) GoString() string

GoString implements fmt.GoStringer so %#v stays redacted too — fmt bypasses String for Go-syntax formatting and would otherwise dump every field.

func (ClaimsValue) ID

func (c ClaimsValue) ID() string

ID reports the "jti" claim, empty when the token carried none.

func (ClaimsValue) IsZero

func (c ClaimsValue) IsZero() bool

IsZero reports whether c carries no claim at all.

func (ClaimsValue) IssuedAt

func (c ClaimsValue) IssuedAt() time.Time

IssuedAt reports the "iat" claim; the zero Time means it was absent.

func (ClaimsValue) Issuer

func (c ClaimsValue) Issuer() string

Issuer reports the "iss" claim, empty when the token carried none.

func (ClaimsValue) NotBefore

func (c ClaimsValue) NotBefore() time.Time

NotBefore reports the "nbf" claim; the zero Time means it was absent.

func (ClaimsValue) PrivateNames

func (c ClaimsValue) PrivateNames() []string

PrivateNames returns the application claim names, sorted, so a caller enumerating them gets a stable order rather than Go's randomised map order.

func (ClaimsValue) PrivateRaw

func (c ClaimsValue) PrivateRaw(name string) (raw []byte, found bool)

PrivateRaw returns a copy of the raw JSON encoding of the named application claim. The second result reports presence, so a claim explicitly set to JSON null is distinguishable from a claim that was never sent.

func (ClaimsValue) String

func (c ClaimsValue) String() string

String implements fmt.Stringer and renders the SHAPE of the claim set, never a value: "token.Claims{registered:3 aud:1 private:2}".

A claim set is the output of an authentication decision and routinely holds a subject id, an email, a tenant, a scope list. Rendering any of it would put that in whichever log line happened to use %v — which is exactly how claim contents end up in a log aggregator that was never scoped to hold them. The counts are enough to tell "the token had no audience" from "the audience did not match", which is what a %v is usually reaching for.

func (ClaimsValue) Subject

func (c ClaimsValue) Subject() string

Subject reports the "sub" claim, empty when the token carried none.

func (ClaimsValue) WithAudience

func (c ClaimsValue) WithAudience(aud ...string) ClaimsValue

WithAudience returns a copy of c whose "aud" claim is exactly aud. Passing no argument clears the claim; the slice is copied, so a later mutation of the caller's backing array cannot reach the claim set.

func (ClaimsValue) WithExpiry

func (c ClaimsValue) WithExpiry(exp time.Time) ClaimsValue

WithExpiry returns a copy of c carrying exp. The zero Time clears the claim, which a verifier reads as "no expiry was sent" — not as "expired".

func (ClaimsValue) WithID

func (c ClaimsValue) WithID(jti string) ClaimsValue

WithID returns a copy of c carrying jti. The empty string clears it.

func (ClaimsValue) WithIssuedAt

func (c ClaimsValue) WithIssuedAt(iat time.Time) ClaimsValue

WithIssuedAt returns a copy of c carrying iat. The zero Time clears it.

func (ClaimsValue) WithIssuer

func (c ClaimsValue) WithIssuer(iss string) ClaimsValue

WithIssuer returns a copy of c carrying iss. The empty string clears it.

func (ClaimsValue) WithNotBefore

func (c ClaimsValue) WithNotBefore(nbf time.Time) ClaimsValue

WithNotBefore returns a copy of c carrying nbf. The zero Time clears it.

func (ClaimsValue) WithPrivateRaw

func (c ClaimsValue) WithPrivateRaw(name string, raw []byte) (claims ClaimsValue, err error)

WithPrivateRaw returns a copy of c carrying the application claim name with the raw JSON encoding raw.

It refuses three things rather than accommodating them:

  • an empty name, which no JSON object member can carry meaningfully;
  • one of the seven registered names (IsRegisteredClaim) — a second "exp" that the temporal validator never reads is a claim an application would trust and nothing would enforce;
  • more than MaxPrivateClaims members, so the decode cost of a hostile payload stays bounded.

raw is copied and is NOT validated as JSON here: the format package that produced it already parsed it, and re-parsing in the value type would put a second, differently-strict JSON reader on the trusted path.

func (ClaimsValue) WithSubject

func (c ClaimsValue) WithSubject(sub string) ClaimsValue

WithSubject returns a copy of c carrying sub. The empty string clears it.

type Issuer

type Issuer interface {
	// Issue renders claims as a token string, or returns a typed verdict
	// (IssueFailed / ExpiryRequired / KeyUnsuitable) when it cannot.
	Issue(claims ClaimsValue) (string, error)
}

Issuer mints a signed token carrying claims. Implementations are bound to exactly one Algorithm and one signing key at construction, MUST be safe for concurrent use, and MUST NOT take an algorithm or a key from the claims.

IFACE-PLUGIN: constructors in internal/service/token hand instances back behind this interface; the concrete types stay unexported.

type Verifier

type Verifier interface {
	// Verify authenticates token and returns its claims, or one of the typed
	// verdicts declared in this package.
	Verify(token string) (ClaimsValue, error)
}

Verifier authenticates a token and returns its claims. Implementations are bound to exactly one Algorithm and one verification key at construction and MUST be safe for concurrent use.

The contract has three parts, and all three are security requirements:

  1. The signature is checked BEFORE any claim is read, so a caller never acts on — or logs — the contents of an unauthenticated token.
  2. The token's own algorithm header is compared against the binding and is never used to select a key, an algorithm, or a code path.
  3. Failure returns exactly one typed verdict and a zero ClaimsValue. There is no "invalid, but here are the claims anyway" path.

IFACE-PLUGIN: constructors in internal/service/token hand instances back behind this interface; the concrete types stay unexported.

Jump to

Keyboard shortcuts

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