Skip to main content

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}