Skip to main content

alibi_plugins/jwt/
mod.rs

1//! Asymmetric session JWTs, public JWKS, and trusted server-side signing.
2mod callbacks;
3mod claims;
4mod config;
5
6use alibi_core::error::codes;
7pub use callbacks::DefineJwtPayload;
8pub use callbacks::DefineJwtSubject;
9pub use callbacks::JwtKeyring;
10pub use callbacks::JwtKeyringContext;
11pub use callbacks::JwtSession;
12pub use callbacks::RemoteJwtClaim;
13pub use callbacks::RemoteJwtPayload;
14pub use callbacks::SignRemoteJwt;
15use claims::decode_compact_json;
16pub use config::JwtAlgorithm;
17pub use config::JwtAudience;
18pub use config::JwtClaimsConfig;
19pub use config::JwtExpiration;
20pub use config::JwtKeyPairConfig;
21pub use config::JwtPluginConfig;
22pub use config::JwtSignOptions;
23mod http;
24mod keyring;
25mod signing;
26mod verification;
27
28mod crypto;
29mod endpoint;
30use alibi_core::{
31    AuthContext, AuthError, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult,
32    AuthRoute, AuthSchema, HttpMethod,
33};
34use async_trait::async_trait;
35pub use endpoint::{JwtTokenOutput, JwtVerifyOutput};
36use serde_json::{Value, json};
37use std::sync::Arc;
38
39/// A selected server signing key. Private material stays inside the plugin.
40#[expect(
41    clippy::partial_pub_fields,
42    reason = "Public signing metadata keeps private key material encapsulated"
43)]
44pub struct ResolvedJwtSigningKey {
45    pub algorithm: JwtAlgorithm,
46    pub key_id: String,
47    private_key: Value,
48}
49
50impl std::fmt::Debug for ResolvedJwtSigningKey {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("ResolvedJwtSigningKey")
53            .finish_non_exhaustive()
54    }
55}
56
57struct JwtVerifyPolicy<'a> {
58    issuer: &'a str,
59    audience: &'a JwtAudience,
60    tolerance: i64,
61    require_nonempty_subject: bool,
62}
63
64#[derive(Clone, Default)]
65pub struct JwtPlugin {
66    config: JwtPluginConfig,
67}
68
69impl std::fmt::Debug for JwtPlugin {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("JwtPlugin").finish_non_exhaustive()
72    }
73}
74
75impl JwtPlugin {
76    #[must_use]
77    pub fn new() -> Self {
78        Self::default()
79    }
80    #[must_use]
81    pub const fn with_config(config: JwtPluginConfig) -> Self {
82        Self { config }
83    }
84}
85
86#[async_trait]
87impl<S: AuthSchema> AuthPlugin<S> for JwtPlugin {
88    route_openapi_metadata!(S);
89
90    fn name(&self) -> &'static str {
91        "jwt"
92    }
93    fn routes(&self) -> Vec<AuthRoute> {
94        vec![
95            AuthRoute::get(&self.config.jwks_path, "get_jwks"),
96            AuthRoute::get("/token", "get_token"),
97        ]
98    }
99    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
100        endpoint::definitions(&self.config.jwks_path)
101    }
102
103    fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
104        vec![self]
105    }
106
107    fn validate_endpoint(
108        &self,
109        call: &alibi_core::endpoint::EndpointCall,
110        _ctx: &AuthContext<S>,
111    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
112        endpoint::validate(call)
113    }
114
115    async fn on_endpoint(
116        &self,
117        call: &alibi_core::endpoint::EndpointCall,
118        ctx: &AuthContext<S>,
119    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
120        self.call_endpoint(call, ctx).await
121    }
122
123    async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
124        ctx.extensions.insert(self.clone());
125        ctx.extensions.insert(JwtDiscoveryOptions {
126            issuer: self.config.claims.issuer.clone(),
127            jwks_path: self.config.jwks_path.clone(),
128            remote_url: self.config.remote_url.clone(),
129            signing_algorithms: {
130                let mut algorithms = vec![self.config.key_pair.algorithm.as_str().to_owned()];
131                for pair in &self.config.additional_key_pairs {
132                    let algorithm = pair.algorithm.as_str().to_owned();
133                    if !algorithms.contains(&algorithm) {
134                        algorithms.push(algorithm);
135                    }
136                }
137                algorithms
138            },
139        });
140
141        if self.config.jwks_path.is_empty()
142            || !self.config.jwks_path.starts_with('/')
143            || self.config.jwks_path.contains("..")
144        {
145            return Err(AuthError::config(
146                "JWKS path must start with '/' and not contain '..'",
147            ));
148        }
149        if self.config.remote_signer.is_some() && self.config.remote_url.is_none() {
150            return Err(AuthError::config(
151                "Remote JWKS URL must be set when using a custom JWT signer",
152            ));
153        }
154        if self.config.session_cookie_cache {
155            if ctx
156                .config
157                .session
158                .cookie_cache
159                .as_ref()
160                .is_none_or(|cache| cache.strategy != alibi_core::CookieCacheStrategy::Jwt)
161            {
162                return Err(AuthError::config(
163                    "Managed JWT session caching requires the JWT cookie-cache strategy",
164                ));
165            }
166            if self.config.remote_signer.is_some() {
167                return Err(AuthError::config(
168                    "Managed JWT session caching requires locally managed signing keys",
169                ));
170            }
171            ctx.extensions.insert(
172                alibi_core::session::cookie_cache::jwt::CookieCacheSignerHandle::<S>(Arc::new(
173                    self.clone(),
174                )),
175            );
176        }
177        Ok(())
178    }
179    async fn on_request(
180        &self,
181        req: &AuthRequest,
182        ctx: &AuthContext<S>,
183    ) -> AuthResult<Option<AuthResponse>> {
184        match (req.method(), req.path()) {
185            (HttpMethod::Get, path) if path == self.config.jwks_path => {
186                Ok(Some(self.jwks(req, ctx).await.map_err(public_jwt_error)?))
187            }
188            (HttpMethod::Get, "/token") => Ok(Some(AuthResponse::json(
189                200,
190                &json!({ "token": self.session_token(req, ctx).await.map_err(public_jwt_error)? }),
191            )?)),
192            _ => Ok(None),
193        }
194    }
195    async fn after_request(
196        &self,
197        req: &AuthRequest,
198        ctx: &AuthContext<S>,
199        mut response: AuthResponse,
200    ) -> AuthResult<AuthResponse> {
201        if req.path() != "/get-session" || self.config.disable_setting_jwt_header {
202            return Ok(response);
203        }
204        let Some((user, session)) = req.session_hook_snapshot() else {
205            return Ok(response);
206        };
207        let cache = alibi_core::session::cookie_cache::runtime::session_hook_cache_metadata(req);
208        let token = self
209            .sign_session_token(
210                Some(req),
211                ctx,
212                &JwtSession {
213                    user,
214                    session,
215                    needs_refresh: None,
216                    updated_at: cache.as_ref().map(|metadata| metadata.updated_at),
217                    version: cache.and_then(|metadata| metadata.version),
218                },
219            )
220            .await
221            .map_err(public_jwt_error)?;
222        let mut expose = response
223            .headers
224            .get("access-control-expose-headers")
225            .into_iter()
226            .flat_map(|value| value.split(','))
227            .map(str::trim)
228            .filter(|value| !value.is_empty())
229            .fold(Vec::new(), |mut headers, header| {
230                if !headers.iter().any(|existing| existing == header) {
231                    headers.push(header.to_owned());
232                }
233                headers
234            });
235        if !expose.iter().any(|header| header == "set-auth-jwt") {
236            expose.push("set-auth-jwt".to_owned());
237        }
238        _ = response.headers.insert("set-auth-jwt", token);
239        _ = response
240            .headers
241            .insert("access-control-expose-headers", expose.join(", "));
242        Ok(response)
243    }
244}
245
246fn public_jwt_error(error: AuthError) -> AuthError {
247    match error {
248        AuthError::Config(_)
249        | AuthError::Database(_)
250        | AuthError::Serialization(_)
251        | AuthError::Plugin { .. }
252        | AuthError::Internal(_)
253        | AuthError::PasswordHash(_)
254        | AuthError::Jwt(_) => AuthError::CallbackFailure(Box::new(error)),
255        error => error,
256    }
257}
258
259const fn unauthorized() -> AuthError {
260    AuthError::Upstream {
261        status: 401,
262        code: codes::UNAUTHORIZED,
263        message: "Unauthorized",
264    }
265}
266
267#[async_trait]
268impl<S: AuthSchema> alibi_core::session::cookie_cache::jwt::CookieCacheSigner<S> for JwtPlugin {
269    async fn sign(
270        &self,
271        payload: Value,
272        max_age: f64,
273        ctx: &AuthContext<S>,
274        transaction: Option<&dyn alibi_core::store::AuthTransaction<S>>,
275    ) -> AuthResult<String> {
276        let key = self
277            .resolve_signing_key_in_transaction(&JwtSignOptions::default(), None, ctx, transaction)
278            .await?
279            .ok_or_else(|| {
280                AuthError::config(
281                    "Managed JWT session caching requires locally managed signing keys",
282                )
283            })?;
284        let mut payload = alibi_core::session::cookie_cache::jwt::time_claims(payload, max_age)?;
285        let claims = payload
286            .as_object_mut()
287            .ok_or_else(|| AuthError::internal("Invalid session cache payload"))?;
288        let sid = claims
289            .get("session")
290            .and_then(|session| session.get("token"))
291            .cloned()
292            .ok_or_else(|| AuthError::internal("Missing session token"))?;
293        let sub = claims
294            .get("user")
295            .and_then(|user| user.get("id"))
296            .cloned()
297            .ok_or_else(|| AuthError::internal("Missing session owner"))?;
298        _ = claims.insert("sid".into(), sid);
299        _ = claims.insert("sub".into(), sub);
300        _ = claims.insert("iss".into(), json!(cache_issuer(ctx)));
301        _ = claims.insert("aud".into(), json!("better-auth:session-cache"));
302        let options = JwtSignOptions {
303            header: Some(serde_json::from_value(
304                json!({"typ":"better-auth.session-cache+jwt"}),
305            )?),
306            ..JwtSignOptions::default()
307        };
308        Self::sign_resolved(claims.clone(), &options, &key)
309    }
310
311    async fn verify(&self, token: &str, ctx: &AuthContext<S>) -> AuthResult<Option<Value>> {
312        let Some(header) = token
313            .split('.')
314            .next()
315            .and_then(|header| decode_compact_json(header, false).ok())
316        else {
317            return Ok(None);
318        };
319        if header.get("typ").and_then(Value::as_str) != Some("better-auth.session-cache+jwt") {
320            return Ok(None);
321        }
322        let audience = JwtAudience::One("better-auth:session-cache".into());
323        let policy = JwtVerifyPolicy {
324            issuer: cache_issuer(ctx),
325            audience: &audience,
326            tolerance: 15,
327            require_nonempty_subject: true,
328        };
329        let Some(payload) = self
330            .verify_internal(token, &policy, None, ctx)
331            .await
332            .unwrap_or(None)
333        else {
334            return Ok(None);
335        };
336        if payload.get("sub") != payload.get("user").and_then(|user| user.get("id"))
337            || payload.get("sid")
338                != payload
339                    .get("session")
340                    .and_then(|session| session.get("token"))
341        {
342            return Ok(None);
343        }
344        Ok(Some(Value::Object(payload)))
345    }
346}
347
348fn cache_issuer<S: AuthSchema>(ctx: &AuthContext<S>) -> &str {
349    if ctx.config.base_url.is_empty() {
350        "better-auth:session-cache"
351    } else {
352        &ctx.config.base_url
353    }
354}
355
356// LCOV_EXCL_START
357#[cfg(test)]
358mod tests {
359    #![allow(
360        clippy::unwrap_used,
361        clippy::indexing_slicing,
362        reason = "asserted JWT contracts with real keys and SQLite"
363    )]
364
365    use super::*;
366    use crate::test_helpers;
367    use crate::token_crypto::{decrypt, encrypt};
368    use alibi_core::CreateUser;
369    use alibi_core::{CreateJwk, Jwk};
370    use base64::Engine;
371    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
372    use chrono::Duration;
373    use chrono::Utc;
374    use serde_json::Map;
375
376    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
377
378    struct ApplicationClaims;
379
380    #[async_trait]
381    impl DefineJwtPayload for ApplicationClaims {
382        async fn define_payload(&self, session: &JwtSession) -> AuthResult<Map<String, Value>> {
383            Ok(
384            json!({"purpose":"application","ownerId":session.user.id,"loginId":session.session.id})
385                .as_object()
386                .unwrap()
387                .clone(),
388        )
389        }
390    }
391
392    struct ApplicationSubject(Option<String>);
393
394    #[async_trait]
395    impl DefineJwtSubject for ApplicationSubject {
396        async fn subject(&self, _session: &JwtSession) -> AuthResult<Option<String>> {
397            Ok(self.0.clone())
398        }
399    }
400
401    struct ApplicationKeyring {
402        database: Arc<dyn alibi_core::AuthStore<TestSchema>>,
403    }
404
405    #[async_trait]
406    impl JwtKeyring for ApplicationKeyring {
407        async fn keys(&self, _context: &JwtKeyringContext<'_>) -> AuthResult<Vec<Jwk>> {
408            self.database.list_jwks().await
409        }
410        async fn create_key(
411            &self,
412            mut key: CreateJwk,
413            context: &JwtKeyringContext<'_>,
414        ) -> AuthResult<Jwk> {
415            if context.request.map(AuthRequest::path) != Some("/jwks") {
416                return Err(AuthError::forbidden(
417                    "Application key provisioning requires its public key request",
418                ));
419            }
420            key.id = Some("application-signing-key".to_owned());
421            self.database.create_jwk(key).await
422        }
423    }
424
425    struct ApplicationSigner {
426        context: AuthContext<TestSchema>,
427        plugin: JwtPlugin,
428    }
429
430    #[async_trait]
431    impl SignRemoteJwt for ApplicationSigner {
432        async fn sign(
433            &self,
434            payload: &RemoteJwtPayload,
435            options: &JwtSignOptions,
436        ) -> AuthResult<String> {
437            self.plugin
438                .sign_jwt_json(payload.raw_claims(), options, None, &self.context)
439                .await
440        }
441    }
442
443    fn payload(subject: &str) -> Map<String, Value> {
444        json!({ "sub": subject, "application": "jwt-tests" })
445            .as_object()
446            .unwrap()
447            .clone()
448    }
449
450    fn decoded(token: &str) -> (Value, Value) {
451        let parts = token.split('.').collect::<Vec<_>>();
452        (
453            serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(),
454            serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(),
455        )
456    }
457
458    #[tokio::test]
459    async fn default_keys_are_encrypted_and_jwt_claims_signature_and_public_material_are_real() {
460        let ctx = test_helpers::create_test_context().await;
461        let plugin = JwtPlugin::new();
462        let token = plugin
463            .sign_jwt(payload("subject-1"), &JwtSignOptions::default(), None, &ctx)
464            .await
465            .unwrap();
466        let (header, claims) = decoded(&token);
467        assert_eq!(header["alg"], "EdDSA");
468        assert!(header.get("typ").is_none());
469        assert!(claims.get("iat").is_none());
470        assert_eq!(claims["iss"], ctx.config.base_url);
471        assert_eq!(claims["aud"], ctx.config.base_url);
472        assert!((claims["exp"].as_i64().unwrap() - Utc::now().timestamp() - 900).abs() <= 1);
473        let keys = ctx.database.list_jwks().await.unwrap();
474        assert_eq!(keys.len(), 1);
475        assert_eq!(header["kid"], keys[0].id);
476        assert!(serde_json::from_str::<String>(&keys[0].private_key).is_ok());
477        assert!(!keys[0].private_key.contains("\"d\""));
478        let private = decrypt(
479            &serde_json::from_str::<String>(&keys[0].private_key).unwrap(),
480            &ctx.config.secret,
481        )
482        .unwrap();
483        assert!(serde_json::from_str::<Value>(&private).unwrap()["d"].is_string());
484        let public = plugin
485            .jwks(
486                &test_helpers::create_auth_request_no_query(HttpMethod::Get, "/jwks", None, None),
487                &ctx,
488            )
489            .await
490            .unwrap();
491        let public: Value = serde_json::from_slice(&public.body).unwrap();
492        assert_eq!(public["keys"][0]["kid"], header["kid"]);
493        assert_eq!(public["keys"][0]["crv"], "Ed25519");
494        assert!(public["keys"][0].get("d").is_none());
495        assert!(public["keys"][0].get("privateKey").is_none());
496        assert_eq!(
497            plugin
498                .verify_jwt(&token, None, None, &ctx)
499                .await
500                .unwrap()
501                .unwrap(),
502            claims.as_object().unwrap().clone()
503        );
504    }
505
506    #[tokio::test]
507    async fn explicit_key_pinning_is_independent_of_the_public_keyring_limit() {
508        use alibi_seaorm::{Database, SeaOrmStore};
509        let mut config = test_helpers::create_test_config();
510        config.advanced.database.default_find_many_limit = 1;
511        let database = Database::connect("sqlite::memory:").await.unwrap();
512        alibi_seaorm::store::__private_test_support::migrator::run_migrations(&database)
513            .await
514            .unwrap();
515        let ctx = AuthContext::<TestSchema>::new(
516            Arc::new(config.clone()),
517            Arc::new(SeaOrmStore::<TestSchema>::new(
518                config.clone(),
519                database.clone(),
520            )),
521        );
522        let plugin = JwtPlugin::new();
523        let first = plugin.create_jwk(None, None, &ctx).await.unwrap();
524        let pinned = plugin.create_jwk(None, None, &ctx).await.unwrap();
525        assert_eq!(
526            ctx.database
527                .list_jwks()
528                .await
529                .unwrap()
530                .iter()
531                .map(|key| key.id.as_str())
532                .collect::<Vec<_>>(),
533            vec![first.id.as_str()]
534        );
535        let token = plugin
536            .sign_jwt(
537                payload("pinned-owner"),
538                &JwtSignOptions {
539                    signing_key_id: Some(pinned.id.clone()),
540                    ..Default::default()
541                },
542                None,
543                &ctx,
544            )
545            .await
546            .expect("explicit key lookup must use findOne rather than a limited public list");
547        assert_eq!(decoded(&token).0["kid"], pinned.id);
548        // Verification intentionally uses findMany and cannot see this key under
549        // the same limit. A full keyring verifies the actual issued signature.
550        assert!(
551            plugin
552                .verify_jwt(&token, None, None, &ctx)
553                .await
554                .unwrap()
555                .is_none()
556        );
557        config.advanced.database.default_find_many_limit = 100;
558        let full = AuthContext::<TestSchema>::new(
559            Arc::new(config.clone()),
560            Arc::new(SeaOrmStore::<TestSchema>::new(config, database)),
561        );
562        assert_eq!(full.database.list_jwks().await.unwrap().len(), 2);
563        assert_eq!(
564            plugin
565                .verify_jwt(&token, None, None, &full)
566                .await
567                .unwrap()
568                .unwrap()["sub"],
569            "pinned-owner"
570        );
571    }
572
573    #[tokio::test]
574    async fn verification_rejects_tampering_wrong_claims_unknown_key_and_algorithm_confusion() {
575        let ctx = test_helpers::create_test_context().await;
576        let plugin = JwtPlugin::new();
577        let options = JwtSignOptions::default();
578        for overrides in [
579            json!({"iss":"wrong-issuer"}),
580            json!({"aud":"wrong-audience"}),
581            json!({"exp":0}),
582            json!({"nbf":Utc::now().timestamp()+1000}),
583            json!({"sub":""}),
584        ] {
585            let mut claims = payload("subject-1");
586            claims.extend(overrides.as_object().unwrap().clone());
587            let token = plugin.sign_jwt(claims, &options, None, &ctx).await.unwrap();
588            assert!(
589                plugin
590                    .verify_jwt(&token, None, None, &ctx)
591                    .await
592                    .unwrap()
593                    .is_none()
594            );
595        }
596        let token = plugin
597            .sign_jwt(payload("subject-1"), &options, None, &ctx)
598            .await
599            .unwrap();
600        let mut parts = token.split('.').map(str::to_owned).collect::<Vec<_>>();
601        let mut header = decoded(&token).0;
602        header["kid"] = json!("unknown-key");
603        parts[0] = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
604        assert!(
605            plugin
606                .verify_jwt(&parts.join("."), None, None, &ctx)
607                .await
608                .unwrap()
609                .is_none()
610        );
611        header["kid"] = decoded(&token).0["kid"].clone();
612        header["alg"] = json!("HS256");
613        parts[0] = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
614        assert!(
615            plugin
616                .verify_jwt(&parts.join("."), None, None, &ctx)
617                .await
618                .unwrap()
619                .is_none()
620        );
621        parts[0] = token.split('.').next().unwrap().to_owned();
622        parts[1] = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload("wrong-user")).unwrap());
623        assert!(
624            plugin
625                .verify_jwt(&parts.join("."), None, None, &ctx)
626                .await
627                .unwrap()
628                .is_none()
629        );
630        for malformed in ["", "two.parts", "a.b.c", "a.b.c.d"] {
631            assert!(
632                plugin
633                    .verify_jwt(malformed, None, None, &ctx)
634                    .await
635                    .unwrap()
636                    .is_none()
637            );
638        }
639        assert!(
640            plugin
641                .verify_jwt(&token, Some("wrong-issuer"), None, &ctx)
642                .await
643                .unwrap()
644                .is_none()
645        );
646    }
647
648    #[tokio::test]
649    #[expect(
650        clippy::as_conversions,
651        clippy::cast_precision_loss,
652        reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
653    )]
654    async fn signing_normalizes_jose_numeric_dates_and_rejects_invalid_claim_types() {
655        let ctx = test_helpers::create_test_context().await;
656        let plugin = JwtPlugin::new();
657        for (expiry, seconds) in [
658            ("1m", 60.0),
659            (" 1.5 minutes", 90.0),
660            ("+1 hr", 3600.0),
661            ("1 minute ago", -60.0),
662            ("1 minute AGO", 60.0),
663            ("1 year from now", 31_557_600.0),
664            ("-0.5 secs", -1.0),
665        ] {
666            let before = Utc::now().timestamp() as f64;
667            let mut claims = payload("subject");
668            _ = claims.insert("exp".to_owned(), json!(expiry));
669            let token = plugin
670                .sign_jwt(claims, &JwtSignOptions::default(), None, &ctx)
671                .await
672                .unwrap();
673            let after = Utc::now().timestamp() as f64;
674            let expiry_2 = decoded(&token).1["exp"].as_f64().unwrap();
675            assert!(expiry_2 >= before + seconds && expiry_2 <= after + seconds);
676            assert_eq!(
677                plugin
678                    .verify_jwt(&token, None, None, &ctx)
679                    .await
680                    .unwrap()
681                    .is_some(),
682                seconds > 0.0,
683            );
684        }
685        for overrides in [
686            json!({"exp":"invalid"}),
687            json!({"exp":"+1m ago"}),
688            json!({"exp":false}),
689            json!({"iat":true}),
690            json!({"nbf":true}),
691            json!({"iss":123}),
692            json!({"sub":123}),
693            json!({"jti":true}),
694            json!({"aud":[ctx.config.base_url,123]}),
695            json!({"iat":"1m"}),
696            json!({"iat":""}),
697        ] {
698            let mut claims = payload("subject");
699            claims.extend(overrides.as_object().unwrap().clone());
700            assert!(
701                plugin
702                    .sign_jwt(claims, &JwtSignOptions::default(), None, &ctx)
703                    .await
704                    .is_err()
705            );
706        }
707        let mut relative_iat = payload("relative-iat-owner");
708        relative_iat.extend(
709            json!({"iat":"1m","exp":4_102_444_800_i64})
710                .as_object()
711                .unwrap()
712                .clone(),
713        );
714        let before = Utc::now().timestamp();
715        let token = plugin
716            .sign_jwt(relative_iat, &JwtSignOptions::default(), None, &ctx)
717            .await
718            .unwrap();
719        let claims = decoded(&token).1;
720        assert!(claims["iat"].as_f64().unwrap() >= (before + 60) as f64);
721        assert!(claims["iat"].as_f64().unwrap() <= (Utc::now().timestamp() + 60) as f64);
722        assert_eq!(claims["exp"], 4_102_444_800_i64);
723        let mut false_iat = payload("false-iat-owner");
724        _ = false_iat.insert("iat".to_owned(), json!(false));
725        let token_2 = plugin
726            .sign_jwt(false_iat, &JwtSignOptions::default(), None, &ctx)
727            .await
728            .unwrap();
729        assert_eq!(decoded(&token_2).1["iat"], false);
730        assert_eq!(decoded(&token_2).1["exp"], 900);
731        // Falsy optional claims are retained by the pinned signer rather than sent
732        // to JOSE's setters; its verifier still checks NumericDate types.
733        let token_3 = plugin
734            .sign_jwt(
735                json!({"sub":"subject","iat":null,"jti":false})
736                    .as_object()
737                    .unwrap()
738                    .clone(),
739                &JwtSignOptions::default(),
740                None,
741                &ctx,
742            )
743            .await
744            .unwrap();
745        assert_eq!(decoded(&token_3).1["jti"], false);
746        assert!(
747            plugin
748                .verify_jwt(&token_3, None, None, &ctx)
749                .await
750                .unwrap()
751                .is_none()
752        );
753    }
754
755    #[tokio::test]
756    async fn signing_and_verification_enforce_jose_headers_and_externally_signed_claims() {
757        let ctx = test_helpers::create_test_context().await;
758        let plugin = JwtPlugin::new();
759        let key = plugin
760            .resolve_signing_key(&JwtSignOptions::default(), None, &ctx)
761            .await
762            .unwrap()
763            .unwrap();
764        let claims = json!({"sub":"subject","exp":4_102_444_800_i64,"iss":ctx.config.base_url,"aud":ctx.config.base_url});
765        for (header, signs, verifies) in [
766            (json!({}), true, true),
767            (json!({"crit":["unknown"],"unknown":true}), false, false),
768            (json!({"crit":[]}), false, false),
769            (json!({"crit":["b64"]}), false, false),
770            (json!({"crit":["b64"],"b64":false}), false, false),
771            (json!({"crit":["b64"],"b64":true}), true, true),
772            (json!({"crit":["b64","b64"],"b64":true}), false, true),
773            (json!({"b64":false}), true, true),
774        ] {
775            let mut header = header.as_object().unwrap().clone();
776            let options = JwtSignOptions {
777                header: Some(header.clone()),
778                ..Default::default()
779            };
780            assert_eq!(
781                plugin
782                    .sign_jwt(claims.as_object().unwrap().clone(), &options, None, &ctx)
783                    .await
784                    .is_ok(),
785                signs
786            );
787            _ = header.insert("alg".to_owned(), json!(key.algorithm.as_str()));
788            _ = header.insert("kid".to_owned(), json!(key.key_id));
789            // Use the key directly to create a cryptographically valid JWT even
790            // when the public signer correctly refuses its extension header.
791            let input = format!(
792                "{}.{}",
793                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
794                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap())
795            );
796            let signature =
797                crypto::sign(key.algorithm, &key.private_key, input.as_bytes()).unwrap();
798            let token = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature));
799            assert_eq!(
800                plugin
801                    .verify_jwt(&token, None, None, &ctx)
802                    .await
803                    .unwrap()
804                    .is_some(),
805                verifies
806            );
807        }
808        // JOSE verifies the signature and the pinned helper then applies JS
809        // truthiness to sub. Preserve this upstream behavior for externally signed
810        // tokens, including truthy JSON values the built-in signer refuses.
811        for (subject, verifies) in [
812            (json!(123), true),
813            (json!(true), true),
814            (json!([]), true),
815            (json!({}), true),
816            (json!(false), false),
817            (Value::Null, false),
818            (json!(""), false),
819        ] {
820            let mut claims = claims.as_object().unwrap().clone();
821            _ = claims.insert("sub".to_owned(), subject);
822            let header = json!({"alg":key.algorithm.as_str(),"kid":key.key_id});
823            let input = format!(
824                "{}.{}",
825                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
826                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap())
827            );
828            let signature =
829                crypto::sign(key.algorithm, &key.private_key, input.as_bytes()).unwrap();
830            let token = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature));
831            assert_eq!(
832                plugin
833                    .verify_jwt(&token, None, None, &ctx)
834                    .await
835                    .unwrap()
836                    .is_some(),
837                verifies
838            );
839        }
840        for invalid in [
841            json!({"iat":"invalid"}),
842            json!({"exp":"1m"}),
843            json!({"nbf":true}),
844        ] {
845            let mut claims = claims.as_object().unwrap().clone();
846            claims.extend(invalid.as_object().unwrap().clone());
847            let header = json!({"alg":key.algorithm.as_str(),"kid":key.key_id});
848            let input = format!(
849                "{}.{}",
850                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
851                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap())
852            );
853            let signature =
854                crypto::sign(key.algorithm, &key.private_key, input.as_bytes()).unwrap();
855            let token = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature));
856            assert!(
857                plugin
858                    .verify_jwt(&token, None, None, &ctx)
859                    .await
860                    .unwrap()
861                    .is_none()
862            );
863        }
864    }
865
866    #[tokio::test]
867    async fn verification_imports_persisted_jwk_metadata_as_the_pinned_runtime_does() {
868        let ctx = test_helpers::create_test_context().await;
869        let plugin = JwtPlugin::new();
870        let (public, private) = crypto::generate(&JwtKeyPairConfig::default()).unwrap();
871        let claims = json!({"sub":"subject","exp":4_102_444_800_i64,"iss":ctx.config.base_url,"aud":ctx.config.base_url});
872        for (index, (metadata, verifies)) in [
873            (json!({}), true),
874            (json!({"ext":false}), true),
875            (json!({"key_ops":["verify"]}), true),
876            // importJWK removes alg and use before WebCrypto imports the key.
877            (json!({"alg":"HS256","use":"enc"}), true),
878            (json!({"kty":"EC"}), false),
879            (json!({"crv":"P-256"}), false),
880            (json!({"ext":"false"}), false),
881            (json!({"key_ops":[]}), false),
882            (json!({"key_ops":["sign"]}), false),
883            (json!({"key_ops":["verify","verify"]}), false),
884            (json!({"d":private["d"]}), false),
885        ]
886        .into_iter()
887        .enumerate()
888        {
889            let mut material = public.as_object().unwrap().clone();
890            material.extend(metadata.as_object().unwrap().clone());
891            let key = ctx
892                .database
893                .create_jwk(CreateJwk {
894                    id: Some(format!("metadata-{index}")),
895                    public_key: serde_json::to_string(&material).unwrap(),
896                    private_key: serde_json::to_string(&private).unwrap(),
897                    created_at: Utc::now(),
898                    expires_at: None,
899                    alg: Some("EdDSA".to_owned()),
900                    crv: Some("Ed25519".to_owned()),
901                })
902                .await
903                .unwrap();
904            let header = json!({"alg":"EdDSA","kid":key.id});
905            let input = format!(
906                "{}.{}",
907                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
908                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap())
909            );
910            let signature = crypto::sign(JwtAlgorithm::EdDsa, &private, input.as_bytes()).unwrap();
911            let token = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature));
912            assert_eq!(
913                plugin
914                    .verify_jwt(&token, None, None, &ctx)
915                    .await
916                    .unwrap()
917                    .is_some(),
918                verifies
919            );
920        }
921    }
922
923    #[tokio::test]
924    async fn all_official_algorithms_generate_sign_and_verify_with_matching_jwks() {
925        for algorithm in [
926            JwtAlgorithm::EdDsa,
927            JwtAlgorithm::Es256,
928            JwtAlgorithm::Es512,
929            JwtAlgorithm::Rs256,
930            JwtAlgorithm::Ps256,
931        ] {
932            let ctx = test_helpers::create_test_context().await;
933            let plugin = JwtPlugin::with_config(JwtPluginConfig {
934                key_pair: JwtKeyPairConfig {
935                    algorithm,
936                    modulus_length: None,
937                },
938                ..Default::default()
939            });
940            let claims = json!({ "sub": "subject", "iat": 100, "exp": 4_102_444_800_i64 })
941                .as_object()
942                .unwrap()
943                .clone();
944            let token = plugin
945                .sign_jwt(claims.clone(), &JwtSignOptions::default(), None, &ctx)
946                .await
947                .unwrap();
948            let (header, _) = decoded(&token);
949            assert_eq!(header["alg"], algorithm.as_str());
950            let repeated = plugin
951                .sign_jwt(claims, &JwtSignOptions::default(), None, &ctx)
952                .await
953                .unwrap();
954            assert_eq!(decoded(&token), decoded(&repeated));
955            if matches!(algorithm, JwtAlgorithm::EdDsa | JwtAlgorithm::Rs256) {
956                assert_eq!(token, repeated);
957            } else {
958                // Pinned WebCrypto uses fresh ECDSA nonces and PSS salt even when
959                // the exact protected header and claims are signed twice.
960                assert_ne!(token, repeated);
961            }
962            assert!(
963                plugin
964                    .verify_jwt(&token, None, None, &ctx)
965                    .await
966                    .unwrap()
967                    .is_some()
968            );
969            let key = ctx
970                .database
971                .get_jwk_by_id(header["kid"].as_str().unwrap())
972                .await
973                .unwrap()
974                .unwrap();
975            assert_eq!(key.alg.as_deref(), Some(algorithm.as_str()));
976            assert_eq!(key.crv.as_deref(), algorithm.curve());
977        }
978    }
979
980    #[tokio::test]
981    #[expect(
982        clippy::too_many_lines,
983        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
984    )]
985    async fn rotation_keeps_public_keys_for_grace_and_pinning_never_silently_changes_keys() {
986        let ctx = test_helpers::create_test_context().await;
987        let plugin = JwtPlugin::with_config(JwtPluginConfig {
988            grace_period: Duration::hours(2),
989            ..Default::default()
990        });
991        let (public, private) = crypto::generate(&JwtKeyPairConfig::default()).unwrap();
992        let old = ctx
993            .database
994            .create_jwk(CreateJwk {
995                id: Some("expired-key".to_owned()),
996                public_key: serde_json::to_string(&public).unwrap(),
997                private_key: serde_json::to_string(
998                    &encrypt(
999                        &serde_json::to_string(&private).unwrap(),
1000                        &ctx.config.secret,
1001                    )
1002                    .unwrap(),
1003                )
1004                .unwrap(),
1005                created_at: Utc::now() - Duration::days(1),
1006                expires_at: Some(Utc::now() - Duration::hours(1)),
1007                alg: Some("EdDSA".to_owned()),
1008                crv: Some("Ed25519".to_owned()),
1009            })
1010            .await
1011            .unwrap();
1012        let old_token = JwtPlugin::sign_resolved(
1013            plugin
1014                .default_claims(payload("old-subject"), None, &ctx)
1015                .unwrap(),
1016            &JwtSignOptions::default(),
1017            &ResolvedJwtSigningKey {
1018                algorithm: JwtAlgorithm::EdDsa,
1019                key_id: old.id.clone(),
1020                private_key: private,
1021            },
1022        )
1023        .unwrap();
1024        let current_token = plugin
1025            .sign_jwt(
1026                payload("new-subject"),
1027                &JwtSignOptions::default(),
1028                None,
1029                &ctx,
1030            )
1031            .await
1032            .unwrap();
1033        assert_ne!(decoded(&current_token).0["kid"], old.id);
1034        let jwks = plugin
1035            .jwks(
1036                &test_helpers::create_auth_request_no_query(HttpMethod::Get, "/jwks", None, None),
1037                &ctx,
1038            )
1039            .await
1040            .unwrap();
1041        assert_eq!(
1042            serde_json::from_slice::<Value>(&jwks.body).unwrap()["keys"]
1043                .as_array()
1044                .unwrap()
1045                .len(),
1046            2
1047        );
1048        assert!(
1049            plugin
1050                .verify_jwt(&old_token, None, None, &ctx)
1051                .await
1052                .unwrap()
1053                .is_some()
1054        );
1055        let beyond_grace = JwtPlugin::with_config(JwtPluginConfig {
1056            grace_period: Duration::seconds(1),
1057            ..Default::default()
1058        });
1059        let jwks_2 = beyond_grace
1060            .jwks(
1061                &test_helpers::create_auth_request_no_query(HttpMethod::Get, "/jwks", None, None),
1062                &ctx,
1063            )
1064            .await
1065            .unwrap();
1066        assert_eq!(
1067            serde_json::from_slice::<Value>(&jwks_2.body).unwrap()["keys"]
1068                .as_array()
1069                .unwrap()
1070                .len(),
1071            1
1072        );
1073        // Server verification deliberately reads the keyring, even after the
1074        // public endpoint's grace window has ended, matching the pinned runtime.
1075        assert!(
1076            beyond_grace
1077                .verify_jwt(&old_token, None, None, &ctx)
1078                .await
1079                .unwrap()
1080                .is_some()
1081        );
1082        assert!(
1083            plugin
1084                .sign_jwt(
1085                    payload("subject"),
1086                    &JwtSignOptions {
1087                        signing_key_id: Some(old.id),
1088                        ..Default::default()
1089                    },
1090                    None,
1091                    &ctx
1092                )
1093                .await
1094                .is_err()
1095        );
1096        assert!(
1097            plugin
1098                .sign_jwt(
1099                    payload("subject"),
1100                    &JwtSignOptions {
1101                        signing_key_id: Some("missing".to_owned()),
1102                        ..Default::default()
1103                    },
1104                    None,
1105                    &ctx
1106                )
1107                .await
1108                .is_err()
1109        );
1110    }
1111
1112    #[tokio::test]
1113    async fn explicit_algorithm_lazy_mints_only_configured_keys_and_default_uses_primary() {
1114        let ctx = test_helpers::create_test_context().await;
1115        let plugin = JwtPlugin::with_config(JwtPluginConfig {
1116            additional_key_pairs: vec![JwtKeyPairConfig {
1117                algorithm: JwtAlgorithm::Es256,
1118                modulus_length: None,
1119            }],
1120            ..Default::default()
1121        });
1122        let extra = plugin
1123            .sign_jwt(
1124                payload("extra"),
1125                &JwtSignOptions {
1126                    signing_algorithm: Some(JwtAlgorithm::Es256),
1127                    header: Some(json!({"typ":"logout+jwt"}).as_object().unwrap().clone()),
1128                    ..Default::default()
1129                },
1130                None,
1131                &ctx,
1132            )
1133            .await
1134            .unwrap();
1135        assert_eq!(decoded(&extra).0["alg"], "ES256");
1136        assert_eq!(decoded(&extra).0["typ"], "logout+jwt");
1137        // A primary key is provisioned explicitly because upstream's unpinned
1138        // fallback uses a previously provisioned live extra key when none exists.
1139        plugin.create_jwk(None, None, &ctx).await.unwrap();
1140        let primary = plugin
1141            .sign_jwt(payload("primary"), &JwtSignOptions::default(), None, &ctx)
1142            .await
1143            .unwrap();
1144        assert_eq!(decoded(&primary).0["alg"], "EdDSA");
1145        assert!(
1146            plugin
1147                .sign_jwt(
1148                    payload("unconfigured"),
1149                    &JwtSignOptions {
1150                        signing_algorithm: Some(JwtAlgorithm::Rs256),
1151                        ..Default::default()
1152                    },
1153                    None,
1154                    &ctx
1155                )
1156                .await
1157                .is_err()
1158        );
1159        let extra_id = decoded(&extra).0["kid"].as_str().unwrap().to_owned();
1160        assert!(
1161            plugin
1162                .sign_jwt(
1163                    payload("mismatch"),
1164                    &JwtSignOptions {
1165                        signing_key_id: Some(extra_id),
1166                        signing_algorithm: Some(JwtAlgorithm::EdDsa),
1167                        ..Default::default()
1168                    },
1169                    None,
1170                    &ctx
1171                )
1172                .await
1173                .is_err()
1174        );
1175    }
1176
1177    #[tokio::test]
1178    async fn session_payload_and_server_only_endpoints_have_distinct_authority() {
1179        let ctx = test_helpers::create_test_context().await;
1180        let plugin = JwtPlugin::new();
1181        let (user, session) = test_helpers::create_user_and_session(
1182            &ctx,
1183            CreateUser::new().with_email("jwt@fixture.test"),
1184            Duration::days(1),
1185        )
1186        .await;
1187        let mut request =
1188            test_helpers::create_auth_request_no_query(HttpMethod::Get, "/token", None, None);
1189        assert!(matches!(
1190            plugin.session_token(&request, &ctx).await,
1191            Err(AuthError::Upstream { status: 401, .. })
1192        ));
1193        request.headers.insert(
1194            "cookie".to_owned(),
1195            alibi_core::utils::cookie_utils::create_session_cookie(&session.token, &ctx.config)
1196                .unwrap()
1197                .split(';')
1198                .next()
1199                .unwrap()
1200                .to_owned(),
1201        );
1202        let token = plugin.session_token(&request, &ctx).await.unwrap();
1203        let claims = plugin
1204            .verify_jwt(&token, None, None, &ctx)
1205            .await
1206            .unwrap()
1207            .unwrap();
1208        assert_eq!(claims["sub"], user.id);
1209        assert_eq!(claims["email"], user.email.as_deref().unwrap());
1210        for path in ["/sign-jwt", "/verify-jwt", "/jwt/sign", "/jwt/verify"] {
1211            assert!(
1212                plugin
1213                    .on_request(
1214                        &test_helpers::create_auth_request_no_query(
1215                            HttpMethod::Post,
1216                            path,
1217                            None,
1218                            None
1219                        ),
1220                        &ctx
1221                    )
1222                    .await
1223                    .unwrap()
1224                    .is_none()
1225            );
1226        }
1227        assert_eq!(
1228            <JwtPlugin as AuthPlugin<TestSchema>>::routes(&plugin).len(),
1229            2
1230        );
1231    }
1232
1233    #[tokio::test]
1234    async fn application_session_callbacks_replace_default_claims_and_preserve_subject_fallback() {
1235        let ctx = test_helpers::create_test_context().await;
1236        let (user, session) = test_helpers::create_user_and_session(
1237            &ctx,
1238            CreateUser::new()
1239                .with_email("callback-owner@fixture.test")
1240                .with_name("Private name"),
1241            Duration::hours(1),
1242        )
1243        .await;
1244        let request = test_helpers::create_auth_request_no_query(
1245            HttpMethod::Get,
1246            "/token",
1247            Some(&session.token),
1248            None,
1249        );
1250        for subject in [Some("application-subject".to_owned()), None] {
1251            let plugin = JwtPlugin::with_config(JwtPluginConfig {
1252                define_payload: Some(Arc::new(ApplicationClaims)),
1253                define_subject: Some(Arc::new(ApplicationSubject(subject.clone()))),
1254                ..Default::default()
1255            });
1256            let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
1257            assert_eq!(response.status, 200);
1258            let response: Value = serde_json::from_slice(&response.body).unwrap();
1259            let claims = plugin
1260                .verify_jwt(response["token"].as_str().unwrap(), None, None, &ctx)
1261                .await
1262                .unwrap()
1263                .unwrap();
1264            assert_eq!(claims["purpose"], "application");
1265            assert_eq!(claims["ownerId"], user.id);
1266            assert_eq!(claims["loginId"], session.id);
1267            assert_eq!(claims["sub"], subject.as_deref().unwrap_or(&user.id));
1268            assert!(!claims.contains_key("email"));
1269            assert!(!claims.contains_key("name"));
1270            assert_eq!(
1271                claims["exp"].as_i64().unwrap() - claims["iat"].as_i64().unwrap(),
1272                900
1273            );
1274        }
1275    }
1276
1277    #[tokio::test]
1278    async fn application_keyring_persists_and_resolves_keys_outside_auth_storage() {
1279        let ctx = test_helpers::create_test_context().await;
1280        let application_keys = test_helpers::create_test_context().await;
1281        let plugin = JwtPlugin::with_config(JwtPluginConfig {
1282            keyring: Some(Arc::new(ApplicationKeyring {
1283                database: Arc::clone(&application_keys.database),
1284            })),
1285            ..Default::default()
1286        });
1287        let request =
1288            test_helpers::create_auth_request_no_query(HttpMethod::Get, "/jwks", None, None);
1289        let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
1290        assert_eq!(response.status, 200);
1291        let public: Value = serde_json::from_slice(&response.body).unwrap();
1292        assert_eq!(public["keys"][0]["kid"], "application-signing-key");
1293        let options = JwtSignOptions {
1294            signing_key_id: Some("application-signing-key".to_owned()),
1295            ..Default::default()
1296        };
1297        let token = plugin
1298            .sign_jwt(payload("external-key-owner"), &options, None, &ctx)
1299            .await
1300            .unwrap();
1301        let (header, claims) = decoded(&token);
1302        assert_eq!(header["kid"], "application-signing-key");
1303        assert_eq!(
1304            plugin
1305                .verify_jwt(&token, None, None, &ctx)
1306                .await
1307                .unwrap()
1308                .unwrap(),
1309            claims.as_object().unwrap().clone()
1310        );
1311        assert!(ctx.database.list_jwks().await.unwrap().is_empty());
1312        let persisted = application_keys.database.list_jwks().await.unwrap();
1313        assert_eq!(persisted.len(), 1);
1314        assert_eq!(persisted[0].id, "application-signing-key");
1315        assert!(!persisted[0].private_key.contains("\"d\""));
1316        let private = decrypt(
1317            &serde_json::from_str::<String>(&persisted[0].private_key).unwrap(),
1318            &ctx.config.secret,
1319        )
1320        .unwrap();
1321        assert!(serde_json::from_str::<Value>(&private).unwrap()["d"].is_string());
1322        assert!(
1323            JwtPlugin::new()
1324                .verify_jwt(&token, None, None, &ctx)
1325                .await
1326                .unwrap()
1327                .is_none()
1328        );
1329    }
1330
1331    #[tokio::test]
1332    async fn delegated_signing_uses_external_keys_and_preserves_explicit_payload_and_headers() {
1333        let ctx = test_helpers::create_test_context().await;
1334        let service = Arc::new(ApplicationSigner {
1335            context: test_helpers::create_test_context().await,
1336            plugin: JwtPlugin::new(),
1337        });
1338        let plugin = JwtPlugin::with_config(JwtPluginConfig {
1339            remote_url: Some("https://keys.fixture.test/jwks".to_owned()),
1340            remote_signer: Some(Arc::<ApplicationSigner>::clone(&service)),
1341            ..Default::default()
1342        });
1343        let options = JwtSignOptions {
1344            header: Some(
1345                json!({"typ":"application+jwt"})
1346                    .as_object()
1347                    .unwrap()
1348                    .clone(),
1349            ),
1350            ..Default::default()
1351        };
1352        let explicit = json!({"sub":"delegated-owner","permission":"read","iat":Utc::now().timestamp(),"exp":Utc::now().timestamp()+600}).as_object().unwrap().clone();
1353        let token = plugin
1354            .sign_jwt(explicit.clone(), &options, None, &ctx)
1355            .await
1356            .unwrap();
1357        let (header, claims) = decoded(&token);
1358        assert_eq!(header["typ"], "application+jwt");
1359        for (name, value) in explicit {
1360            assert_eq!(claims[&name], value);
1361        }
1362        assert_eq!(claims["iss"], ctx.config.base_url);
1363        assert_eq!(claims["aud"], ctx.config.base_url);
1364        assert_eq!(
1365            service
1366                .plugin
1367                .verify_jwt(&token, None, None, &service.context)
1368                .await
1369                .unwrap()
1370                .unwrap(),
1371            claims.as_object().unwrap().clone()
1372        );
1373        assert!(ctx.database.list_jwks().await.unwrap().is_empty());
1374        assert_eq!(service.context.database.list_jwks().await.unwrap().len(), 1);
1375        let request =
1376            test_helpers::create_auth_request_no_query(HttpMethod::Get, "/jwks", None, None);
1377        let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
1378        assert_eq!(response.status, 404);
1379        assert_eq!(response.body.len(), 0);
1380        // The reference verifier reads its configured keyring; remoteUrl does not
1381        // substitute external keys for the local verification adapter.
1382        assert!(
1383            plugin
1384                .verify_jwt(&token, None, None, &ctx)
1385                .await
1386                .unwrap()
1387                .is_none()
1388        );
1389        let invalid = JwtPlugin::with_config(JwtPluginConfig {
1390            remote_signer: Some(service),
1391            ..Default::default()
1392        });
1393        let mut init = AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1394        assert!(invalid.on_init(&mut init).await.is_err());
1395    }
1396
1397    // Fixture produced by the unchanged pinned createJwk/signJWT implementation.
1398    // Rust consumes the imported encrypted row rather than encrypting its own key.
1399    #[tokio::test]
1400    async fn signs_pinned_typescript_encrypted_key_and_refuses_a_changed_secret() {
1401        let fixture: Value = serde_json::from_str(include_str!(
1402            "../../../../tests/fixtures/jwt/typescript-1.7.7-encrypted-jwk.json"
1403        ))
1404        .unwrap();
1405        assert_eq!(fixture["referenceVersion"], "better-auth@1.7.7");
1406        let config = alibi_core::AuthConfig::new(fixture["secret"].as_str().unwrap())
1407            .base_url(fixture["origin"].as_str().unwrap());
1408        let ctx = test_helpers::create_test_context_with_config(config.clone()).await;
1409        let key = &fixture["key"];
1410        let imported = ctx
1411            .database
1412            .create_jwk(CreateJwk {
1413                id: Some(key["id"].as_str().unwrap().to_owned()),
1414                public_key: key["publicKey"].as_str().unwrap().to_owned(),
1415                private_key: key["privateKey"].as_str().unwrap().to_owned(),
1416                created_at: key["createdAt"].as_str().unwrap().parse().unwrap(),
1417                expires_at: None,
1418                alg: Some("EdDSA".to_owned()),
1419                crv: Some("Ed25519".to_owned()),
1420            })
1421            .await
1422            .unwrap();
1423        let plugin = JwtPlugin::new();
1424        let payload = fixture["payload"].as_object().unwrap().clone();
1425        let options = JwtSignOptions {
1426            signing_key_id: Some(imported.id.clone()),
1427            ..Default::default()
1428        };
1429        let token = plugin
1430            .sign_jwt(payload.clone(), &options, None, &ctx)
1431            .await
1432            .unwrap();
1433        assert_eq!(token, fixture["token"].as_str().unwrap());
1434        assert_eq!(
1435            plugin
1436                .verify_jwt(&token, None, None, &ctx)
1437                .await
1438                .unwrap()
1439                .unwrap(),
1440            payload
1441        );
1442        let mut changed = config;
1443        changed.secret = "a-different-keyring-secret-at-least-32-characters".to_owned();
1444        let changed = AuthContext::<TestSchema>::new(Arc::new(changed), Arc::clone(&ctx.database));
1445        let error = plugin
1446            .sign_jwt(
1447                fixture["payload"].as_object().unwrap().clone(),
1448                &options,
1449                None,
1450                &changed,
1451            )
1452            .await
1453            .unwrap_err();
1454        assert!(error.to_string().contains("Failed to decrypt private key"));
1455        assert_eq!(ctx.database.list_jwks().await.unwrap().len(), 1);
1456        assert_eq!(
1457            ctx.database
1458                .get_jwk_by_id(&imported.id)
1459                .await
1460                .unwrap()
1461                .unwrap()
1462                .private_key,
1463            imported.private_key
1464        );
1465        assert_eq!(
1466            plugin
1467                .sign_jwt(
1468                    fixture["payload"].as_object().unwrap().clone(),
1469                    &options,
1470                    None,
1471                    &ctx
1472                )
1473                .await
1474                .unwrap(),
1475            token
1476        );
1477    }
1478
1479    #[tokio::test]
1480    #[expect(
1481        clippy::too_many_lines,
1482        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1483    )]
1484    async fn imported_private_ec_coordinates_must_match_the_secret_scalar() {
1485        for algorithm in [
1486            JwtAlgorithm::Es256,
1487            JwtAlgorithm::Es512,
1488            JwtAlgorithm::EdDsa,
1489        ] {
1490            let ctx = test_helpers::create_test_context().await;
1491            let pair = JwtKeyPairConfig {
1492                algorithm,
1493                ..Default::default()
1494            };
1495            let plugin = JwtPlugin::with_config(JwtPluginConfig {
1496                key_pair: pair.clone(),
1497                ..Default::default()
1498            });
1499            let (public, mut private) = crypto::generate(&pair).unwrap();
1500            let (_, unrelated) = crypto::generate(&pair).unwrap();
1501            private["x"] = unrelated["x"].clone();
1502            if algorithm != JwtAlgorithm::EdDsa {
1503                private["y"] = unrelated["y"].clone();
1504            }
1505            let stored = ctx
1506                .database
1507                .create_jwk(CreateJwk {
1508                    id: Some(format!("imported-{}", algorithm.as_str())),
1509                    public_key: serde_json::to_string(&public).unwrap(),
1510                    private_key: serde_json::to_string(
1511                        &encrypt(
1512                            &serde_json::to_string(&private).unwrap(),
1513                            &ctx.config.secret,
1514                        )
1515                        .unwrap(),
1516                    )
1517                    .unwrap(),
1518                    created_at: Utc::now(),
1519                    expires_at: None,
1520                    alg: Some(algorithm.as_str().to_owned()),
1521                    crv: algorithm.curve().map(str::to_owned),
1522                })
1523                .await
1524                .unwrap();
1525            let result = plugin
1526                .sign_jwt(
1527                    payload("imported-owner"),
1528                    &JwtSignOptions {
1529                        signing_key_id: Some(stored.id.clone()),
1530                        ..Default::default()
1531                    },
1532                    None,
1533                    &ctx,
1534                )
1535                .await;
1536            if algorithm == JwtAlgorithm::EdDsa {
1537                // Pinned Ed25519 import derives x from d and accepts unrelated x.
1538                let token = result.expect("EdDSA intentionally retains the pinned import behavior");
1539                assert!(
1540                    plugin
1541                        .verify_jwt(&token, None, None, &ctx)
1542                        .await
1543                        .unwrap()
1544                        .is_some()
1545                );
1546            } else {
1547                assert!(
1548                    result.is_err(),
1549                    "EC imports must reject unrelated public coordinates"
1550                );
1551            }
1552            assert_eq!(ctx.database.list_jwks().await.unwrap().len(), 1);
1553            assert_eq!(
1554                ctx.database
1555                    .get_jwk_by_id(&stored.id)
1556                    .await
1557                    .unwrap()
1558                    .unwrap()
1559                    .private_key,
1560                stored.private_key
1561            );
1562            if algorithm != JwtAlgorithm::EdDsa {
1563                let (public_3, mut private_3, scalar) = loop {
1564                    let (public_2, private_2) = crypto::generate(&pair).unwrap();
1565                    let scalar = URL_SAFE_NO_PAD
1566                        .decode(private_2["d"].as_str().unwrap())
1567                        .unwrap();
1568                    if scalar.first() == Some(&0) {
1569                        break (public_2, private_2, scalar);
1570                    }
1571                };
1572                let mut padded_once = vec![0];
1573                padded_once.extend(&scalar);
1574                let mut padded_many = vec![0; 32];
1575                padded_many.extend(&scalar);
1576                let mut overflow = vec![1];
1577                overflow.extend(&scalar);
1578                for (index, (scalar_2, accepted)) in [
1579                    (scalar[1..].to_vec(), true),
1580                    (padded_once, true),
1581                    (padded_many, true),
1582                    (overflow, false),
1583                ]
1584                .into_iter()
1585                .enumerate()
1586                {
1587                    private_3["d"] = json!(URL_SAFE_NO_PAD.encode(scalar_2));
1588                    let imported = ctx
1589                        .database
1590                        .create_jwk(CreateJwk {
1591                            id: Some(format!("unsigned-scalar-{}-{index}", algorithm.as_str())),
1592                            public_key: serde_json::to_string(&public_3).unwrap(),
1593                            private_key: serde_json::to_string(
1594                                &encrypt(
1595                                    &serde_json::to_string(&private_3).unwrap(),
1596                                    &ctx.config.secret,
1597                                )
1598                                .unwrap(),
1599                            )
1600                            .unwrap(),
1601                            created_at: Utc::now(),
1602                            expires_at: None,
1603                            alg: Some(algorithm.as_str().to_owned()),
1604                            crv: algorithm.curve().map(str::to_owned),
1605                        })
1606                        .await
1607                        .unwrap();
1608                    let result_2 = plugin
1609                        .sign_jwt(
1610                            payload("matching-owner"),
1611                            &JwtSignOptions {
1612                                signing_key_id: Some(imported.id.clone()),
1613                                ..Default::default()
1614                            },
1615                            None,
1616                            &ctx,
1617                        )
1618                        .await;
1619                    if accepted {
1620                        let token = result_2.expect("unsigned scalar leading zero representations with matching coordinates remain valid");
1621                        assert!(
1622                            plugin
1623                                .verify_jwt(&token, None, None, &ctx)
1624                                .await
1625                                .unwrap()
1626                                .is_some()
1627                        );
1628                    } else {
1629                        assert!(
1630                            result_2.is_err(),
1631                            "nonzero scalar overflow must fail import"
1632                        );
1633                    }
1634                    assert_eq!(ctx.database.list_jwks().await.unwrap().len(), index + 2);
1635                    assert_eq!(
1636                        ctx.database
1637                            .get_jwk_by_id(&imported.id)
1638                            .await
1639                            .unwrap()
1640                            .unwrap()
1641                            .private_key,
1642                        imported.private_key
1643                    );
1644                }
1645            }
1646        }
1647    }
1648
1649    #[tokio::test]
1650    async fn negative_rotation_issues_one_expired_key_per_request() {
1651        let ctx = test_helpers::create_test_context().await;
1652        let plugin = JwtPlugin::with_config(JwtPluginConfig {
1653            rotation_interval: Some(Duration::seconds(-1)),
1654            ..Default::default()
1655        });
1656        for count in [1, 2] {
1657            let token = plugin
1658                .sign_jwt(
1659                    payload("negative-rotation-owner"),
1660                    &JwtSignOptions::default(),
1661                    None,
1662                    &ctx,
1663                )
1664                .await
1665                .unwrap();
1666            let keys = ctx.database.list_jwks().await.unwrap();
1667            assert_eq!(
1668                keys.len(),
1669                count,
1670                "one issuance must persist exactly one replacement key"
1671            );
1672            let signing_id = decoded(&token).0["kid"].as_str().unwrap().to_owned();
1673            assert!(keys.iter().any(|key| key.id == signing_id
1674                && key.expires_at.is_some_and(|expiry| expiry < Utc::now())));
1675            assert!(
1676                plugin
1677                    .verify_jwt(&token, None, None, &ctx)
1678                    .await
1679                    .unwrap()
1680                    .is_some()
1681            );
1682            assert!(
1683                plugin
1684                    .sign_jwt(
1685                        payload("explicit-owner"),
1686                        &JwtSignOptions {
1687                            signing_key_id: Some(signing_id),
1688                            ..Default::default()
1689                        },
1690                        None,
1691                        &ctx
1692                    )
1693                    .await
1694                    .is_err()
1695            );
1696            assert_eq!(ctx.database.list_jwks().await.unwrap().len(), count);
1697        }
1698    }
1699
1700    #[tokio::test]
1701    #[expect(
1702        clippy::too_many_lines,
1703        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1704    )]
1705    async fn verifies_compact_signature_padding_bits_and_ascii_whitespace_without_accepting_base64_alphabet()
1706     {
1707        const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1708
1709        let ctx = test_helpers::create_test_context().await;
1710        let plugin = JwtPlugin::new();
1711        let token = plugin
1712            .sign_jwt(
1713                payload("encoding-owner"),
1714                &JwtSignOptions::default(),
1715                None,
1716                &ctx,
1717            )
1718            .await
1719            .unwrap();
1720        let parts = token.split('.').collect::<Vec<_>>();
1721        let signature = parts[2];
1722        assert_eq!(signature.len() % 4, 2);
1723        for (suffix, accepted) in [
1724            ("", true),
1725            ("=", false),
1726            ("==", true),
1727            ("===", false),
1728            ("====", false),
1729        ] {
1730            let encoded = format!("{}.{}.{}{suffix}", parts[0], parts[1], signature);
1731            assert_eq!(
1732                plugin
1733                    .verify_jwt(&encoded, None, None, &ctx)
1734                    .await
1735                    .unwrap()
1736                    .is_some(),
1737                accepted
1738            );
1739        }
1740
1741        let final_index = ALPHABET
1742            .iter()
1743            .position(|value| *value == *signature.as_bytes().last().unwrap())
1744            .unwrap();
1745        for offset in [1, 2, 15] {
1746            let alternate = format!(
1747                "{}.{}.{}{}",
1748                parts[0],
1749                parts[1],
1750                (signature)
1751                    .get(..signature.len() - 1)
1752                    .expect("fixture range is on a UTF-8 boundary"),
1753                char::from(
1754                    *ALPHABET
1755                        .get(final_index + offset)
1756                        .expect("fixture index is in the alphabet")
1757                )
1758            );
1759            assert!(
1760                plugin
1761                    .verify_jwt(&alternate, None, None, &ctx)
1762                    .await
1763                    .unwrap()
1764                    .is_some()
1765            );
1766        }
1767        for whitespace in [" ", "\t", "\n", "\r", "\u{000c}"] {
1768            let alternate = format!(
1769                "{}.{}.{}{}{}=={}",
1770                parts[0],
1771                parts[1],
1772                (signature)
1773                    .get(..20)
1774                    .expect("fixture range is on a UTF-8 boundary"),
1775                whitespace,
1776                (signature)
1777                    .get(20..)
1778                    .expect("fixture range is on a UTF-8 boundary"),
1779                whitespace
1780            );
1781            assert!(
1782                plugin
1783                    .verify_jwt(&alternate, None, None, &ctx)
1784                    .await
1785                    .unwrap()
1786                    .is_some()
1787            );
1788        }
1789        for rejected in [
1790            format!(
1791                "+{}",
1792                (signature)
1793                    .get(1..)
1794                    .expect("fixture range is on a UTF-8 boundary")
1795            ),
1796            format!(
1797                "/{}",
1798                (signature)
1799                    .get(1..)
1800                    .expect("fixture range is on a UTF-8 boundary")
1801            ),
1802            format!("={signature}"),
1803            format!("{signature}==A"),
1804            format!("{signature}\u{000b}"),
1805            format!("{signature}\u{00a0}"),
1806        ] {
1807            let alternate = format!("{}.{}.{}", parts[0], parts[1], rejected);
1808            assert!(
1809                plugin
1810                    .verify_jwt(&alternate, None, None, &ctx)
1811                    .await
1812                    .unwrap()
1813                    .is_none()
1814            );
1815        }
1816
1817        let stored = ctx.database.list_jwks().await.unwrap().remove(0);
1818        let private: Value = serde_json::from_str(
1819            &decrypt(
1820                &serde_json::from_str::<String>(&stored.private_key).unwrap(),
1821                &ctx.config.secret,
1822            )
1823            .unwrap(),
1824        )
1825        .unwrap();
1826        // Re-sign changed header/payload encodings. A rejection must come from
1827        // decoding, rather than the unchanged signature no longer covering them.
1828        for extra in 0..3 {
1829            let proof = format!("{}\u{ffff}", "x".repeat(extra));
1830            let header = URL_SAFE_NO_PAD.encode(
1831                serde_json::to_vec(&json!({"alg":"EdDSA","kid":stored.id,"proof":proof})).unwrap(),
1832            );
1833            let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({"sub":"encoding-owner","exp":4_102_444_800_i64,"iss":ctx.config.base_url,"aud":ctx.config.base_url,"proof":proof})).unwrap());
1834            for (index, source) in [&header, &claims].into_iter().enumerate() {
1835                let remainder = source.len() % 4;
1836                let mut variants = vec![(source.clone(), true)];
1837                for padding in 1..=4 {
1838                    variants.push((
1839                        format!("{source}{}", "=".repeat(padding)),
1840                        remainder != 0 && padding == 4 - remainder,
1841                    ));
1842                }
1843                if matches!(remainder, 2 | 3) {
1844                    let last = ALPHABET
1845                        .iter()
1846                        .position(|value| *value == *source.as_bytes().last().unwrap())
1847                        .unwrap();
1848                    variants.push((
1849                        format!(
1850                            "{}{}",
1851                            (source)
1852                                .get(..source.len() - 1)
1853                                .expect("fixture range is on a UTF-8 boundary"),
1854                            char::from(
1855                                *ALPHABET
1856                                    .get(last + 1)
1857                                    .expect("fixture index is in the alphabet")
1858                            )
1859                        ),
1860                        true,
1861                    ));
1862                }
1863                for whitespace in [" ", "\t", "\n", "\r", "\u{000c}", "\u{000b}", "\u{00a0}"] {
1864                    variants.push((
1865                        format!(
1866                            "{}{}{}",
1867                            (source)
1868                                .get(..3)
1869                                .expect("fixture range is on a UTF-8 boundary"),
1870                            whitespace,
1871                            (source)
1872                                .get(3..)
1873                                .expect("fixture range is on a UTF-8 boundary")
1874                        ),
1875                        index == 1 && matches!(whitespace, " " | "\t" | "\n" | "\r" | "\u{000c}"),
1876                    ));
1877                }
1878                let ordinary = source.replace('-', "+").replace('_', "/");
1879                if ordinary != *source {
1880                    variants.push((ordinary, false));
1881                }
1882                for (variant, accepted) in variants {
1883                    let mut segments = [header.clone(), claims.clone()];
1884                    segments[index] = variant;
1885                    let input = segments.join(".");
1886                    let signature_2 =
1887                        crypto::sign(JwtAlgorithm::EdDsa, &private, input.as_bytes()).unwrap();
1888                    let token_2 = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature_2));
1889                    assert_eq!(
1890                        plugin
1891                            .verify_jwt(&token_2, None, None, &ctx)
1892                            .await
1893                            .unwrap()
1894                            .is_some(),
1895                        accepted,
1896                        "segment {index}, remainder {remainder}"
1897                    );
1898                }
1899            }
1900        }
1901    }
1902
1903    #[tokio::test]
1904    #[expect(
1905        clippy::too_many_lines,
1906        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1907    )]
1908    async fn imported_jwk_material_accepts_padded_coordinates_and_rejects_other_alphabets_and_whitespace()
1909     {
1910        for algorithm in [JwtAlgorithm::Es256, JwtAlgorithm::Es512] {
1911            let ctx = test_helpers::create_test_context().await;
1912            let pair = JwtKeyPairConfig {
1913                algorithm,
1914                ..Default::default()
1915            };
1916            let plugin = JwtPlugin::with_config(JwtPluginConfig {
1917                key_pair: pair.clone(),
1918                disable_private_key_encryption: true,
1919                ..Default::default()
1920            });
1921            let (public, private) = crypto::generate(&pair).unwrap();
1922            let mut imports = Vec::<(Value, Value, bool, Option<Value>)>::new();
1923            for suffix in ["", "=", "==", "===", "===="] {
1924                let mut imported_private = private.clone();
1925                let mut imported_public = public.clone();
1926                for name in ["d", "x", "y"] {
1927                    imported_private[name] =
1928                        json!(format!("{}{suffix}", private[name].as_str().unwrap()));
1929                    if name != "d" {
1930                        imported_public[name] = imported_private[name].clone();
1931                    }
1932                }
1933                imports.push((imported_public, imported_private, true, None));
1934            }
1935            if algorithm == JwtAlgorithm::Es256 {
1936                const ALPHABET: &[u8] =
1937                    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1938                for offset in [1, 3] {
1939                    let mut imported_private = private.clone();
1940                    let mut imported_public = public.clone();
1941                    for name in ["d", "x", "y"] {
1942                        let source = private[name].as_str().unwrap();
1943                        let last = ALPHABET
1944                            .iter()
1945                            .position(|value| *value == *source.as_bytes().last().unwrap())
1946                            .unwrap();
1947                        imported_private[name] = json!(format!(
1948                            "{}{}",
1949                            (source)
1950                                .get(..source.len() - 1)
1951                                .expect("fixture range is on a UTF-8 boundary"),
1952                            char::from(
1953                                *ALPHABET
1954                                    .get(last + offset)
1955                                    .expect("fixture index is in the alphabet")
1956                            )
1957                        ));
1958                        if name != "d" {
1959                            imported_public[name] = imported_private[name].clone();
1960                        }
1961                    }
1962                    imports.push((imported_public, imported_private, true, None));
1963                }
1964            }
1965            let (coordinate_public, coordinate_private, x) = loop {
1966                let (public_2, private_2) = crypto::generate(&pair).unwrap();
1967                let x = URL_SAFE_NO_PAD
1968                    .decode(public_2["x"].as_str().unwrap())
1969                    .unwrap();
1970                if x.first() == Some(&0) {
1971                    break (public_2, private_2, x);
1972                }
1973            };
1974            for variation in 0..4 {
1975                let mut imported_public = coordinate_public.clone();
1976                let mut imported_private = coordinate_private.clone();
1977                let x = match variation {
1978                    0 => x[1..].to_vec(),
1979                    1 => {
1980                        let mut value = vec![0];
1981                        value.extend(&x);
1982                        value
1983                    }
1984                    2 => {
1985                        let mut value = vec![0; 32];
1986                        value.extend(&x);
1987                        value
1988                    }
1989                    _ => {
1990                        let mut value = vec![1];
1991                        value.extend(&x);
1992                        value
1993                    }
1994                };
1995                imported_public["x"] = json!(URL_SAFE_NO_PAD.encode(x));
1996                imported_private["x"] = imported_public["x"].clone();
1997                if variation == 2 {
1998                    let mut y = vec![0; 32];
1999                    y.extend(
2000                        URL_SAFE_NO_PAD
2001                            .decode(coordinate_public["y"].as_str().unwrap())
2002                            .unwrap(),
2003                    );
2004                    imported_public["y"] = json!(URL_SAFE_NO_PAD.encode(y));
2005                    imported_private["y"] = imported_public["y"].clone();
2006                }
2007                imports.push((
2008                    imported_public,
2009                    imported_private,
2010                    variation != 3,
2011                    (variation == 3).then(|| coordinate_private.clone()),
2012                ));
2013            }
2014            let scalar = private["d"].as_str().unwrap();
2015            let mut invalid = vec![
2016                format!(
2017                    "+{}",
2018                    (scalar)
2019                        .get(1..)
2020                        .expect("fixture range is on a UTF-8 boundary")
2021                ),
2022                format!(
2023                    "/{}",
2024                    (scalar)
2025                        .get(1..)
2026                        .expect("fixture range is on a UTF-8 boundary")
2027                ),
2028                format!("={scalar}"),
2029                format!("{scalar}=A"),
2030                URL_SAFE_NO_PAD.encode(vec![
2031                    1;
2032                    if algorithm == JwtAlgorithm::Es256 {
2033                        31
2034                    } else {
2035                        65
2036                    }
2037                ]),
2038            ];
2039            for whitespace in [" ", "\t", "\n", "\r", "\u{000c}", "\u{000b}", "\u{00a0}"] {
2040                invalid.push(format!(
2041                    "{}{}{}",
2042                    (scalar)
2043                        .get(..3)
2044                        .expect("fixture range is on a UTF-8 boundary"),
2045                    whitespace,
2046                    (scalar)
2047                        .get(3..)
2048                        .expect("fixture range is on a UTF-8 boundary")
2049                ));
2050            }
2051            for scalar_2 in invalid {
2052                let mut imported_private = private.clone();
2053                imported_private["d"] = json!(scalar_2);
2054                imports.push((public.clone(), imported_private, false, None));
2055            }
2056            for (index, (public_3, private_3, accepted, verification_control)) in
2057                imports.into_iter().enumerate()
2058            {
2059                let key = ctx
2060                    .database
2061                    .create_jwk(CreateJwk {
2062                        id: Some(format!("imported-{}-{index}", algorithm.as_str())),
2063                        public_key: serde_json::to_string(&public_3).unwrap(),
2064                        private_key: serde_json::to_string(&private_3).unwrap(),
2065                        created_at: Utc::now(),
2066                        expires_at: None,
2067                        alg: Some(algorithm.as_str().to_owned()),
2068                        crv: algorithm.curve().map(str::to_owned),
2069                    })
2070                    .await
2071                    .unwrap();
2072                let result = plugin
2073                    .sign_jwt(
2074                        payload("imported-key-owner"),
2075                        &JwtSignOptions {
2076                            signing_key_id: Some(key.id.clone()),
2077                            ..Default::default()
2078                        },
2079                        None,
2080                        &ctx,
2081                    )
2082                    .await;
2083                if accepted {
2084                    let token =
2085                        result.expect("WebCrypto accepts padding and unused bits in JWK material");
2086                    assert!(
2087                        plugin
2088                            .verify_jwt(&token, None, None, &ctx)
2089                            .await
2090                            .unwrap()
2091                            .is_some()
2092                    );
2093                } else {
2094                    assert!(
2095                        result.is_err(),
2096                        "WebCrypto rejects this imported private scalar"
2097                    );
2098                }
2099                if let Some(private_key) = verification_control {
2100                    let token = JwtPlugin::sign_resolved(
2101                        plugin
2102                            .default_claims(payload("coordinate-owner"), None, &ctx)
2103                            .unwrap(),
2104                        &JwtSignOptions::default(),
2105                        &ResolvedJwtSigningKey {
2106                            algorithm,
2107                            key_id: key.id.clone(),
2108                            private_key,
2109                        },
2110                    )
2111                    .unwrap();
2112                    assert!(
2113                        plugin
2114                            .verify_jwt(&token, None, None, &ctx)
2115                            .await
2116                            .unwrap()
2117                            .is_none(),
2118                        "a nonzero overflow coordinate cannot verify a correctly signed token"
2119                    );
2120                }
2121                assert_eq!(
2122                    ctx.database
2123                        .get_jwk_by_id(&key.id)
2124                        .await
2125                        .unwrap()
2126                        .unwrap()
2127                        .private_key,
2128                    key.private_key
2129                );
2130                assert_eq!(ctx.database.list_jwks().await.unwrap().len(), index + 1);
2131            }
2132        }
2133    }
2134
2135    // Raw JSON input has JavaScript Number semantics before cryptographic emission.
2136    // Ordinary object keys and string-valued IDs must not enter number decoding.
2137    #[tokio::test]
2138    async fn raw_json_signing_normalizes_application_numbers_and_preserves_literal_keys() {
2139        let ctx = test_helpers::create_test_context().await;
2140        let plugin = JwtPlugin::new();
2141        let payload = alibi_core::utils::json::parse_value(
2142        r#"{"sub":"9007199254740993","exp":4102444800,"rounded":9007199254740993,"overflow":1e400,"nested":[-0.0,-1e400],"literal":{"$serde_json::private::Number":"1e400","$serde_json::private::RawValue":"hello"}}"#,
2143    ).unwrap();
2144        let token = plugin
2145            .sign_jwt_json(&payload, &JwtSignOptions::default(), None, &ctx)
2146            .await
2147            .unwrap();
2148        let (_, signed) = decoded(&token);
2149        assert_eq!(signed["rounded"], 9_007_199_254_740_992_u64);
2150        assert_eq!(signed["overflow"], Value::Null);
2151        assert_eq!(signed["nested"], json!([0, null]));
2152        assert_eq!(signed["sub"], "9007199254740993");
2153        assert_eq!(
2154            signed["literal"],
2155            json!({"$serde_json::private::Number":"1e400","$serde_json::private::RawValue":"hello"})
2156        );
2157        assert_eq!(
2158            plugin
2159                .verify_jwt(&token, None, None, &ctx)
2160                .await
2161                .unwrap()
2162                .unwrap(),
2163            signed.as_object().unwrap().clone()
2164        );
2165
2166        // A literal private serde marker as the first and only key must survive
2167        // managed verification even when SQLx enables serde_json raw_value.
2168        let literal = alibi_core::utils::json::parse_value(
2169        r#"{"sub":"literal-key-owner","exp":4102444800,"singleton":{"$serde_json::private::RawValue":"hello"}}"#,
2170    )
2171    .unwrap();
2172        let token_2 = plugin
2173            .sign_jwt_json(&literal, &JwtSignOptions::default(), None, &ctx)
2174            .await
2175            .unwrap();
2176        let verified = plugin
2177            .verify_jwt(&token_2, None, None, &ctx)
2178            .await
2179            .unwrap()
2180            .unwrap();
2181        assert_eq!(
2182            verified["singleton"]["$serde_json::private::RawValue"],
2183            "hello"
2184        );
2185
2186        // Native Map<Value> callers use the same JSON signing boundary, including headers.
2187        let options = JwtSignOptions {
2188            header: Some(
2189                json!({"proof":u64::MAX,"literal":"1e400"})
2190                    .as_object()
2191                    .unwrap()
2192                    .clone(),
2193            ),
2194            ..Default::default()
2195        };
2196        let native = plugin
2197        .sign_jwt(
2198            json!({"sub":"native-numbers","exp":4_102_444_800_u64,"rounded":9_007_199_254_740_993_u64})
2199                .as_object()
2200                .unwrap()
2201                .clone(),
2202            &options,
2203            None,
2204            &ctx,
2205        )
2206        .await
2207        .unwrap();
2208        let (header, signed_2) = decoded(&native);
2209        assert_eq!(signed_2["rounded"], 9_007_199_254_740_992_u64);
2210        assert_eq!(header["literal"], "1e400");
2211        let header_text = String::from_utf8(
2212            URL_SAFE_NO_PAD
2213                .decode(native.split('.').next().unwrap())
2214                .unwrap(),
2215        )
2216        .unwrap();
2217        assert!(header_text.contains("\"proof\":18446744073709552000"));
2218    }
2219
2220    // Signing must reject nonfinite registered claims before nullable JSON emission.
2221    // Lazy key creation precedes the rejection in pinned JOSE and remains observable.
2222    #[tokio::test]
2223    async fn raw_json_signing_rejects_nonfinite_dates_and_truthy_scalar_subjects() {
2224        let ctx = test_helpers::create_test_context().await;
2225        let plugin = JwtPlugin::new();
2226        assert!(ctx.database.list_jwks().await.unwrap().is_empty());
2227        for (field, literal) in [
2228            ("exp", "1e400"),
2229            ("exp", "-1e400"),
2230            ("iat", "1e400"),
2231            ("iat", "-1e400"),
2232            ("nbf", "1e400"),
2233            ("nbf", "-1e400"),
2234            ("sub", "1e400"),
2235            ("jti", "-1e400"),
2236            ("iss", "1e400"),
2237        ] {
2238            let input = format!("{{\"exp\":4102444800,\"{field}\":{literal}}}");
2239            let payload = alibi_core::utils::json::parse_value(&input).unwrap();
2240            assert!(
2241                plugin
2242                    .sign_jwt_json(&payload, &JwtSignOptions::default(), None, &ctx)
2243                    .await
2244                    .is_err(),
2245                "{field}: {literal}"
2246            );
2247            assert_eq!(ctx.database.list_jwks().await.unwrap().len(), 1);
2248        }
2249        let payload = alibi_core::utils::json::parse_value(
2250            r#"{"exp":4102444800,"sub":0,"jti":false,"iat":null,"nbf":false}"#,
2251        )
2252        .unwrap();
2253        let token = plugin
2254            .sign_jwt_json(&payload, &JwtSignOptions::default(), None, &ctx)
2255            .await
2256            .unwrap();
2257        let (_, signed) = decoded(&token);
2258        assert_eq!(signed["sub"], 0);
2259        assert_eq!(signed["jti"], false);
2260        assert_eq!(signed["iat"], Value::Null);
2261        assert_eq!(signed["nbf"], false);
2262    }
2263}
2264// LCOV_EXCL_STOP
2265
2266/// Installed JWT policy consumed by authorization-server discovery.
2267#[derive(Clone)]
2268pub(crate) struct JwtDiscoveryOptions {
2269    pub(crate) issuer: Option<String>,
2270    pub(crate) jwks_path: String,
2271    pub(crate) remote_url: Option<String>,
2272    pub(crate) signing_algorithms: Vec<String>,
2273}