Skip to content

feat(auth): JWKS-based JWT verification (ES256 + rotation) for smoo/jwt modes#117

Merged
brentrager merged 1 commit into
mainfrom
jwks-es256-auth
Jun 26, 2026
Merged

feat(auth): JWKS-based JWT verification (ES256 + rotation) for smoo/jwt modes#117
brentrager merged 1 commit into
mainfrom
jwks-es256-auth

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

The Rust operator's auth verifier (rust/smooth-operator/src/auth.rs) only accepted a static RS256 PEM (AUTH_JWT_RS256_PUBLIC_KEY). SmooAI's auth.smoo.ai (the smoo issuer) signs dashboard tokens with ES256 (/.well-known/jwks.jsonalg: ES256, kty: EC), so the operator rejected every real SmooAI token — blocking AUTH_MODE=smoo for the SmooAI K8s flavor.

Solution (additive, behavior-preserving)

A JWKS-backed verification path that validates tokens signed with any algorithm (ES256/ES384/RS256/PS256/EdDSA/…) from the issuer's published JWKS:

  • Config: new optional AUTH_JWT_JWKS_URL; otherwise auto-derive {AUTH_JWT_ISSUER}/.well-known/jwks.json when an issuer is set and no static key is given.
  • JwksVerifier: fetches + caches the JWKS (TTL) and is rotation-aware (refresh-on-unknown-kid, rate-limited against storms). Per token it reads the header kid/alg, selects the matching JWK, builds DecodingKey::from_jwk, and decodes with Validation::new(alg) + issuer/audience checks.
  • Algorithm-confusion hardening: pins verification to the JWK-declared alg when present (auth.smoo.ai advertises ES256); the key is always built from the trusted JWK (typed by kty), never reinterpreted.
  • Wired into both SmooIdentityVerifier (the smoo path) and JwtVerifier (BYO), so any OIDC issuer works.
  • verify stays synchronous — the keyset is read from cache via an RwLock; the blocking HTTP fetch runs on a dedicated thread (off the hot path), so there is no per-request await and no trait change.

Key-source precedence (jwt/smoo)

  1. static AUTH_JWT_RS256_PUBLIC_KEY (RS256 PEM) — unchanged
  2. static AUTH_JWT_HS256_SECRET
  3. JWKS — AUTH_JWT_JWKS_URL, else issuer-derived

The static-RS256/HS256 paths are untouched (existing tests still pass). AUTH_MODE=smoo now needs only AUTH_JWT_ISSUER (+ optional audience) — no static public key.

Tests (offline — injected JwksFetcher, no network)

  • (a) ES256 token verified against an EC JWKS → valid Principal
  • (a') SmooIdentityVerifier validates an ES256 token via JWKS (the auth.smoo.ai scenario)
  • (b) the existing static RS256 path still verifies
  • (c) unknown-kid triggers a refresh (rotation picked up without redeploy; absent key fails cleanly)
  • (d) wrong issuer/audience rejected
  • plus JWKS-source precedence + AUTH_MODE=smoo builds from issuer alone

Gates

cargo build, cargo test -p smooai-smooth-operator -p smooai-smooth-operator-server, cargo fmt --check, and cargo clippy on the changed crate all clean. (Pre-existing clippy style lints in the untouched adapters/in-memory + adapters/dynamodb crates under toolchain 1.96 are not from this change.)

Chart follow-up

With this, AUTH_MODE=smoo needs only AUTH_JWT_ISSUER (+ audience) — AUTH_JWT_RS256_PUBLIC_KEY / chatWsAuthJwtPublicKey can be dropped.

Polyglot fan-out

The TS/Python/Go/C# auth verifiers should likewise validate via the issuer's JWKS (ES256 + rotation), not a static RS256 key.

🤖 Generated with Claude Code

…wt modes

The auth verifier only accepted a static RS256 PEM (AUTH_JWT_RS256_PUBLIC_KEY).
SmooAI's auth.smoo.ai (the `smoo` issuer) signs dashboard tokens with ES256
(/.well-known/jwks.json -> alg: ES256, kty: EC), so the operator rejected every
real SmooAI token -- blocking AUTH_MODE=smoo for the SmooAI K8s flavor.

Add a JWKS-backed verification path (additive, behavior-preserving):

- New optional AUTH_JWT_JWKS_URL, plus auto-derivation of
  {AUTH_JWT_ISSUER}/.well-known/jwks.json when an issuer is set and no static
  key is given.
- JwksVerifier fetches + caches the JWKS (TTL) and is rotation-aware
  (refresh-on-unknown-kid), selects the key per-token by `kid`, builds
  DecodingKey::from_jwk, and validates with the key's algorithm -- so any
  advertised JWS algorithm works (ES256/ES384/RS256/PS256/EdDSA/...).
- Pins verification to the JWK-declared alg when present (closes the JWS
  algorithm-confusion gap); falls back to the header alg only when the JWK
  omits one (still constrained to the key's type by from_jwk).
- Wired into both SmooIdentityVerifier (smoo) and JwtVerifier (BYO). The
  HTTP fetch runs on a dedicated thread so AuthVerifier::verify stays sync
  (no per-request await); the hot path is a cached local read.

Key-source precedence (jwt/smoo): static RS256 PEM -> static HS256 secret ->
JWKS (AUTH_JWT_JWKS_URL, else issuer-derived). Static paths unchanged; the
existing static-RS256/HS256 tests still pass. AUTH_MODE=smoo now needs only
AUTH_JWT_ISSUER (+ optional audience) -- no static public key.

Tests (offline, injected JwksFetcher -- no network): ES256 token verified
against an EC JWKS; SmooIdentityVerifier ES256 via JWKS; static RS256 still
verifies; unknown-kid triggers a refresh (rotation) and fails cleanly when
absent; wrong issuer/audience rejected; JWKS source precedence; smoo builds
from issuer alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8034b7a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/smooth-operator Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager brentrager merged commit 023c531 into main Jun 26, 2026
1 check passed
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.

1 participant