feat(auth): JWKS-based JWT verification (ES256 + rotation) for smoo/jwt modes#117
Merged
Conversation
…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 detectedLatest commit: 8034b7a The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sauth.smoo.ai(thesmooissuer) signs dashboard tokens with ES256 (/.well-known/jwks.json→alg: ES256, kty: EC), so the operator rejected every real SmooAI token — blockingAUTH_MODE=smoofor 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:
AUTH_JWT_JWKS_URL; otherwise auto-derive{AUTH_JWT_ISSUER}/.well-known/jwks.jsonwhen 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 headerkid/alg, selects the matching JWK, buildsDecodingKey::from_jwk, anddecodes withValidation::new(alg)+ issuer/audience checks.algwhen present (auth.smoo.ai advertisesES256); the key is always built from the trusted JWK (typed bykty), never reinterpreted.SmooIdentityVerifier(thesmoopath) andJwtVerifier(BYO), so any OIDC issuer works.verifystays synchronous — the keyset is read from cache via anRwLock; the blocking HTTP fetch runs on a dedicated thread (off the hot path), so there is no per-requestawaitand no trait change.Key-source precedence (
jwt/smoo)AUTH_JWT_RS256_PUBLIC_KEY(RS256 PEM) — unchangedAUTH_JWT_HS256_SECRETAUTH_JWT_JWKS_URL, else issuer-derivedThe static-RS256/HS256 paths are untouched (existing tests still pass).
AUTH_MODE=smoonow needs onlyAUTH_JWT_ISSUER(+ optional audience) — no static public key.Tests (offline — injected
JwksFetcher, no network)PrincipalSmooIdentityVerifiervalidates an ES256 token via JWKS (the auth.smoo.ai scenario)kidtriggers a refresh (rotation picked up without redeploy; absent key fails cleanly)AUTH_MODE=smoobuilds from issuer aloneGates
cargo build,cargo test -p smooai-smooth-operator -p smooai-smooth-operator-server,cargo fmt --check, andcargo clippyon the changed crate all clean. (Pre-existing clippy style lints in the untouchedadapters/in-memory+adapters/dynamodbcrates under toolchain 1.96 are not from this change.)Chart follow-up
With this,
AUTH_MODE=smooneeds onlyAUTH_JWT_ISSUER(+ audience) —AUTH_JWT_RS256_PUBLIC_KEY/chatWsAuthJwtPublicKeycan 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