use std::sync::Arc;
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation, decode, decode_header,
encode,
};
use parking_lot::RwLock;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::error::{Error, Result};
pub struct ActiveKey {
pub kid: String,
pub alg: Algorithm,
pub encoding_key: EncodingKey,
pub decoding_key: DecodingKey,
pub expires_at: Option<f64>,
}
pub struct HistoryKey {
pub kid: String,
pub alg: Algorithm,
pub decoding_key: DecodingKey,
}
struct Inner {
active: Option<ActiveKey>,
history: Vec<HistoryKey>,
issuer: String,
audience: Vec<String>,
}
#[derive(Clone)]
pub struct JwtConfig {
inner: Arc<RwLock<Inner>>,
}
impl JwtConfig {
pub fn new(issuer: String, audience: Vec<String>) -> Self {
Self {
inner: Arc::new(RwLock::new(Inner {
active: None,
history: Vec::new(),
issuer,
audience,
})),
}
}
pub fn set_active(&self, active: ActiveKey, history: Vec<HistoryKey>) {
let mut guard = self.inner.write();
guard.active = Some(active);
guard.history = history;
}
pub fn issue<T: Serialize>(&self, claims: &T) -> Result<String> {
let guard = self.inner.read();
let active = guard
.active
.as_ref()
.ok_or_else(|| Error::Jwt("no active jwt key configured".to_string()))?;
let mut header = Header::new(active.alg);
header.kid = Some(active.kid.clone());
encode(&header, claims, &active.encoding_key).map_err(map_jwt_err)
}
pub fn verify<T: DeserializeOwned>(&self, token: &str) -> Result<TokenData<T>> {
let header = decode_header(token).map_err(map_jwt_err)?;
let kid = header
.kid
.as_deref()
.ok_or_else(|| Error::Jwt("token has no kid header".to_string()))?;
let guard = self.inner.read();
let (alg, decoding_key) = lookup_decoding_key(&guard, kid)
.ok_or_else(|| Error::Jwt(format!("unknown kid {kid}")))?;
let mut validation = Validation::new(alg);
validation.set_issuer(std::slice::from_ref(&guard.issuer));
if !guard.audience.is_empty() {
validation.set_audience(&guard.audience);
}
decode::<T>(token, decoding_key, &validation).map_err(map_jwt_err)
}
pub fn active_kid(&self) -> Option<String> {
self.inner.read().active.as_ref().map(|k| k.kid.clone())
}
pub fn issuer(&self) -> String {
self.inner.read().issuer.clone()
}
pub fn audience(&self) -> Vec<String> {
self.inner.read().audience.clone()
}
#[cfg(feature = "backend-postgres")]
pub async fn load_from_postgres(&self, pool: &sqlx::PgPool) -> Result<()> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT kid, alg, private_pem_encrypted, rotated_at, expires_at
FROM auth.jwks_keys
ORDER BY created_at",
)
.fetch_all(pool)
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("load auth.jwks_keys (pg): {e}")))?;
let mut active = None;
let mut history = Vec::new();
for row in rows {
let kid: String = row.get("kid");
let alg_str: String = row.get("alg");
let pem: Option<Vec<u8>> = row.get("private_pem_encrypted");
let rotated_at: Option<f64> = row.get("rotated_at");
let expires_at: Option<f64> = row.get("expires_at");
let alg = parse_alg(&alg_str)?;
let pem = pem.ok_or_else(|| {
Error::Jwt(format!("auth.jwks_keys row {kid} has no private key"))
})?;
let (encoding_key, decoding_key) = build_keys(alg, &pem)?;
if rotated_at.is_none() && active.is_none() {
active = Some(ActiveKey {
kid: kid.clone(),
alg,
encoding_key,
decoding_key,
expires_at,
});
} else {
history.push(HistoryKey {
kid,
alg,
decoding_key,
});
}
}
let mut guard = self.inner.write();
guard.active = active;
guard.history = history;
Ok(())
}
#[cfg(feature = "backend-sqlite")]
pub async fn load_from_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<()> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT kid, alg, private_pem_encrypted, rotated_at, expires_at
FROM auth.jwks_keys
ORDER BY created_at",
)
.fetch_all(pool)
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("load auth.jwks_keys (sqlite): {e}")))?;
let mut active = None;
let mut history = Vec::new();
for row in rows {
let kid: String = row.get("kid");
let alg_str: String = row.get("alg");
let pem: Option<Vec<u8>> = row.get("private_pem_encrypted");
let rotated_at: Option<f64> = row.get("rotated_at");
let expires_at: Option<f64> = row.get("expires_at");
let alg = parse_alg(&alg_str)?;
let pem = pem.ok_or_else(|| {
Error::Jwt(format!("auth.jwks_keys row {kid} has no private key"))
})?;
let (encoding_key, decoding_key) = build_keys(alg, &pem)?;
if rotated_at.is_none() && active.is_none() {
active = Some(ActiveKey {
kid: kid.clone(),
alg,
encoding_key,
decoding_key,
expires_at,
});
} else {
history.push(HistoryKey {
kid,
alg,
decoding_key,
});
}
}
let mut guard = self.inner.write();
guard.active = active;
guard.history = history;
Ok(())
}
#[cfg(feature = "backend-postgres")]
pub async fn rotate_postgres(&self, pool: &sqlx::PgPool) -> Result<String> {
let GeneratedKey {
kid,
alg,
private_pem,
public_jwk,
} = generate_ed25519_key();
let (encoding_key, decoding_key) = build_keys(alg, private_pem.as_bytes())?;
let now = now_secs();
let mut tx = pool
.begin()
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("begin tx (pg rotate): {e}")))?;
sqlx::query("UPDATE auth.jwks_keys SET rotated_at = $1 WHERE rotated_at IS NULL")
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("mark old key rotated (pg): {e}")))?;
sqlx::query(
"INSERT INTO auth.jwks_keys
(kid, alg, public_jwk, private_pem_encrypted, created_at, rotated_at, expires_at)
VALUES ($1, $2, $3::jsonb, $4, $5, NULL, NULL)",
)
.bind(&kid)
.bind(alg_str(alg))
.bind(public_jwk.to_string())
.bind(private_pem.as_bytes())
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("insert new key (pg): {e}")))?;
tx.commit()
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("commit tx (pg rotate): {e}")))?;
let mut guard = self.inner.write();
if let Some(prev) = guard.active.take() {
guard.history.push(HistoryKey {
kid: prev.kid,
alg: prev.alg,
decoding_key: prev.decoding_key,
});
}
guard.active = Some(ActiveKey {
kid: kid.clone(),
alg,
encoding_key,
decoding_key,
expires_at: None,
});
Ok(kid)
}
#[cfg(feature = "backend-sqlite")]
pub async fn rotate_sqlite(&self, pool: &sqlx::SqlitePool) -> Result<String> {
let GeneratedKey {
kid,
alg,
private_pem,
public_jwk,
} = generate_ed25519_key();
let (encoding_key, decoding_key) = build_keys(alg, private_pem.as_bytes())?;
let now = now_secs();
let mut tx = pool
.begin()
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("begin tx (sqlite rotate): {e}")))?;
sqlx::query("UPDATE auth.jwks_keys SET rotated_at = ? WHERE rotated_at IS NULL")
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("mark old key rotated (sqlite): {e}")))?;
sqlx::query(
"INSERT INTO auth.jwks_keys
(kid, alg, public_jwk, private_pem_encrypted, created_at, rotated_at, expires_at)
VALUES (?, ?, ?, ?, ?, NULL, NULL)",
)
.bind(&kid)
.bind(alg_str(alg))
.bind(public_jwk.to_string())
.bind(private_pem.as_bytes())
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("insert new key (sqlite): {e}")))?;
tx.commit()
.await
.map_err(|e| Error::Backend(anyhow::anyhow!("commit tx (sqlite rotate): {e}")))?;
let mut guard = self.inner.write();
if let Some(prev) = guard.active.take() {
guard.history.push(HistoryKey {
kid: prev.kid,
alg: prev.alg,
decoding_key: prev.decoding_key,
});
}
guard.active = Some(ActiveKey {
kid: kid.clone(),
alg,
encoding_key,
decoding_key,
expires_at: None,
});
Ok(kid)
}
}
fn lookup_decoding_key<'a>(inner: &'a Inner, kid: &str) -> Option<(Algorithm, &'a DecodingKey)> {
if let Some(active) = &inner.active
&& active.kid == kid
{
return Some((active.alg, &active.decoding_key));
}
inner
.history
.iter()
.find(|h| h.kid == kid)
.map(|h| (h.alg, &h.decoding_key))
}
fn build_keys(alg: Algorithm, pem: &[u8]) -> Result<(EncodingKey, DecodingKey)> {
match alg {
Algorithm::EdDSA => {
let enc = EncodingKey::from_ed_pem(pem).map_err(map_jwt_err)?;
let public_pem = ed25519_public_pem_from_private(pem)?;
let dec = DecodingKey::from_ed_pem(public_pem.as_bytes()).map_err(map_jwt_err)?;
Ok((enc, dec))
}
other => Err(Error::Jwt(format!(
"unsupported jwt algorithm {other:?} (only EdDSA in phase 4)"
))),
}
}
fn ed25519_public_pem_from_private(private_pem: &[u8]) -> Result<String> {
use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::pkcs8::spki::EncodePublicKey;
let pem_str = std::str::from_utf8(private_pem)
.map_err(|e| Error::Jwt(format!("ed25519 private PEM utf8: {e}")))?;
let signing = SigningKey::from_pkcs8_pem(pem_str)
.map_err(|e| Error::Jwt(format!("parse ed25519 private PEM: {e}")))?;
let verifying = signing.verifying_key();
verifying
.to_public_key_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
.map_err(|e| Error::Jwt(format!("encode ed25519 public PEM: {e}")))
}
fn parse_alg(name: &str) -> Result<Algorithm> {
match name {
"EdDSA" => Ok(Algorithm::EdDSA),
other => Err(Error::Jwt(format!(
"unknown jwt algorithm {other:?} (only EdDSA in phase 4)"
))),
}
}
fn alg_str(alg: Algorithm) -> &'static str {
match alg {
Algorithm::EdDSA => "EdDSA",
_ => "EdDSA",
}
}
fn map_jwt_err(e: jsonwebtoken::errors::Error) -> Error {
Error::Jwt(e.to_string())
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
struct GeneratedKey {
kid: String,
alg: Algorithm,
private_pem: String,
public_jwk: serde_json::Value,
}
fn generate_ed25519_key() -> GeneratedKey {
use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::EncodePrivateKey;
let signing = SigningKey::generate(&mut rand_core_06::OsRng);
let private_pem = signing
.to_pkcs8_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
.expect("ed25519 PKCS#8 PEM encoding")
.to_string();
let verifying = signing.verifying_key();
let pub_bytes = verifying.to_bytes();
let kid = format!(
"kid_{}",
data_encoding::BASE64URL_NOPAD.encode(&pub_bytes[..16])
);
let public_jwk = serde_json::json!({
"kty": "OKP",
"crv": "Ed25519",
"alg": "EdDSA",
"kid": kid,
"use": "sig",
"x": data_encoding::BASE64URL_NOPAD.encode(&pub_bytes),
});
GeneratedKey {
kid,
alg: Algorithm::EdDSA,
private_pem,
public_jwk,
}
}
pub fn generate_ephemeral_ed25519(kid: impl Into<String>) -> Result<ActiveKey> {
let GeneratedKey { private_pem, .. } = generate_ed25519_key();
let (encoding_key, decoding_key) = build_keys(Algorithm::EdDSA, private_pem.as_bytes())?;
Ok(ActiveKey {
kid: kid.into(),
alg: Algorithm::EdDSA,
encoding_key,
decoding_key,
expires_at: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Claims {
sub: String,
iss: String,
aud: String,
exp: usize,
}
fn config_with_active(issuer: &str, audience: &[&str]) -> JwtConfig {
let cfg = JwtConfig::new(
issuer.to_string(),
audience.iter().map(|s| s.to_string()).collect(),
);
let active = generate_ephemeral_ed25519("kid_test").unwrap();
cfg.set_active(active, Vec::new());
cfg
}
fn future_exp() -> usize {
(now_secs() as usize) + 3600
}
fn past_exp() -> usize {
(now_secs() as usize).saturating_sub(3600)
}
#[test]
fn issue_and_verify_round_trip() {
let cfg = config_with_active("assay", &["assay-engine"]);
let claims = Claims {
sub: "user_alice".to_string(),
iss: "assay".to_string(),
aud: "assay-engine".to_string(),
exp: future_exp(),
};
let token = cfg.issue(&claims).unwrap();
let data = cfg.verify::<Claims>(&token).unwrap();
assert_eq!(data.claims, claims);
assert_eq!(data.header.kid.as_deref(), Some("kid_test"));
}
#[test]
fn wrong_audience_is_rejected() {
let cfg = config_with_active("assay", &["assay-engine"]);
let token = cfg
.issue(&Claims {
sub: "u".to_string(),
iss: "assay".to_string(),
aud: "someone-else".to_string(),
exp: future_exp(),
})
.unwrap();
let result = cfg.verify::<Claims>(&token);
assert!(matches!(result, Err(Error::Jwt(_))));
}
#[test]
fn expired_token_is_rejected() {
let cfg = config_with_active("assay", &["assay-engine"]);
let token = cfg
.issue(&Claims {
sub: "u".to_string(),
iss: "assay".to_string(),
aud: "assay-engine".to_string(),
exp: past_exp(),
})
.unwrap();
let result = cfg.verify::<Claims>(&token);
assert!(matches!(result, Err(Error::Jwt(_))));
}
#[test]
fn unknown_kid_is_rejected() {
let cfg_a = config_with_active("assay", &["assay-engine"]);
let token = cfg_a
.issue(&Claims {
sub: "u".to_string(),
iss: "assay".to_string(),
aud: "assay-engine".to_string(),
exp: future_exp(),
})
.unwrap();
let cfg_b = JwtConfig::new("assay".to_string(), vec!["assay-engine".to_string()]);
let other = generate_ephemeral_ed25519("kid_b").unwrap();
cfg_b.set_active(other, Vec::new());
let result = cfg_b.verify::<Claims>(&token);
assert!(matches!(result, Err(Error::Jwt(_))));
}
}