Skip to content

feat(jwtutil): RS256 signing + JWKS-based key resolution - #21

Merged
braghettos merged 1 commit into
mainfrom
feat/rs256-jwks-keysource
Aug 13, 2026
Merged

feat(jwtutil): RS256 signing + JWKS-based key resolution#21
braghettos merged 1 commit into
mainfrom
feat/rs256-jwks-keysource

Conversation

@EdmondDantes21

Copy link
Copy Markdown

Summary

  • CreateToken signs with RS256 (RSA private key + KeyID) instead of HS256 over a shared secret; the key ID is stamped into the token's kid header.
  • Validate/new ValidateWithKeySource verify against an RSA public key and reject any non-RSA signing method before the key is even consulted — closes the algorithm-confusion path a shared-secret scheme is exposed to.
  • New JWKSKeySource (jwtutil/jwks.go) resolves verification keys by kid from a remote JWKS document (e.g. authn's GET /.well-known/jwks.json): lazy first fetch, TTL + min-refresh-interval caching, and stale-key fallback on a failed refetch. Key-resolution faults surface as ErrKeyUnavailable (distinct from ErrTokenInvalid) so an HTTP caller can answer 503/retry instead of 401/re-auth when the fault is ours, not the token's.
  • server/use.UserConfig and e2e.SignUp move from a shared signing string to this KeySource API.

Why

Foundation for the krateo asymmetric-key migration: authn will sign with an RSA private key and publish the public key via JWKS; consumers (snowplow, etc.) verify against that JWKS instead of sharing a symmetric secret with authn.

Consumers

authn and snowplow both currently pin github.com/krateo-platformops/plumbing v1.13.0 and already contain code written against this new API (in separate, still-unmerged PRs). Their go.mod/go.sum need bumping to whatever tag this PR is released as, before those PRs can build — see the cross-repo ordering note left on those PRs.

Test plan

  • go build ./...
  • go test ./jwtutil/... ./server/use/... (full go test ./... has one pre-existing, unrelated go vet failure in endpoints on main)

…AC secret

CreateToken now takes a KeyID + RSA private key and signs with RS256, stamping
the key ID into the "kid" header. Validate/ValidateWithKeySource verify against
an RSA public key and reject any non-RSA signing method up front, so algorithm
confusion against a symmetric secret is no longer possible.

Adds JWKSKeySource (jwtutil/jwks.go), a KeySource that resolves keys by "kid"
from a remote JWKS document (authn's /.well-known/jwks.json), with lazily
fetched, cached, and rotation-tolerant key resolution: a stale-but-known key is
served if a refetch fails, and refetches are floored by a minimum interval so
an unknown kid cannot become a request-rate stampede. Key-resolution failures
surface as ErrKeyUnavailable, distinct from ErrTokenInvalid, so HTTP callers
can answer 503 (retry) instead of 401 (re-authenticate) when the fault is ours
and not the token's.

server/use.UserConfig and e2e.SignUp move from a shared signing string to this
KeySource-based API.
Comment thread server/use/userconfig.go
return func(next http.Handler) http.Handler {
fn := func(wri http.ResponseWriter, req *http.Request) {
if keys == nil {
response.InternalError(wri, fmt.Errorf("no JWT key source configured"))
Comment thread server/use/userconfig.go
// credential: answer 503 so the client retries instead of
// re-authenticating against an authn that is simply down.
if errors.Is(err, jwtutil.ErrKeyUnavailable) {
response.ServiceUnavailable(wri, err)
@braghettos
braghettos merged commit 031c4bf into main Aug 13, 2026
12 checks passed
@braghettos
braghettos deleted the feat/rs256-jwks-keysource branch August 13, 2026 16:23
braghettos added a commit to krateo-platformops/authn that referenced this pull request Aug 13, 2026
* fix(testdata): oauth RESTAction groups step must wait on userInfo

The "groups" step reuses .token, which userInfo's response sets on the shared
step context; without dependsOn the two can run out of order and groups fires
before .token exists.

* chore(deps): bump plumbing to pick up RS256/JWKS jwtutil API

Pinned to the krateo-platformops/plumbing@feat/rs256-jwks-keysource commit
pending release; re-pin to the tagged version once
krateo-platformops/plumbing#21 is merged and tagged.

* feat: sign JWTs with RS256 and publish the public key via JWKS

authn signs access tokens with an RSA private key (RS256) instead of a shared
HMAC secret, and publishes the matching public key as a JWKS at
GET /.well-known/jwks.json (new internal/routes/jwks package). Every route
that mints a token now carries the key through as PrivateKey+KeyID instead of
a plain SigningKey string.

The private key is read from a mounted file (--jwt-sign-key-file /
JWT_SIGN_KEY_FILE), never from an env var, and authn now fails fast at startup
(before any route is registered) if the key ID or key file is missing or
unparseable — the old design failed silently per-request into a tokenless
response instead.

Chart: the Secret (authn-jwt-signing-key, key private.pem) is mounted as a
volume rather than pulled in via envFrom; jwtSignKeySecretName becomes the
jwt.{signKeySecretName,signKeySecretKey,mountPath,kid} block. Consumers that
verify authn's tokens (snowplow, etc.) get the public key from the JWKS
endpoint, not from a shared Secret — see docs/jwt-jwks.md.

* fix(docs): jwt-jwks.md frontmatter type must come from the docs-standard registry

"Reference" isn't a registered OKF type (see .github/DOCS-STANDARD.md's lint-docs
TYPES set); "Integration" is the closest existing fit and matches rbac.md's
precedent for an extension doc. Also list the file in llms.txt alongside rbac.md.

* chore(deps): bump plumbing to v1.14.0 (released RS256/JWKS jwtutil)

---------

Co-authored-by: Diego Braga <diego.braga86@gmail.com>
braghettos added a commit to krateo-platformops/snowplow that referenced this pull request Aug 13, 2026
#146)

* chore(deps): bump plumbing to pick up RS256/JWKS jwtutil API

Pinned to the krateo-platformops/plumbing@feat/rs256-jwks-keysource commit
pending release; re-pin to the tagged version once
krateo-platformops/plumbing#21 is merged and tagged.

* feat: verify JWTs against authn's JWKS instead of a shared signing key

snowplow no longer holds a copy of authn's signing key. It resolves RS256
verification keys by "kid" from authn's JWKS endpoint
(GET /.well-known/jwks.json) via a cached jwtutil.JWKSKeySource, defaulting to
<url-authn>/.well-known/jwks.json unless --jwks-url/JWT_JWKS_URL overrides it.
Fetches are lazy (first validation, not at boot) and cached, so snowplow
starts and serves unauthenticated routes even if authn isn't up yet, and a
key rotation on authn's side needs no snowplow redeploy.

middleware.UserConfig and middleware.RefreshAuth take a jwtutil.KeySource in
place of the raw signing-key string; the snowplow-local UserConfig mirror of
plumbing's use.UserConfig (AC-D3.14 provenance guard) is re-audited
line-by-line against upstream's new KeySource-based flow and re-pinned.

Chart: helm/snowplow's jwtSignKeySecretName and its envFrom secretRef are
gone; jwt.{jwksUrl,cacheTTL,minRefreshInterval,requestTimeout} configures the
JWKS client instead. No Secret is mounted.

* chore(deps): bump plumbing to v1.14.0 (released RS256/JWKS jwtutil)

* test: fix e2e JWT setup for RS256 — use PEM RSA keys, not the HMAC secret

The RS256 migration made plumbing's e2e.SignUp expect a PEM-encoded RSA private
key (JWTSignKey) + a JWTKeyID, but six test setups still passed the old symmetric
secret "abbracadabbra", so they failed with
  e2e.go:50: invalid key: Key must be a PEM encoded PKCS1 or PKCS8 key
in TestResolveAPI, TestResolveWidgets(_Extras), TestGet, TestRESTAction, TestCallHandler.

Generate an inline PKCS1 PEM RSA key per test (matching the already-fixed
internal/rbac/rbac_test.go) and pass JWTSignKey + JWTKeyID: "test-kid".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJsLqtryCgWwEt8FnPE1se

* chore: re-pin PinnedPlumbingVersion to v1.14.0 (plumbing #21 tagged)

---------

Co-authored-by: Diego Braga <diego.braga86@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants