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