Skip to main content

alibi_plugins/jwt/
mod.rs

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