Skip to content

v0.2.0

Choose a tag to compare

@kataras kataras released this 16 Aug 15:13
· 3 commits to main since this release
0ac6468

jwt v0.2.0

The first release since v0.1.17, and much the largest. Every exported symbol was read against
its own documentation, and most of what follows came out of doing that.

It breaks things. Going to 0.2.0 rather than 0.1.18 is the signal: a leading zero means the
API is not stable yet, and in a 0.x line the minor bump is where a break gets announced.

Upgrading

Three changes cover most callers:

  1. Add an Alg as the first argument to any Enrich call.
  2. Stop producing or accepting NONE in a header. The name is none now, which is what
    RFC 7518 section 3.6 says and what every other implementation reads.
  3. Check anything that depended on Expected.Audience matching an exact list in order. It
    checks membership now, per RFC 7519 section 4.1.3.

Two smaller ones: TokenPair fields are string rather than json.RawMessage, and if you
assigned Blocklist.Clock you want Blocklist.SetClock.

Security

Enrich signed whatever it was handed. It read the algorithm out of the header of the
token it was given, reused that header verbatim, spliced in that payload, and verified
nothing before re-signing with your private key. A service enriching a token it received
would mint one for any header and payload the sender chose. parseAlg resolved the unsecured
algorithm, so the sender could also ask for a token carrying no signature. Keys.EnrichToken
inherited the lot. The algorithm is the caller's now, none is refused, and both entry points
check the signature before re-signing.

A revocation for a token with no exp was discarded. InvalidateToken stored the claim's
expiry and returned nil. With no exp that value is 0, which garbage collection read as
expiring in 1970 and deleted on the next tick. You were told the session was revoked; a tick
later it was live again, and nothing reported a problem.

A typed-nil public key took the process down. A *rsa.PublicKey(nil) held in an interface
is not nil, so it passed every nil check and was dereferenced inside crypto/rsa. Any token
naming that kid crashed the process.

The JWKS fetch had no limits at all. No timeout, so an endpoint that accepted the
connection and then went quiet held the goroutine for the life of the process. No scheme
check, so keys could arrive over plain http. Redirects were followed anywhere, https to http
included. Neither the body nor the decode was bounded, and the whole remote response went
into the error string. Now: 15 second timeout, https except on loopback, five redirects each
re-checked, a 1 MB ceiling, and the body on a field instead of in the message.

Base64Decode wrote past the end of the caller's buffer. It appended = padding, and
bytes.Split does not cap the last part it returns, so the signature segment still owned the
token's whole spare capacity. With a pooled HTTP read buffer, which is how most servers hand a
token to this package, that write lands in memory the next request is about to use.

FetchAWSCognitoPublicKeys built its URL by interpolation. A region of evil.com/x moved
the request to another host, and the keys that came back were used to verify tokens.

Faster, from the same fix

Trimming the padding rather than appending it, and decoding with base64.RawURLEncoding, took
four allocations and 256 bytes off every verification. The safest version turned out to be the
quickest one.

Before Now
Verify 22 allocs, 1632 B 18 allocs, 1376 B
Sign (map) 23 allocs, 1528 B 23 allocs, 1528 B
Sign (struct) 21 allocs, 1392 B 21 allocs, 1392 B

Enrich moved the other way, 39 allocations to 47. That is what checking a signature costs,
and it was not being checked before, so it is not a trade worth reversing.

New

Most of this came from reading what three services in production are forced to write around
the package.

  • KeySet, NewRemoteKeySet, NewCognitoKeySet. A key set that refreshes on a timer and
    again when a token names a kid it has not seen, rate limited. FetchPublicKeys was a
    single GET with no cache and no refetch, so one fleet failed with ErrUnknownKid until
    restart every time the issuer rotated.
  • SignPair, Keys.SignPair. Linked access and refresh tokens. The hand-written version
    it replaces ran to about 129 lines.
  • FromHeader, FromQuery, FromCookie, ExtractToken. Two of the three Bearer parsers
    found in the wild disagreed about a token containing a space.
  • Skew, which tolerates clock skew on nbf as well as iat. Future only ever rescued
    iat, and validation checks nbf first and stops, so four call sites worked only because
    their provider omits nbf.
  • Classify and ErrorKind, sorting a failure into malformed, invalid, expired or
    rejected, instead of enumerating fifteen sentinels by hand.
  • TokenBlocklist and DefaultBlocklistKey. A Redis backend that re-derived the key
    function returned the jti unconditionally, so every token without one mapped to the empty
    key and a single logout blocked every session in the system.
  • Keys.Verify, Validators, RequireExpiry, Audience.Contains, Blocklist.Close,
    Blocklist.SetClock
    , and sign options for individual claims.

Also in this release

  • A fourteen chapter book, 121 pages as PDF. Chapter 12 is the honest list of this
    package's sharp edges.
  • A brand kit and an agent skill, the second installable as a Claude Code
    plugin.
  • A Lint job in CI, and benchmark charts generated from the benchmarks themselves rather
    than hotlinked from a URL that had started returning 404.
  • go.mod still has no requirements, and it is not going to get any.

Full detail, including everything not listed here, is in CHANGELOG.md.