Skip to main content

alibi_plugins/jwt/
mod.rs

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