Skip to main content

assay_auth/
jwt.rs

1//! JWT issuance + verification with key rotation backed by
2//! `auth.jwks_keys`.
3//!
4//! Plan 11 reference: `jsonwebtoken` 10 with kid-based key lookup so old
5//! tokens still verify after a key rotation. We default to EdDSA
6//! (Ed25519) — small keys, fast signatures, no PKCS#1 footguns.
7//!
8//! Lifecycle:
9//! 1. Boot loads keys with [`JwtConfig::load_from_postgres`] /
10//!    [`JwtConfig::load_from_sqlite`]. The row with `rotated_at IS NULL`
11//!    becomes the active signing key; rotated rows become history
12//!    (verify-only).
13//! 2. [`JwtConfig::issue`] signs new tokens with the active key, putting
14//!    its `kid` in the JWT header.
15//! 3. [`JwtConfig::verify`] looks up the signing key by `kid` (active
16//!    first, then history), validates `iss` and `aud`, returns the
17//!    [`jsonwebtoken::TokenData`].
18//! 4. [`JwtConfig::rotate_postgres`] / [`JwtConfig::rotate_sqlite`]
19//!    generate a fresh Ed25519 keypair, persist it, mark the old active
20//!    key rotated, and swap the in-memory state atomically.
21
22use std::sync::Arc;
23
24use jsonwebtoken::{
25    Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation, decode, decode_header,
26    encode,
27};
28use parking_lot::RwLock;
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31
32use crate::error::{Error, Result};
33
34/// Active signing key + its decoding twin. Held by the `Inner` state
35/// behind a `RwLock` so [`JwtConfig::rotate_postgres`] /
36/// [`JwtConfig::rotate_sqlite`] can swap the active key under callers
37/// already verifying inflight tokens.
38pub struct ActiveKey {
39    pub kid: String,
40    pub alg: Algorithm,
41    pub encoding_key: EncodingKey,
42    pub decoding_key: DecodingKey,
43    pub expires_at: Option<f64>,
44}
45
46/// Verify-only entry. Older keys live here so already-issued tokens
47/// validate until they expire on their own.
48pub struct HistoryKey {
49    pub kid: String,
50    pub alg: Algorithm,
51    pub decoding_key: DecodingKey,
52}
53
54struct Inner {
55    active: Option<ActiveKey>,
56    history: Vec<HistoryKey>,
57    issuer: String,
58    audience: Vec<String>,
59}
60
61/// Cheap-to-clone JWT configuration. Wrap with `Arc` internally so all
62/// clones share the same active-key + history view.
63#[derive(Clone)]
64pub struct JwtConfig {
65    inner: Arc<RwLock<Inner>>,
66}
67
68impl JwtConfig {
69    /// Empty configuration — no keys yet. Caller must populate via
70    /// [`JwtConfig::load_from_postgres`] / [`JwtConfig::load_from_sqlite`]
71    /// or [`JwtConfig::set_active`] before issuing tokens.
72    pub fn new(issuer: String, audience: Vec<String>) -> Self {
73        Self {
74            inner: Arc::new(RwLock::new(Inner {
75                active: None,
76                history: Vec::new(),
77                issuer,
78                audience,
79            })),
80        }
81    }
82
83    /// Replace the in-memory active key + history. Useful in tests where
84    /// we want a single ephemeral keypair without round-tripping the DB.
85    pub fn set_active(&self, active: ActiveKey, history: Vec<HistoryKey>) {
86        let mut guard = self.inner.write();
87        guard.active = Some(active);
88        guard.history = history;
89    }
90
91    /// Sign `claims` with the active key. The active key's `kid` is
92    /// written into the JWT header so verify can look it up.
93    pub fn issue<T: Serialize>(&self, claims: &T) -> Result<String> {
94        let guard = self.inner.read();
95        let active = guard
96            .active
97            .as_ref()
98            .ok_or_else(|| Error::Jwt("no active jwt key configured".to_string()))?;
99        let mut header = Header::new(active.alg);
100        header.kid = Some(active.kid.clone());
101        encode(&header, claims, &active.encoding_key).map_err(map_jwt_err)
102    }
103
104    /// Verify `token` and decode its claims. Looks up the decoding key
105    /// by header `kid` (active first, then history), validates `iss` and
106    /// the audience list against the in-memory configuration.
107    pub fn verify<T: DeserializeOwned>(&self, token: &str) -> Result<TokenData<T>> {
108        let header = decode_header(token).map_err(map_jwt_err)?;
109        let kid = header
110            .kid
111            .as_deref()
112            .ok_or_else(|| Error::Jwt("token has no kid header".to_string()))?;
113        let guard = self.inner.read();
114        let (alg, decoding_key) = lookup_decoding_key(&guard, kid)
115            .ok_or_else(|| Error::Jwt(format!("unknown kid {kid}")))?;
116        let mut validation = Validation::new(alg);
117        validation.set_issuer(std::slice::from_ref(&guard.issuer));
118        if !guard.audience.is_empty() {
119            validation.set_audience(&guard.audience);
120        }
121        decode::<T>(token, decoding_key, &validation).map_err(map_jwt_err)
122    }
123
124    /// Borrow the active key's `kid` (cheap clone). Useful in tests and
125    /// for telemetry.
126    pub fn active_kid(&self) -> Option<String> {
127        self.inner.read().active.as_ref().map(|k| k.kid.clone())
128    }
129
130    /// Configured issuer string. Plan-locked: every JWT this config
131    /// signs must carry this `iss` claim. Useful for downstream callers
132    /// (e.g. the BW-compat shim in assay-vault) that mint their own
133    /// claim shapes but still need `verify` to accept the token.
134    pub fn issuer(&self) -> String {
135        self.inner.read().issuer.clone()
136    }
137
138    /// Configured audience list (cheap clone — typical size 1).
139    pub fn audience(&self) -> Vec<String> {
140        self.inner.read().audience.clone()
141    }
142
143    /// Load every key from `auth.jwks_keys` into memory. The row with
144    /// `rotated_at IS NULL` becomes active; the rest become history.
145    /// `private_pem_encrypted` is treated as plaintext PEM for now —
146    /// encryption-at-rest is a later phase.
147    #[cfg(feature = "backend-postgres")]
148    pub async fn load_from_postgres(&self, pool: &sqlx::PgPool) -> Result<()> {
149        use sqlx::Row;
150        let rows = sqlx::query(
151            "SELECT kid, alg, private_pem_encrypted, rotated_at, expires_at
152             FROM auth.jwks_keys
153             ORDER BY created_at",
154        )
155        .fetch_all(pool)
156        .await
157        .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.jwks_keys (pg): {e}")))?;
158
159        let mut active = None;
160        let mut history = Vec::new();
161        for row in rows {
162            let kid: String = row.get("kid");
163            let alg_str: String = row.get("alg");
164            let pem: Option<Vec<u8>> = row.get("private_pem_encrypted");
165            let rotated_at: Option<f64> = row.get("rotated_at");
166            let expires_at: Option<f64> = row.get("expires_at");
167            let alg = parse_alg(&alg_str)?;
168            let pem = pem.ok_or_else(|| {
169                Error::Jwt(format!("auth.jwks_keys row {kid} has no private key"))
170            })?;
171            let (encoding_key, decoding_key) = build_keys(alg, &pem)?;
172            if rotated_at.is_none() && active.is_none() {
173                active = Some(ActiveKey {
174                    kid: kid.clone(),
175                    alg,
176                    encoding_key,
177                    decoding_key,
178                    expires_at,
179                });
180            } else {
181                history.push(HistoryKey {
182                    kid,
183                    alg,
184                    decoding_key,
185                });
186            }
187        }
188        let mut guard = self.inner.write();
189        guard.active = active;
190        guard.history = history;
191        Ok(())
192    }
193
194    /// SQLite mirror of [`JwtConfig::load_from_postgres`].
195    #[cfg(feature = "backend-sqlite")]
196    pub async fn load_from_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<()> {
197        use sqlx::Row;
198        let rows = sqlx::query(
199            "SELECT kid, alg, private_pem_encrypted, rotated_at, expires_at
200             FROM auth.jwks_keys
201             ORDER BY created_at",
202        )
203        .fetch_all(pool)
204        .await
205        .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.jwks_keys (sqlite): {e}")))?;
206
207        let mut active = None;
208        let mut history = Vec::new();
209        for row in rows {
210            let kid: String = row.get("kid");
211            let alg_str: String = row.get("alg");
212            let pem: Option<Vec<u8>> = row.get("private_pem_encrypted");
213            let rotated_at: Option<f64> = row.get("rotated_at");
214            let expires_at: Option<f64> = row.get("expires_at");
215            let alg = parse_alg(&alg_str)?;
216            let pem = pem.ok_or_else(|| {
217                Error::Jwt(format!("auth.jwks_keys row {kid} has no private key"))
218            })?;
219            let (encoding_key, decoding_key) = build_keys(alg, &pem)?;
220            if rotated_at.is_none() && active.is_none() {
221                active = Some(ActiveKey {
222                    kid: kid.clone(),
223                    alg,
224                    encoding_key,
225                    decoding_key,
226                    expires_at,
227                });
228            } else {
229                history.push(HistoryKey {
230                    kid,
231                    alg,
232                    decoding_key,
233                });
234            }
235        }
236        let mut guard = self.inner.write();
237        guard.active = active;
238        guard.history = history;
239        Ok(())
240    }
241
242    /// Generate a fresh Ed25519 keypair, INSERT it into `auth.jwks_keys`
243    /// as the new active row, mark the prior active row rotated, and
244    /// swap the in-memory state. Returns the new `kid`.
245    #[cfg(feature = "backend-postgres")]
246    pub async fn rotate_postgres(&self, pool: &sqlx::PgPool) -> Result<String> {
247        let GeneratedKey {
248            kid,
249            alg,
250            private_pem,
251            public_jwk,
252        } = generate_ed25519_key();
253        let (encoding_key, decoding_key) = build_keys(alg, private_pem.as_bytes())?;
254        let now = now_secs();
255        let mut tx = pool
256            .begin()
257            .await
258            .map_err(|e| Error::Backend(anyhow::anyhow!("begin tx (pg rotate): {e}")))?;
259        sqlx::query("UPDATE auth.jwks_keys SET rotated_at = $1 WHERE rotated_at IS NULL")
260            .bind(now)
261            .execute(&mut *tx)
262            .await
263            .map_err(|e| Error::Backend(anyhow::anyhow!("mark old key rotated (pg): {e}")))?;
264        sqlx::query(
265            "INSERT INTO auth.jwks_keys
266                 (kid, alg, public_jwk, private_pem_encrypted, created_at, rotated_at, expires_at)
267             VALUES ($1, $2, $3::jsonb, $4, $5, NULL, NULL)",
268        )
269        .bind(&kid)
270        .bind(alg_str(alg))
271        .bind(public_jwk.to_string())
272        .bind(private_pem.as_bytes())
273        .bind(now)
274        .execute(&mut *tx)
275        .await
276        .map_err(|e| Error::Backend(anyhow::anyhow!("insert new key (pg): {e}")))?;
277        tx.commit()
278            .await
279            .map_err(|e| Error::Backend(anyhow::anyhow!("commit tx (pg rotate): {e}")))?;
280        // Swap in-memory.
281        let mut guard = self.inner.write();
282        if let Some(prev) = guard.active.take() {
283            guard.history.push(HistoryKey {
284                kid: prev.kid,
285                alg: prev.alg,
286                decoding_key: prev.decoding_key,
287            });
288        }
289        guard.active = Some(ActiveKey {
290            kid: kid.clone(),
291            alg,
292            encoding_key,
293            decoding_key,
294            expires_at: None,
295        });
296        Ok(kid)
297    }
298
299    /// SQLite mirror of [`JwtConfig::rotate_postgres`].
300    #[cfg(feature = "backend-sqlite")]
301    pub async fn rotate_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<String> {
302        let GeneratedKey {
303            kid,
304            alg,
305            private_pem,
306            public_jwk,
307        } = generate_ed25519_key();
308        let (encoding_key, decoding_key) = build_keys(alg, private_pem.as_bytes())?;
309        let now = now_secs();
310        let mut tx = pool
311            .begin()
312            .await
313            .map_err(|e| Error::Backend(anyhow::anyhow!("begin tx (sqlite rotate): {e}")))?;
314        sqlx::query("UPDATE auth.jwks_keys SET rotated_at = ? WHERE rotated_at IS NULL")
315            .bind(now)
316            .execute(&mut *tx)
317            .await
318            .map_err(|e| Error::Backend(anyhow::anyhow!("mark old key rotated (sqlite): {e}")))?;
319        sqlx::query(
320            "INSERT INTO auth.jwks_keys
321                 (kid, alg, public_jwk, private_pem_encrypted, created_at, rotated_at, expires_at)
322             VALUES (?, ?, ?, ?, ?, NULL, NULL)",
323        )
324        .bind(&kid)
325        .bind(alg_str(alg))
326        .bind(public_jwk.to_string())
327        .bind(private_pem.as_bytes())
328        .bind(now)
329        .execute(&mut *tx)
330        .await
331        .map_err(|e| Error::Backend(anyhow::anyhow!("insert new key (sqlite): {e}")))?;
332        tx.commit()
333            .await
334            .map_err(|e| Error::Backend(anyhow::anyhow!("commit tx (sqlite rotate): {e}")))?;
335        let mut guard = self.inner.write();
336        if let Some(prev) = guard.active.take() {
337            guard.history.push(HistoryKey {
338                kid: prev.kid,
339                alg: prev.alg,
340                decoding_key: prev.decoding_key,
341            });
342        }
343        guard.active = Some(ActiveKey {
344            kid: kid.clone(),
345            alg,
346            encoding_key,
347            decoding_key,
348            expires_at: None,
349        });
350        Ok(kid)
351    }
352}
353
354fn lookup_decoding_key<'a>(inner: &'a Inner, kid: &str) -> Option<(Algorithm, &'a DecodingKey)> {
355    if let Some(active) = &inner.active
356        && active.kid == kid
357    {
358        return Some((active.alg, &active.decoding_key));
359    }
360    inner
361        .history
362        .iter()
363        .find(|h| h.kid == kid)
364        .map(|h| (h.alg, &h.decoding_key))
365}
366
367/// Build encoding+decoding keys from a stored Ed25519 PKCS#8 private
368/// key PEM. `from_ed_pem` for the [`DecodingKey`] expects a *public*
369/// PEM, so we derive the SPKI public PEM from the private key first.
370fn build_keys(alg: Algorithm, pem: &[u8]) -> Result<(EncodingKey, DecodingKey)> {
371    match alg {
372        Algorithm::EdDSA => {
373            let enc = EncodingKey::from_ed_pem(pem).map_err(map_jwt_err)?;
374            let public_pem = ed25519_public_pem_from_private(pem)?;
375            let dec = DecodingKey::from_ed_pem(public_pem.as_bytes()).map_err(map_jwt_err)?;
376            Ok((enc, dec))
377        }
378        // RSA / ECDSA paths land when the operator brings their own key
379        // material; for v0.14.0 phase 4 we ship Ed25519 only.
380        other => Err(Error::Jwt(format!(
381            "unsupported jwt algorithm {other:?} (only EdDSA in phase 4)"
382        ))),
383    }
384}
385
386/// Derive the SPKI (subjectPublicKeyInfo) PEM for an Ed25519 keypair
387/// from the private PKCS#8 PEM. Done by re-parsing the private key with
388/// `ed25519_dalek` and re-encoding only the public half.
389fn ed25519_public_pem_from_private(private_pem: &[u8]) -> Result<String> {
390    use ed25519_dalek::SigningKey;
391    use ed25519_dalek::pkcs8::DecodePrivateKey;
392    use ed25519_dalek::pkcs8::spki::EncodePublicKey;
393
394    let pem_str = std::str::from_utf8(private_pem)
395        .map_err(|e| Error::Jwt(format!("ed25519 private PEM utf8: {e}")))?;
396    let signing = SigningKey::from_pkcs8_pem(pem_str)
397        .map_err(|e| Error::Jwt(format!("parse ed25519 private PEM: {e}")))?;
398    let verifying = signing.verifying_key();
399    verifying
400        .to_public_key_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
401        .map_err(|e| Error::Jwt(format!("encode ed25519 public PEM: {e}")))
402}
403
404fn parse_alg(name: &str) -> Result<Algorithm> {
405    match name {
406        "EdDSA" => Ok(Algorithm::EdDSA),
407        other => Err(Error::Jwt(format!(
408            "unknown jwt algorithm {other:?} (only EdDSA in phase 4)"
409        ))),
410    }
411}
412
413fn alg_str(alg: Algorithm) -> &'static str {
414    match alg {
415        Algorithm::EdDSA => "EdDSA",
416        // Other variants are unreachable today (build_keys / parse_alg
417        // gate Ed25519 only). Spell them out so future expansion is a
418        // compile-error.
419        _ => "EdDSA",
420    }
421}
422
423fn map_jwt_err(e: jsonwebtoken::errors::Error) -> Error {
424    Error::Jwt(e.to_string())
425}
426
427fn now_secs() -> f64 {
428    std::time::SystemTime::now()
429        .duration_since(std::time::UNIX_EPOCH)
430        .unwrap_or_default()
431        .as_secs_f64()
432}
433
434/// Output of the in-process Ed25519 key generator. Only used by the
435/// rotation helpers; ephemeral test keys go through
436/// [`generate_ephemeral_ed25519`] instead so the PEM never round-trips.
437struct GeneratedKey {
438    kid: String,
439    alg: Algorithm,
440    private_pem: String,
441    public_jwk: serde_json::Value,
442}
443
444fn generate_ed25519_key() -> GeneratedKey {
445    use ed25519_dalek::SigningKey;
446    use ed25519_dalek::pkcs8::EncodePrivateKey;
447
448    let signing = SigningKey::generate(&mut rand_core_06::OsRng);
449    let private_pem = signing
450        .to_pkcs8_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
451        .expect("ed25519 PKCS#8 PEM encoding")
452        .to_string();
453    let verifying = signing.verifying_key();
454    let pub_bytes = verifying.to_bytes();
455    let kid = format!(
456        "kid_{}",
457        data_encoding::BASE64URL_NOPAD.encode(&pub_bytes[..16])
458    );
459    let public_jwk = serde_json::json!({
460        "kty": "OKP",
461        "crv": "Ed25519",
462        "alg": "EdDSA",
463        "kid": kid,
464        "use": "sig",
465        "x": data_encoding::BASE64URL_NOPAD.encode(&pub_bytes),
466    });
467    GeneratedKey {
468        kid,
469        alg: Algorithm::EdDSA,
470        private_pem,
471        public_jwk,
472    }
473}
474
475/// Generate an ephemeral Ed25519 [`ActiveKey`] without touching any DB.
476/// Used by tests and by short-lived deployments that don't need
477/// rotation persistence.
478pub fn generate_ephemeral_ed25519(kid: impl Into<String>) -> Result<ActiveKey> {
479    let GeneratedKey { private_pem, .. } = generate_ed25519_key();
480    let (encoding_key, decoding_key) = build_keys(Algorithm::EdDSA, private_pem.as_bytes())?;
481    Ok(ActiveKey {
482        kid: kid.into(),
483        alg: Algorithm::EdDSA,
484        encoding_key,
485        decoding_key,
486        expires_at: None,
487    })
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use serde::{Deserialize, Serialize};
494
495    #[derive(Debug, Serialize, Deserialize, PartialEq)]
496    struct Claims {
497        sub: String,
498        iss: String,
499        aud: String,
500        exp: usize,
501    }
502
503    fn config_with_active(issuer: &str, audience: &[&str]) -> JwtConfig {
504        let cfg = JwtConfig::new(
505            issuer.to_string(),
506            audience.iter().map(|s| s.to_string()).collect(),
507        );
508        let active = generate_ephemeral_ed25519("kid_test").unwrap();
509        cfg.set_active(active, Vec::new());
510        cfg
511    }
512
513    fn future_exp() -> usize {
514        (now_secs() as usize) + 3600
515    }
516
517    fn past_exp() -> usize {
518        (now_secs() as usize).saturating_sub(3600)
519    }
520
521    #[test]
522    fn issue_and_verify_round_trip() {
523        let cfg = config_with_active("assay", &["assay-engine"]);
524        let claims = Claims {
525            sub: "user_alice".to_string(),
526            iss: "assay".to_string(),
527            aud: "assay-engine".to_string(),
528            exp: future_exp(),
529        };
530        let token = cfg.issue(&claims).unwrap();
531        let data = cfg.verify::<Claims>(&token).unwrap();
532        assert_eq!(data.claims, claims);
533        assert_eq!(data.header.kid.as_deref(), Some("kid_test"));
534    }
535
536    #[test]
537    fn wrong_audience_is_rejected() {
538        let cfg = config_with_active("assay", &["assay-engine"]);
539        let token = cfg
540            .issue(&Claims {
541                sub: "u".to_string(),
542                iss: "assay".to_string(),
543                aud: "someone-else".to_string(),
544                exp: future_exp(),
545            })
546            .unwrap();
547        let result = cfg.verify::<Claims>(&token);
548        assert!(matches!(result, Err(Error::Jwt(_))));
549    }
550
551    #[test]
552    fn expired_token_is_rejected() {
553        let cfg = config_with_active("assay", &["assay-engine"]);
554        let token = cfg
555            .issue(&Claims {
556                sub: "u".to_string(),
557                iss: "assay".to_string(),
558                aud: "assay-engine".to_string(),
559                exp: past_exp(),
560            })
561            .unwrap();
562        let result = cfg.verify::<Claims>(&token);
563        assert!(matches!(result, Err(Error::Jwt(_))));
564    }
565
566    #[test]
567    fn unknown_kid_is_rejected() {
568        let cfg_a = config_with_active("assay", &["assay-engine"]);
569        let token = cfg_a
570            .issue(&Claims {
571                sub: "u".to_string(),
572                iss: "assay".to_string(),
573                aud: "assay-engine".to_string(),
574                exp: future_exp(),
575            })
576            .unwrap();
577        // Build a fresh config with a different active key — verifying
578        // the prior token must fail because the kid isn't known here.
579        let cfg_b = JwtConfig::new("assay".to_string(), vec!["assay-engine".to_string()]);
580        let other = generate_ephemeral_ed25519("kid_b").unwrap();
581        cfg_b.set_active(other, Vec::new());
582        let result = cfg_b.verify::<Claims>(&token);
583        assert!(matches!(result, Err(Error::Jwt(_))));
584    }
585}