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