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