Documentation
¶
Overview ¶
Package jwt signs and verifies JSON Web Tokens (HS256) isomorphically: the same code runs on the native backend and inside a WASM/edge binary.
The library is deliberately small and closed: HS256 only, one claim set, no algorithm negotiation. See docs/ARCHITECTURE.md for why.
Index ¶
- Constants
- Variables
- func FromBearer(authorizationHeader string) (token string, ok bool)
- func Sign(secret []byte, c Claims) (string, error)
- func Verify(secret []byte, token string) (Claims, Outcome, error)
- func VerifyAny(secrets [][]byte, token string) (Claims, Outcome, error)
- type Claims
- type Outcome
Constants ¶
const DefaultTTL = 86400 // 24h, in seconds
DefaultTTL is the lifetime NewClaims uses when ttl <= 0.
const Leeway = 60
Leeway is the clock skew tolerated when checking exp. It is a constant rather than a parameter because the zero value (no leeway) would cause intermittent 401s in distributed systems due to clock drift.
Variables ¶
var ( // ErrEmptySecret is a refusal, not a failure. HMAC over an empty key is valid math: // it produces a token that verifies. A zero-value config would therefore mint // tokens that ANYONE can forge, and nothing would ever look wrong. // // It is an `error`, not an Outcome, because it means THE CALLER is broken — not the // token. The two must never share a channel. ErrEmptySecret = fmt.Err("jwt", "secret", "empty") // ErrEmptySubject: a token that authenticates nobody is never what the caller meant. ErrEmptySubject = fmt.Err("jwt", "subject", "empty") )
Functions ¶
func FromBearer ¶ added in v0.1.0
FromBearer extracts the token from an Authorization header value. A missing or non-Bearer header yields ok == false; the token is never guessed. Case-insensitive for the "Bearer " scheme.
func Sign ¶ added in v0.0.2
Sign returns a signed HS256 token. It refuses to mint a forgeable or meaningless token rather than handing back one that merely looks fine.
func Verify ¶ added in v0.0.2
Verify authenticates a token and returns its verdict.
The two return channels mean different things, and that separation IS the API:
error — THE CALLER is broken (an empty secret). A configuration bug. Outcome — what the TOKEN is: Valid, Expired, or Forged. Never an error.
Claims are meaningful only when the Outcome is Valid; otherwise they are zero.
The `alg` field of the header is READ BY NOBODY, and that is the point: this verifier always recomputes HS256. Choosing the algorithm from a value carried inside the untrusted token is the classic alg-confusion vulnerability — it is how `{"alg":"none"}` forgeries get accepted. Do not "fix" this by parsing the header.
func VerifyAny ¶ added in v0.1.0
VerifyAny tries each secret and accepts the token if any of them authenticates it. For rotation: pass the new secret first, the old one second.
The empty-secret rule does not relax for coming in a list: any empty entry is refused before the token is even looked at, exactly like Verify refuses an empty secret regardless of the token's shape.
Every secret is tried before answering — no early exit on the first match, and the payload is decoded only after the full traversal — so the timing of the verdict does not tell a caller (or an attacker measuring it) WHICH secret matched.
Types ¶
type Claims ¶ added in v0.0.2
type Claims struct {
Sub string // subject: who the token authenticates
Exp int64 // expiry, unix seconds
Iat int64 // issued at, unix seconds
// Aud is the RFC 7519 "audience" claim: who/what the token is scoped
// to. "" means unscoped — an identity-only token (unchanged meaning
// from before this field existed).
Aud string
// Scope lists what the subject is allowed to do within Aud. nil means
// no scope claims — same as Aud, an identity-only token leaves this
// empty. This package does not interpret these strings; a caller
// scoping a token to a project fills Aud with a project id and Scope
// with whatever role vocabulary that project uses (see
// veltylabs/iam's use in config/token.go) — this package never says
// "role" or "project", only "audience" and "scope".
Scope []string
}
Claims is the payload. Closed on purpose: the registered claims this ecosystem actually uses. No `map[string]any` bag — that is how JWT libraries grow holes.
func DecodeUnverified ¶ added in v0.1.0
DecodeUnverified reads the claims WITHOUT checking the signature. The token is UNTRUSTED input: treat the result as a display hint, never as an authorization decision.
It follows the same shape requirements as Verify (3 parts, base64 valid, sub and exp present).
func NewScopedClaims ¶ added in v0.1.16
NewScopedClaims builds a claim set like NewClaims, additionally scoped to aud with the given scope — for tokens that authorize actions within a specific audience (e.g. a project), not just identity.
func (*Claims) DecodeFields ¶ added in v0.0.2
func (c *Claims) DecodeFields(r model.FieldReader)
func (Claims) EncodeFields ¶ added in v0.0.2
func (c Claims) EncodeFields(w model.FieldWriter)
type Outcome ¶ added in v0.0.3
type Outcome uint8
Outcome is the CLOSED set of verdicts on a token. It is not an error: a token being expired or forged is this function working correctly, and the caller must act differently on each — "log in again" is not "you are under attack".
It is an enum rather than a sentinel error on purpose. With `(Claims, error)` a caller can write `if err != nil { alarm() }` and collapse a routine expiry into a forgery alarm — which is exactly what happened in tinywasm/user, drowning real tampering in noise. A closed type makes that collapse something you have to deliberately write, not something you get by forgetting.
const ( // Forged is the ZERO VALUE: closed by default. Anything not proven authentic — // wrong shape, bad signature, undecodable payload, missing claims — is this. // The verdict does not say WHICH: telling "bad signature" apart from "bad base64" // tells an attacker where they stand. Forged Outcome = iota // Valid: authentic and in date. The Claims returned alongside are trustworthy. Valid // Expired: authentic, but past its `exp`. NOT an attack — the session simply ended. Expired )