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