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