1mod 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#[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#[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 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 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 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 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 (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 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(¤t_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 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 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 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 #[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 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 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 #[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 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 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 #[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#[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}