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        self.verify_with_policy(token, true)
109    }
110
111    /// Verify a token minted by this OIDC provider for a dynamic client
112    /// audience. The caller must validate the signed audience and token
113    /// purpose after decoding.
114    pub(crate) fn verify_provider_token<T: DeserializeOwned>(
115        &self,
116        token: &str,
117    ) -> Result<TokenData<T>> {
118        self.verify_with_policy(token, false)
119    }
120
121    fn verify_with_policy<T: DeserializeOwned>(
122        &self,
123        token: &str,
124        validate_configured_audience: bool,
125    ) -> Result<TokenData<T>> {
126        let header = decode_header(token).map_err(map_jwt_err)?;
127        let kid = header
128            .kid
129            .as_deref()
130            .ok_or_else(|| Error::Jwt("token has no kid header".to_string()))?;
131        let guard = self.inner.read();
132        let (alg, decoding_key) = lookup_decoding_key(&guard, kid)
133            .ok_or_else(|| Error::Jwt(format!("unknown kid {kid}")))?;
134        let mut validation = Validation::new(alg);
135        validation.set_issuer(std::slice::from_ref(&guard.issuer));
136        if validate_configured_audience && !guard.audience.is_empty() {
137            validation.set_audience(&guard.audience);
138        } else if !validate_configured_audience {
139            validation.validate_aud = false;
140        }
141        decode::<T>(token, decoding_key, &validation).map_err(map_jwt_err)
142    }
143
144    /// Borrow the active key's `kid` (cheap clone). Useful in tests and
145    /// for telemetry.
146    pub fn active_kid(&self) -> Option<String> {
147        self.inner.read().active.as_ref().map(|k| k.kid.clone())
148    }
149
150    /// Configured issuer string. Plan-locked: every JWT this config
151    /// signs must carry this `iss` claim. Useful for downstream callers
152    /// (e.g. the BW-compat shim in assay-vault) that mint their own
153    /// claim shapes but still need `verify` to accept the token.
154    pub fn issuer(&self) -> String {
155        self.inner.read().issuer.clone()
156    }
157
158    /// Configured audience list (cheap clone — typical size 1).
159    pub fn audience(&self) -> Vec<String> {
160        self.inner.read().audience.clone()
161    }
162
163    /// Load every key from `auth.jwks_keys` into memory. The row with
164    /// `rotated_at IS NULL` becomes active; the rest become history.
165    /// `private_pem_encrypted` is treated as plaintext PEM for now —
166    /// encryption-at-rest is a later phase.
167    #[cfg(feature = "backend-postgres")]
168    pub async fn load_from_postgres(&self, pool: &sqlx::PgPool) -> Result<()> {
169        use sqlx::Row;
170        let rows = sqlx::query(
171            "SELECT kid, alg, private_pem_encrypted, rotated_at, expires_at
172             FROM auth.jwks_keys
173             ORDER BY created_at",
174        )
175        .fetch_all(pool)
176        .await
177        .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.jwks_keys (pg): {e}")))?;
178
179        let mut active = None;
180        let mut history = Vec::new();
181        for row in rows {
182            let kid: String = row.get("kid");
183            let alg_str: String = row.get("alg");
184            let pem: Option<Vec<u8>> = row.get("private_pem_encrypted");
185            let rotated_at: Option<f64> = row.get("rotated_at");
186            let expires_at: Option<f64> = row.get("expires_at");
187            let alg = parse_alg(&alg_str)?;
188            let pem = pem.ok_or_else(|| {
189                Error::Jwt(format!("auth.jwks_keys row {kid} has no private key"))
190            })?;
191            let (encoding_key, decoding_key) = build_keys(alg, &pem)?;
192            if rotated_at.is_none() && active.is_none() {
193                active = Some(ActiveKey {
194                    kid: kid.clone(),
195                    alg,
196                    encoding_key,
197                    decoding_key,
198                    expires_at,
199                });
200            } else {
201                history.push(HistoryKey {
202                    kid,
203                    alg,
204                    decoding_key,
205                });
206            }
207        }
208        let mut guard = self.inner.write();
209        guard.active = active;
210        guard.history = history;
211        Ok(())
212    }
213
214    /// SQLite mirror of [`JwtConfig::load_from_postgres`].
215    #[cfg(feature = "backend-sqlite")]
216    pub async fn load_from_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<()> {
217        use sqlx::Row;
218        let rows = sqlx::query(
219            "SELECT kid, alg, private_pem_encrypted, rotated_at, expires_at
220             FROM auth.jwks_keys
221             ORDER BY created_at",
222        )
223        .fetch_all(pool)
224        .await
225        .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.jwks_keys (sqlite): {e}")))?;
226
227        let mut active = None;
228        let mut history = Vec::new();
229        for row in rows {
230            let kid: String = row.get("kid");
231            let alg_str: String = row.get("alg");
232            let pem: Option<Vec<u8>> = row.get("private_pem_encrypted");
233            let rotated_at: Option<f64> = row.get("rotated_at");
234            let expires_at: Option<f64> = row.get("expires_at");
235            let alg = parse_alg(&alg_str)?;
236            let pem = pem.ok_or_else(|| {
237                Error::Jwt(format!("auth.jwks_keys row {kid} has no private key"))
238            })?;
239            let (encoding_key, decoding_key) = build_keys(alg, &pem)?;
240            if rotated_at.is_none() && active.is_none() {
241                active = Some(ActiveKey {
242                    kid: kid.clone(),
243                    alg,
244                    encoding_key,
245                    decoding_key,
246                    expires_at,
247                });
248            } else {
249                history.push(HistoryKey {
250                    kid,
251                    alg,
252                    decoding_key,
253                });
254            }
255        }
256        let mut guard = self.inner.write();
257        guard.active = active;
258        guard.history = history;
259        Ok(())
260    }
261
262    /// Generate a fresh Ed25519 keypair, INSERT it into `auth.jwks_keys`
263    /// as the new active row, mark the prior active row rotated, and
264    /// swap the in-memory state. Returns the new `kid`.
265    #[cfg(feature = "backend-postgres")]
266    pub async fn rotate_postgres(&self, pool: &sqlx::PgPool) -> Result<String> {
267        let GeneratedKey {
268            kid,
269            alg,
270            private_pem,
271            public_jwk,
272        } = generate_ed25519_key();
273        let (encoding_key, decoding_key) = build_keys(alg, private_pem.as_bytes())?;
274        let now = now_secs();
275        let mut tx = pool
276            .begin()
277            .await
278            .map_err(|e| Error::Backend(anyhow::anyhow!("begin tx (pg rotate): {e}")))?;
279        sqlx::query("UPDATE auth.jwks_keys SET rotated_at = $1 WHERE rotated_at IS NULL")
280            .bind(now)
281            .execute(&mut *tx)
282            .await
283            .map_err(|e| Error::Backend(anyhow::anyhow!("mark old key rotated (pg): {e}")))?;
284        sqlx::query(
285            "INSERT INTO auth.jwks_keys
286                 (kid, alg, public_jwk, private_pem_encrypted, created_at, rotated_at, expires_at)
287             VALUES ($1, $2, $3::jsonb, $4, $5, NULL, NULL)",
288        )
289        .bind(&kid)
290        .bind(alg_str(alg))
291        .bind(public_jwk.to_string())
292        .bind(private_pem.as_bytes())
293        .bind(now)
294        .execute(&mut *tx)
295        .await
296        .map_err(|e| Error::Backend(anyhow::anyhow!("insert new key (pg): {e}")))?;
297        tx.commit()
298            .await
299            .map_err(|e| Error::Backend(anyhow::anyhow!("commit tx (pg rotate): {e}")))?;
300        // Swap in-memory.
301        let mut guard = self.inner.write();
302        if let Some(prev) = guard.active.take() {
303            guard.history.push(HistoryKey {
304                kid: prev.kid,
305                alg: prev.alg,
306                decoding_key: prev.decoding_key,
307            });
308        }
309        guard.active = Some(ActiveKey {
310            kid: kid.clone(),
311            alg,
312            encoding_key,
313            decoding_key,
314            expires_at: None,
315        });
316        Ok(kid)
317    }
318
319    /// SQLite mirror of [`JwtConfig::rotate_postgres`].
320    #[cfg(feature = "backend-sqlite")]
321    pub async fn rotate_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<String> {
322        let GeneratedKey {
323            kid,
324            alg,
325            private_pem,
326            public_jwk,
327        } = generate_ed25519_key();
328        let (encoding_key, decoding_key) = build_keys(alg, private_pem.as_bytes())?;
329        let now = now_secs();
330        let mut tx = pool
331            .begin()
332            .await
333            .map_err(|e| Error::Backend(anyhow::anyhow!("begin tx (sqlite rotate): {e}")))?;
334        sqlx::query("UPDATE auth.jwks_keys SET rotated_at = ? WHERE rotated_at IS NULL")
335            .bind(now)
336            .execute(&mut *tx)
337            .await
338            .map_err(|e| Error::Backend(anyhow::anyhow!("mark old key rotated (sqlite): {e}")))?;
339        sqlx::query(
340            "INSERT INTO auth.jwks_keys
341                 (kid, alg, public_jwk, private_pem_encrypted, created_at, rotated_at, expires_at)
342             VALUES (?, ?, ?, ?, ?, NULL, NULL)",
343        )
344        .bind(&kid)
345        .bind(alg_str(alg))
346        .bind(public_jwk.to_string())
347        .bind(private_pem.as_bytes())
348        .bind(now)
349        .execute(&mut *tx)
350        .await
351        .map_err(|e| Error::Backend(anyhow::anyhow!("insert new key (sqlite): {e}")))?;
352        tx.commit()
353            .await
354            .map_err(|e| Error::Backend(anyhow::anyhow!("commit tx (sqlite rotate): {e}")))?;
355        let mut guard = self.inner.write();
356        if let Some(prev) = guard.active.take() {
357            guard.history.push(HistoryKey {
358                kid: prev.kid,
359                alg: prev.alg,
360                decoding_key: prev.decoding_key,
361            });
362        }
363        guard.active = Some(ActiveKey {
364            kid: kid.clone(),
365            alg,
366            encoding_key,
367            decoding_key,
368            expires_at: None,
369        });
370        Ok(kid)
371    }
372}
373
374fn lookup_decoding_key<'a>(inner: &'a Inner, kid: &str) -> Option<(Algorithm, &'a DecodingKey)> {
375    if let Some(active) = &inner.active
376        && active.kid == kid
377    {
378        return Some((active.alg, &active.decoding_key));
379    }
380    inner
381        .history
382        .iter()
383        .find(|h| h.kid == kid)
384        .map(|h| (h.alg, &h.decoding_key))
385}
386
387/// Build encoding+decoding keys from a stored Ed25519 PKCS#8 private
388/// key PEM. `from_ed_pem` for the [`DecodingKey`] expects a *public*
389/// PEM, so we derive the SPKI public PEM from the private key first.
390fn build_keys(alg: Algorithm, pem: &[u8]) -> Result<(EncodingKey, DecodingKey)> {
391    match alg {
392        Algorithm::EdDSA => {
393            let enc = EncodingKey::from_ed_pem(pem).map_err(map_jwt_err)?;
394            let public_pem = ed25519_public_pem_from_private(pem)?;
395            let dec = DecodingKey::from_ed_pem(public_pem.as_bytes()).map_err(map_jwt_err)?;
396            Ok((enc, dec))
397        }
398        // RSA / ECDSA paths land when the operator brings their own key
399        // material; for v0.14.0 phase 4 we ship Ed25519 only.
400        other => Err(Error::Jwt(format!(
401            "unsupported jwt algorithm {other:?} (only EdDSA in phase 4)"
402        ))),
403    }
404}
405
406/// Derive the SPKI (subjectPublicKeyInfo) PEM for an Ed25519 keypair
407/// from the private PKCS#8 PEM. Done by re-parsing the private key with
408/// `ed25519_dalek` and re-encoding only the public half.
409fn ed25519_public_pem_from_private(private_pem: &[u8]) -> Result<String> {
410    use ed25519_dalek::SigningKey;
411    use ed25519_dalek::pkcs8::DecodePrivateKey;
412    use ed25519_dalek::pkcs8::spki::EncodePublicKey;
413
414    let pem_str = std::str::from_utf8(private_pem)
415        .map_err(|e| Error::Jwt(format!("ed25519 private PEM utf8: {e}")))?;
416    let signing = SigningKey::from_pkcs8_pem(pem_str)
417        .map_err(|e| Error::Jwt(format!("parse ed25519 private PEM: {e}")))?;
418    let verifying = signing.verifying_key();
419    verifying
420        .to_public_key_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
421        .map_err(|e| Error::Jwt(format!("encode ed25519 public PEM: {e}")))
422}
423
424fn parse_alg(name: &str) -> Result<Algorithm> {
425    match name {
426        "EdDSA" => Ok(Algorithm::EdDSA),
427        other => Err(Error::Jwt(format!(
428            "unknown jwt algorithm {other:?} (only EdDSA in phase 4)"
429        ))),
430    }
431}
432
433fn alg_str(alg: Algorithm) -> &'static str {
434    match alg {
435        Algorithm::EdDSA => "EdDSA",
436        // Other variants are unreachable today (build_keys / parse_alg
437        // gate Ed25519 only). Spell them out so future expansion is a
438        // compile-error.
439        _ => "EdDSA",
440    }
441}
442
443fn map_jwt_err(e: jsonwebtoken::errors::Error) -> Error {
444    Error::Jwt(e.to_string())
445}
446
447fn now_secs() -> f64 {
448    std::time::SystemTime::now()
449        .duration_since(std::time::UNIX_EPOCH)
450        .unwrap_or_default()
451        .as_secs_f64()
452}
453
454/// Output of the in-process Ed25519 key generator. Only used by the
455/// rotation helpers; ephemeral test keys go through
456/// [`generate_ephemeral_ed25519`] instead so the PEM never round-trips.
457struct GeneratedKey {
458    kid: String,
459    alg: Algorithm,
460    private_pem: String,
461    public_jwk: serde_json::Value,
462}
463
464fn generate_ed25519_key() -> GeneratedKey {
465    use ed25519_dalek::SigningKey;
466    use ed25519_dalek::pkcs8::EncodePrivateKey;
467
468    let signing = SigningKey::generate(&mut rand_core_06::OsRng);
469    let private_pem = signing
470        .to_pkcs8_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
471        .expect("ed25519 PKCS#8 PEM encoding")
472        .to_string();
473    let verifying = signing.verifying_key();
474    let pub_bytes = verifying.to_bytes();
475    let kid = format!(
476        "kid_{}",
477        data_encoding::BASE64URL_NOPAD.encode(&pub_bytes[..16])
478    );
479    let public_jwk = serde_json::json!({
480        "kty": "OKP",
481        "crv": "Ed25519",
482        "alg": "EdDSA",
483        "kid": kid,
484        "use": "sig",
485        "x": data_encoding::BASE64URL_NOPAD.encode(&pub_bytes),
486    });
487    GeneratedKey {
488        kid,
489        alg: Algorithm::EdDSA,
490        private_pem,
491        public_jwk,
492    }
493}
494
495/// Generate an ephemeral Ed25519 [`ActiveKey`] without touching any DB.
496/// Used by tests and by short-lived deployments that don't need
497/// rotation persistence.
498pub fn generate_ephemeral_ed25519(kid: impl Into<String>) -> Result<ActiveKey> {
499    let GeneratedKey { private_pem, .. } = generate_ed25519_key();
500    let (encoding_key, decoding_key) = build_keys(Algorithm::EdDSA, private_pem.as_bytes())?;
501    Ok(ActiveKey {
502        kid: kid.into(),
503        alg: Algorithm::EdDSA,
504        encoding_key,
505        decoding_key,
506        expires_at: None,
507    })
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use serde::{Deserialize, Serialize};
514
515    #[derive(Debug, Serialize, Deserialize, PartialEq)]
516    struct Claims {
517        sub: String,
518        iss: String,
519        aud: String,
520        exp: usize,
521    }
522
523    fn config_with_active(issuer: &str, audience: &[&str]) -> JwtConfig {
524        let cfg = JwtConfig::new(
525            issuer.to_string(),
526            audience.iter().map(|s| s.to_string()).collect(),
527        );
528        let active = generate_ephemeral_ed25519("kid_test").unwrap();
529        cfg.set_active(active, Vec::new());
530        cfg
531    }
532
533    fn future_exp() -> usize {
534        (now_secs() as usize) + 3600
535    }
536
537    fn past_exp() -> usize {
538        (now_secs() as usize).saturating_sub(3600)
539    }
540
541    #[test]
542    fn issue_and_verify_round_trip() {
543        let cfg = config_with_active("assay", &["assay-engine"]);
544        let claims = Claims {
545            sub: "user_alice".to_string(),
546            iss: "assay".to_string(),
547            aud: "assay-engine".to_string(),
548            exp: future_exp(),
549        };
550        let token = cfg.issue(&claims).unwrap();
551        let data = cfg.verify::<Claims>(&token).unwrap();
552        assert_eq!(data.claims, claims);
553        assert_eq!(data.header.kid.as_deref(), Some("kid_test"));
554    }
555
556    #[test]
557    fn wrong_audience_is_rejected() {
558        let cfg = config_with_active("assay", &["assay-engine"]);
559        let token = cfg
560            .issue(&Claims {
561                sub: "u".to_string(),
562                iss: "assay".to_string(),
563                aud: "someone-else".to_string(),
564                exp: future_exp(),
565            })
566            .unwrap();
567        let result = cfg.verify::<Claims>(&token);
568        assert!(matches!(result, Err(Error::Jwt(_))));
569    }
570
571    #[test]
572    fn provider_token_verification_accepts_a_signed_dynamic_client_audience() {
573        let cfg = config_with_active(
574            "https://auth.assay.rs/auth",
575            &["https://auth.assay.rs/auth"],
576        );
577        let claims = Claims {
578            sub: "user_alice".to_string(),
579            iss: "https://auth.assay.rs/auth".to_string(),
580            aud: "agentkit-pages".to_string(),
581            exp: future_exp(),
582        };
583        let token = cfg.issue(&claims).unwrap();
584
585        let data = cfg.verify_provider_token::<Claims>(&token).unwrap();
586
587        assert_eq!(data.claims, claims);
588    }
589
590    #[test]
591    fn provider_token_verification_still_rejects_the_wrong_issuer() {
592        let cfg = config_with_active(
593            "https://auth.assay.rs/auth",
594            &["https://auth.assay.rs/auth"],
595        );
596        let token = cfg
597            .issue(&Claims {
598                sub: "user_alice".to_string(),
599                iss: "https://attacker.example/auth".to_string(),
600                aud: "agentkit-pages".to_string(),
601                exp: future_exp(),
602            })
603            .unwrap();
604
605        let result = cfg.verify_provider_token::<Claims>(&token);
606
607        assert!(matches!(result, Err(Error::Jwt(_))));
608    }
609
610    #[test]
611    fn expired_token_is_rejected() {
612        let cfg = config_with_active("assay", &["assay-engine"]);
613        let token = cfg
614            .issue(&Claims {
615                sub: "u".to_string(),
616                iss: "assay".to_string(),
617                aud: "assay-engine".to_string(),
618                exp: past_exp(),
619            })
620            .unwrap();
621        let result = cfg.verify::<Claims>(&token);
622        assert!(matches!(result, Err(Error::Jwt(_))));
623    }
624
625    #[test]
626    fn unknown_kid_is_rejected() {
627        let cfg_a = config_with_active("assay", &["assay-engine"]);
628        let token = cfg_a
629            .issue(&Claims {
630                sub: "u".to_string(),
631                iss: "assay".to_string(),
632                aud: "assay-engine".to_string(),
633                exp: future_exp(),
634            })
635            .unwrap();
636        // Build a fresh config with a different active key — verifying
637        // the prior token must fail because the kid isn't known here.
638        let cfg_b = JwtConfig::new("assay".to_string(), vec!["assay-engine".to_string()]);
639        let other = generate_ephemeral_ed25519("kid_b").unwrap();
640        cfg_b.set_active(other, Vec::new());
641        let result = cfg_b.verify::<Claims>(&token);
642        assert!(matches!(result, Err(Error::Jwt(_))));
643    }
644}