authkestra_devsig/jwks.rs
1//! Cached Issuer JWKS lookup (attestation trust needs `jwks.get(att.claims.iss, att.header.kid)`).
2//!
3//! ## Why this is a small hand-rolled cache instead of `authkestra_resource::jwt::JwksCache`
4//!
5//! The obvious move is to reuse `authkestra_resource::jwt::JwksCache` — it already exists, is
6//! already depended on by `authkestra-oidc`, and this crate should not reimplement JWKS
7//! fetching/rotation from scratch. Read against the real, current source
8//! (`crates/authkestra-resource/src/jwt.rs`) rather than assumed, it turns out not to be a
9//! drop-in fit here, for two independent reasons:
10//!
11//! 1. **It is a network-fetching cache with no seed/construct-from-memory path.**
12//! `JwksCache::new(jwks_uri, refresh_interval)` only ever populates itself by calling
13//! `Jwks::fetch(jwks_uri)` — a live `reqwest::get` — inside `refresh()`/`get_jwks()`. There is
14//! no constructor that accepts an in-memory key set. That is exactly right for the
15//! OAuth/OIDC resource-server case it was built for (a real Issuer is always reachable at a
16//! real URL), but this crate's test suite has no live Issuer (`authkestra-op`'s enrolment/
17//! attestation-minting side is tracked separately in authkestra#136) and mints test
18//! attestations directly — exercising the real cache in every test would mean standing up a
19//! mock HTTP server (`wiremock`, already a dev-dependency of `authkestra-resource` for exactly
20//! this reason) for each one.
21//! 2. **It stores `authkestra_engine::token::jwk::Jwk`, which is hard-coded RSA-only** —
22//! `to_decoding_key()` explicitly errors on any non-RSA `kty`. That happens not to bite the
23//! attestation side specifically (the attestation is always RS256 per its wire format), but
24//! consuming it would still mean converting its `Jwk` type at the boundary for no benefit,
25//! and this crate needs `jsonwebtoken::jwk::Jwk` directly anyway for the *signature* side
26//! (the device key, which is EC — see `signature.rs` — and which that wrapper cannot
27//! represent at all).
28//!
29//! So: a minimal in-memory `(issuer, kid) -> Jwk` cache, populated via [`IssuerJwks::insert`].
30//! A production integration wraps this with a periodic refresh task against each trusted
31//! issuer's published JWKS endpoint — `authkestra_resource::jwt::Jwks::fetch` is a perfectly
32//! reasonable way to do the HTTP part of that — and calls `insert` on every refresh. That
33//! refresh loop is deliberately not implemented in this crate: it is integration plumbing, not
34//! part of the verification algorithm, and belongs next to whatever task-spawning convention the
35//! embedding application already uses (the framework integration in `axum_integration.rs` takes
36//! an `Arc<IssuerJwks>` for exactly this reason — the caller owns the refresh lifecycle).
37
38use std::collections::HashMap;
39
40use jsonwebtoken::jwk::Jwk;
41use tokio::sync::RwLock;
42
43/// An in-memory cache of Issuer public keys, keyed by `(issuer, kid)`.
44///
45/// This is the "one cached public JWKS" that makes verification self-contained — no per-request
46/// network call. It performs no network I/O itself; callers populate it via
47/// [`IssuerJwks::insert`], however they choose to fetch and refresh keys.
48#[derive(Default)]
49pub struct IssuerJwks {
50 keys: RwLock<HashMap<(String, String), Jwk>>,
51}
52
53impl IssuerJwks {
54 /// Creates an empty cache.
55 pub fn new() -> Self {
56 Self {
57 keys: RwLock::new(HashMap::new()),
58 }
59 }
60
61 /// Registers (or replaces) the key published by `issuer` under `kid`.
62 pub async fn insert(&self, issuer: impl Into<String>, kid: impl Into<String>, jwk: Jwk) {
63 let mut keys = self.keys.write().await;
64 keys.insert((issuer.into(), kid.into()), jwk);
65 }
66
67 /// Looks up the key published by `issuer` under `kid`. Returns `None` on a cache miss;
68 /// callers map that to [`crate::VerifyError::UnknownKid`].
69 pub async fn get(&self, issuer: &str, kid: &str) -> Option<Jwk> {
70 let keys = self.keys.read().await;
71 keys.get(&(issuer.to_string(), kid.to_string())).cloned()
72 }
73}