1use std::sync::Arc;
23
24use jsonwebtoken::{
25 Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation, decode, decode_header,
26 encode,
27};
28use parking_lot::RwLock;
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31
32use crate::error::{Error, Result};
33
34pub struct ActiveKey {
39 pub kid: String,
40 pub alg: Algorithm,
41 pub encoding_key: EncodingKey,
42 pub decoding_key: DecodingKey,
43 pub expires_at: Option<f64>,
44}
45
46pub struct HistoryKey {
49 pub kid: String,
50 pub alg: Algorithm,
51 pub decoding_key: DecodingKey,
52}
53
54struct Inner {
55 active: Option<ActiveKey>,
56 history: Vec<HistoryKey>,
57 issuer: String,
58 audience: Vec<String>,
59}
60
61#[derive(Clone)]
64pub struct JwtConfig {
65 inner: Arc<RwLock<Inner>>,
66}
67
68impl JwtConfig {
69 pub fn new(issuer: String, audience: Vec<String>) -> Self {
73 Self {
74 inner: Arc::new(RwLock::new(Inner {
75 active: None,
76 history: Vec::new(),
77 issuer,
78 audience,
79 })),
80 }
81 }
82
83 pub fn set_active(&self, active: ActiveKey, history: Vec<HistoryKey>) {
86 let mut guard = self.inner.write();
87 guard.active = Some(active);
88 guard.history = history;
89 }
90
91 pub fn issue<T: Serialize>(&self, claims: &T) -> Result<String> {
94 let guard = self.inner.read();
95 let active = guard
96 .active
97 .as_ref()
98 .ok_or_else(|| Error::Jwt("no active jwt key configured".to_string()))?;
99 let mut header = Header::new(active.alg);
100 header.kid = Some(active.kid.clone());
101 encode(&header, claims, &active.encoding_key).map_err(map_jwt_err)
102 }
103
104 pub fn verify<T: DeserializeOwned>(&self, token: &str) -> Result<TokenData<T>> {
108 self.verify_with_policy(token, true)
109 }
110
111 pub(crate) fn verify_provider_token<T: DeserializeOwned>(
115 &self,
116 token: &str,
117 ) -> Result<TokenData<T>> {
118 self.verify_with_policy(token, false)
119 }
120
121 fn verify_with_policy<T: DeserializeOwned>(
122 &self,
123 token: &str,
124 validate_configured_audience: bool,
125 ) -> Result<TokenData<T>> {
126 let header = decode_header(token).map_err(map_jwt_err)?;
127 let kid = header
128 .kid
129 .as_deref()
130 .ok_or_else(|| Error::Jwt("token has no kid header".to_string()))?;
131 let guard = self.inner.read();
132 let (alg, decoding_key) = lookup_decoding_key(&guard, kid)
133 .ok_or_else(|| Error::Jwt(format!("unknown kid {kid}")))?;
134 let mut validation = Validation::new(alg);
135 validation.set_issuer(std::slice::from_ref(&guard.issuer));
136 if validate_configured_audience && !guard.audience.is_empty() {
137 validation.set_audience(&guard.audience);
138 } else if !validate_configured_audience {
139 validation.validate_aud = false;
140 }
141 decode::<T>(token, decoding_key, &validation).map_err(map_jwt_err)
142 }
143
144 pub fn active_kid(&self) -> Option<String> {
147 self.inner.read().active.as_ref().map(|k| k.kid.clone())
148 }
149
150 pub fn issuer(&self) -> String {
155 self.inner.read().issuer.clone()
156 }
157
158 pub fn audience(&self) -> Vec<String> {
160 self.inner.read().audience.clone()
161 }
162
163 #[cfg(feature = "backend-postgres")]
168 pub async fn load_from_postgres(&self, pool: &sqlx::PgPool) -> Result<()> {
169 use sqlx::Row;
170 let rows = sqlx::query(
171 "SELECT kid, alg, private_pem_encrypted, rotated_at, expires_at
172 FROM auth.jwks_keys
173 ORDER BY created_at",
174 )
175 .fetch_all(pool)
176 .await
177 .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.jwks_keys (pg): {e}")))?;
178
179 let mut active = None;
180 let mut history = Vec::new();
181 for row in rows {
182 let kid: String = row.get("kid");
183 let alg_str: String = row.get("alg");
184 let pem: Option<Vec<u8>> = row.get("private_pem_encrypted");
185 let rotated_at: Option<f64> = row.get("rotated_at");
186 let expires_at: Option<f64> = row.get("expires_at");
187 let alg = parse_alg(&alg_str)?;
188 let pem = pem.ok_or_else(|| {
189 Error::Jwt(format!("auth.jwks_keys row {kid} has no private key"))
190 })?;
191 let (encoding_key, decoding_key) = build_keys(alg, &pem)?;
192 if rotated_at.is_none() && active.is_none() {
193 active = Some(ActiveKey {
194 kid: kid.clone(),
195 alg,
196 encoding_key,
197 decoding_key,
198 expires_at,
199 });
200 } else {
201 history.push(HistoryKey {
202 kid,
203 alg,
204 decoding_key,
205 });
206 }
207 }
208 let mut guard = self.inner.write();
209 guard.active = active;
210 guard.history = history;
211 Ok(())
212 }
213
214 #[cfg(feature = "backend-sqlite")]
216 pub async fn load_from_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<()> {
217 use sqlx::Row;
218 let rows = sqlx::query(
219 "SELECT kid, alg, private_pem_encrypted, rotated_at, expires_at
220 FROM auth.jwks_keys
221 ORDER BY created_at",
222 )
223 .fetch_all(pool)
224 .await
225 .map_err(|e| Error::Backend(anyhow::anyhow!("load auth.jwks_keys (sqlite): {e}")))?;
226
227 let mut active = None;
228 let mut history = Vec::new();
229 for row in rows {
230 let kid: String = row.get("kid");
231 let alg_str: String = row.get("alg");
232 let pem: Option<Vec<u8>> = row.get("private_pem_encrypted");
233 let rotated_at: Option<f64> = row.get("rotated_at");
234 let expires_at: Option<f64> = row.get("expires_at");
235 let alg = parse_alg(&alg_str)?;
236 let pem = pem.ok_or_else(|| {
237 Error::Jwt(format!("auth.jwks_keys row {kid} has no private key"))
238 })?;
239 let (encoding_key, decoding_key) = build_keys(alg, &pem)?;
240 if rotated_at.is_none() && active.is_none() {
241 active = Some(ActiveKey {
242 kid: kid.clone(),
243 alg,
244 encoding_key,
245 decoding_key,
246 expires_at,
247 });
248 } else {
249 history.push(HistoryKey {
250 kid,
251 alg,
252 decoding_key,
253 });
254 }
255 }
256 let mut guard = self.inner.write();
257 guard.active = active;
258 guard.history = history;
259 Ok(())
260 }
261
262 #[cfg(feature = "backend-postgres")]
266 pub async fn rotate_postgres(&self, pool: &sqlx::PgPool) -> Result<String> {
267 let GeneratedKey {
268 kid,
269 alg,
270 private_pem,
271 public_jwk,
272 } = generate_ed25519_key();
273 let (encoding_key, decoding_key) = build_keys(alg, private_pem.as_bytes())?;
274 let now = now_secs();
275 let mut tx = pool
276 .begin()
277 .await
278 .map_err(|e| Error::Backend(anyhow::anyhow!("begin tx (pg rotate): {e}")))?;
279 sqlx::query("UPDATE auth.jwks_keys SET rotated_at = $1 WHERE rotated_at IS NULL")
280 .bind(now)
281 .execute(&mut *tx)
282 .await
283 .map_err(|e| Error::Backend(anyhow::anyhow!("mark old key rotated (pg): {e}")))?;
284 sqlx::query(
285 "INSERT INTO auth.jwks_keys
286 (kid, alg, public_jwk, private_pem_encrypted, created_at, rotated_at, expires_at)
287 VALUES ($1, $2, $3::jsonb, $4, $5, NULL, NULL)",
288 )
289 .bind(&kid)
290 .bind(alg_str(alg))
291 .bind(public_jwk.to_string())
292 .bind(private_pem.as_bytes())
293 .bind(now)
294 .execute(&mut *tx)
295 .await
296 .map_err(|e| Error::Backend(anyhow::anyhow!("insert new key (pg): {e}")))?;
297 tx.commit()
298 .await
299 .map_err(|e| Error::Backend(anyhow::anyhow!("commit tx (pg rotate): {e}")))?;
300 let mut guard = self.inner.write();
302 if let Some(prev) = guard.active.take() {
303 guard.history.push(HistoryKey {
304 kid: prev.kid,
305 alg: prev.alg,
306 decoding_key: prev.decoding_key,
307 });
308 }
309 guard.active = Some(ActiveKey {
310 kid: kid.clone(),
311 alg,
312 encoding_key,
313 decoding_key,
314 expires_at: None,
315 });
316 Ok(kid)
317 }
318
319 #[cfg(feature = "backend-sqlite")]
321 pub async fn rotate_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<String> {
322 let GeneratedKey {
323 kid,
324 alg,
325 private_pem,
326 public_jwk,
327 } = generate_ed25519_key();
328 let (encoding_key, decoding_key) = build_keys(alg, private_pem.as_bytes())?;
329 let now = now_secs();
330 let mut tx = pool
331 .begin()
332 .await
333 .map_err(|e| Error::Backend(anyhow::anyhow!("begin tx (sqlite rotate): {e}")))?;
334 sqlx::query("UPDATE auth.jwks_keys SET rotated_at = ? WHERE rotated_at IS NULL")
335 .bind(now)
336 .execute(&mut *tx)
337 .await
338 .map_err(|e| Error::Backend(anyhow::anyhow!("mark old key rotated (sqlite): {e}")))?;
339 sqlx::query(
340 "INSERT INTO auth.jwks_keys
341 (kid, alg, public_jwk, private_pem_encrypted, created_at, rotated_at, expires_at)
342 VALUES (?, ?, ?, ?, ?, NULL, NULL)",
343 )
344 .bind(&kid)
345 .bind(alg_str(alg))
346 .bind(public_jwk.to_string())
347 .bind(private_pem.as_bytes())
348 .bind(now)
349 .execute(&mut *tx)
350 .await
351 .map_err(|e| Error::Backend(anyhow::anyhow!("insert new key (sqlite): {e}")))?;
352 tx.commit()
353 .await
354 .map_err(|e| Error::Backend(anyhow::anyhow!("commit tx (sqlite rotate): {e}")))?;
355 let mut guard = self.inner.write();
356 if let Some(prev) = guard.active.take() {
357 guard.history.push(HistoryKey {
358 kid: prev.kid,
359 alg: prev.alg,
360 decoding_key: prev.decoding_key,
361 });
362 }
363 guard.active = Some(ActiveKey {
364 kid: kid.clone(),
365 alg,
366 encoding_key,
367 decoding_key,
368 expires_at: None,
369 });
370 Ok(kid)
371 }
372}
373
374fn lookup_decoding_key<'a>(inner: &'a Inner, kid: &str) -> Option<(Algorithm, &'a DecodingKey)> {
375 if let Some(active) = &inner.active
376 && active.kid == kid
377 {
378 return Some((active.alg, &active.decoding_key));
379 }
380 inner
381 .history
382 .iter()
383 .find(|h| h.kid == kid)
384 .map(|h| (h.alg, &h.decoding_key))
385}
386
387fn build_keys(alg: Algorithm, pem: &[u8]) -> Result<(EncodingKey, DecodingKey)> {
391 match alg {
392 Algorithm::EdDSA => {
393 let enc = EncodingKey::from_ed_pem(pem).map_err(map_jwt_err)?;
394 let public_pem = ed25519_public_pem_from_private(pem)?;
395 let dec = DecodingKey::from_ed_pem(public_pem.as_bytes()).map_err(map_jwt_err)?;
396 Ok((enc, dec))
397 }
398 other => Err(Error::Jwt(format!(
401 "unsupported jwt algorithm {other:?} (only EdDSA in phase 4)"
402 ))),
403 }
404}
405
406fn ed25519_public_pem_from_private(private_pem: &[u8]) -> Result<String> {
410 use ed25519_dalek::SigningKey;
411 use ed25519_dalek::pkcs8::DecodePrivateKey;
412 use ed25519_dalek::pkcs8::spki::EncodePublicKey;
413
414 let pem_str = std::str::from_utf8(private_pem)
415 .map_err(|e| Error::Jwt(format!("ed25519 private PEM utf8: {e}")))?;
416 let signing = SigningKey::from_pkcs8_pem(pem_str)
417 .map_err(|e| Error::Jwt(format!("parse ed25519 private PEM: {e}")))?;
418 let verifying = signing.verifying_key();
419 verifying
420 .to_public_key_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
421 .map_err(|e| Error::Jwt(format!("encode ed25519 public PEM: {e}")))
422}
423
424fn parse_alg(name: &str) -> Result<Algorithm> {
425 match name {
426 "EdDSA" => Ok(Algorithm::EdDSA),
427 other => Err(Error::Jwt(format!(
428 "unknown jwt algorithm {other:?} (only EdDSA in phase 4)"
429 ))),
430 }
431}
432
433fn alg_str(alg: Algorithm) -> &'static str {
434 match alg {
435 Algorithm::EdDSA => "EdDSA",
436 _ => "EdDSA",
440 }
441}
442
443fn map_jwt_err(e: jsonwebtoken::errors::Error) -> Error {
444 Error::Jwt(e.to_string())
445}
446
447fn now_secs() -> f64 {
448 std::time::SystemTime::now()
449 .duration_since(std::time::UNIX_EPOCH)
450 .unwrap_or_default()
451 .as_secs_f64()
452}
453
454struct GeneratedKey {
458 kid: String,
459 alg: Algorithm,
460 private_pem: String,
461 public_jwk: serde_json::Value,
462}
463
464fn generate_ed25519_key() -> GeneratedKey {
465 use ed25519_dalek::SigningKey;
466 use ed25519_dalek::pkcs8::EncodePrivateKey;
467
468 let signing = SigningKey::generate(&mut rand_core_06::OsRng);
469 let private_pem = signing
470 .to_pkcs8_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
471 .expect("ed25519 PKCS#8 PEM encoding")
472 .to_string();
473 let verifying = signing.verifying_key();
474 let pub_bytes = verifying.to_bytes();
475 let kid = format!(
476 "kid_{}",
477 data_encoding::BASE64URL_NOPAD.encode(&pub_bytes[..16])
478 );
479 let public_jwk = serde_json::json!({
480 "kty": "OKP",
481 "crv": "Ed25519",
482 "alg": "EdDSA",
483 "kid": kid,
484 "use": "sig",
485 "x": data_encoding::BASE64URL_NOPAD.encode(&pub_bytes),
486 });
487 GeneratedKey {
488 kid,
489 alg: Algorithm::EdDSA,
490 private_pem,
491 public_jwk,
492 }
493}
494
495pub fn generate_ephemeral_ed25519(kid: impl Into<String>) -> Result<ActiveKey> {
499 let GeneratedKey { private_pem, .. } = generate_ed25519_key();
500 let (encoding_key, decoding_key) = build_keys(Algorithm::EdDSA, private_pem.as_bytes())?;
501 Ok(ActiveKey {
502 kid: kid.into(),
503 alg: Algorithm::EdDSA,
504 encoding_key,
505 decoding_key,
506 expires_at: None,
507 })
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use serde::{Deserialize, Serialize};
514
515 #[derive(Debug, Serialize, Deserialize, PartialEq)]
516 struct Claims {
517 sub: String,
518 iss: String,
519 aud: String,
520 exp: usize,
521 }
522
523 fn config_with_active(issuer: &str, audience: &[&str]) -> JwtConfig {
524 let cfg = JwtConfig::new(
525 issuer.to_string(),
526 audience.iter().map(|s| s.to_string()).collect(),
527 );
528 let active = generate_ephemeral_ed25519("kid_test").unwrap();
529 cfg.set_active(active, Vec::new());
530 cfg
531 }
532
533 fn future_exp() -> usize {
534 (now_secs() as usize) + 3600
535 }
536
537 fn past_exp() -> usize {
538 (now_secs() as usize).saturating_sub(3600)
539 }
540
541 #[test]
542 fn issue_and_verify_round_trip() {
543 let cfg = config_with_active("assay", &["assay-engine"]);
544 let claims = Claims {
545 sub: "user_alice".to_string(),
546 iss: "assay".to_string(),
547 aud: "assay-engine".to_string(),
548 exp: future_exp(),
549 };
550 let token = cfg.issue(&claims).unwrap();
551 let data = cfg.verify::<Claims>(&token).unwrap();
552 assert_eq!(data.claims, claims);
553 assert_eq!(data.header.kid.as_deref(), Some("kid_test"));
554 }
555
556 #[test]
557 fn wrong_audience_is_rejected() {
558 let cfg = config_with_active("assay", &["assay-engine"]);
559 let token = cfg
560 .issue(&Claims {
561 sub: "u".to_string(),
562 iss: "assay".to_string(),
563 aud: "someone-else".to_string(),
564 exp: future_exp(),
565 })
566 .unwrap();
567 let result = cfg.verify::<Claims>(&token);
568 assert!(matches!(result, Err(Error::Jwt(_))));
569 }
570
571 #[test]
572 fn provider_token_verification_accepts_a_signed_dynamic_client_audience() {
573 let cfg = config_with_active(
574 "https://auth.assay.rs/auth",
575 &["https://auth.assay.rs/auth"],
576 );
577 let claims = Claims {
578 sub: "user_alice".to_string(),
579 iss: "https://auth.assay.rs/auth".to_string(),
580 aud: "agentkit-pages".to_string(),
581 exp: future_exp(),
582 };
583 let token = cfg.issue(&claims).unwrap();
584
585 let data = cfg.verify_provider_token::<Claims>(&token).unwrap();
586
587 assert_eq!(data.claims, claims);
588 }
589
590 #[test]
591 fn provider_token_verification_still_rejects_the_wrong_issuer() {
592 let cfg = config_with_active(
593 "https://auth.assay.rs/auth",
594 &["https://auth.assay.rs/auth"],
595 );
596 let token = cfg
597 .issue(&Claims {
598 sub: "user_alice".to_string(),
599 iss: "https://attacker.example/auth".to_string(),
600 aud: "agentkit-pages".to_string(),
601 exp: future_exp(),
602 })
603 .unwrap();
604
605 let result = cfg.verify_provider_token::<Claims>(&token);
606
607 assert!(matches!(result, Err(Error::Jwt(_))));
608 }
609
610 #[test]
611 fn expired_token_is_rejected() {
612 let cfg = config_with_active("assay", &["assay-engine"]);
613 let token = cfg
614 .issue(&Claims {
615 sub: "u".to_string(),
616 iss: "assay".to_string(),
617 aud: "assay-engine".to_string(),
618 exp: past_exp(),
619 })
620 .unwrap();
621 let result = cfg.verify::<Claims>(&token);
622 assert!(matches!(result, Err(Error::Jwt(_))));
623 }
624
625 #[test]
626 fn unknown_kid_is_rejected() {
627 let cfg_a = config_with_active("assay", &["assay-engine"]);
628 let token = cfg_a
629 .issue(&Claims {
630 sub: "u".to_string(),
631 iss: "assay".to_string(),
632 aud: "assay-engine".to_string(),
633 exp: future_exp(),
634 })
635 .unwrap();
636 let cfg_b = JwtConfig::new("assay".to_string(), vec!["assay-engine".to_string()]);
639 let other = generate_ephemeral_ed25519("kid_b").unwrap();
640 cfg_b.set_active(other, Vec::new());
641 let result = cfg_b.verify::<Claims>(&token);
642 assert!(matches!(result, Err(Error::Jwt(_))));
643 }
644}