Skip to main content

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