use std::{fmt, time::Duration};
use rskit_util::SecretString;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum JwtAlgorithm {
Hs256Internal,
Rs256,
Es256,
EdDsa,
}
impl JwtAlgorithm {
#[must_use]
pub const fn as_jsonwebtoken(self) -> jsonwebtoken::Algorithm {
match self {
Self::Hs256Internal => jsonwebtoken::Algorithm::HS256,
Self::Rs256 => jsonwebtoken::Algorithm::RS256,
Self::Es256 => jsonwebtoken::Algorithm::ES256,
Self::EdDsa => jsonwebtoken::Algorithm::EdDSA,
}
}
#[must_use]
pub const fn is_symmetric(self) -> bool {
matches!(self, Self::Hs256Internal)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub enum AsymmetricAlgorithm {
#[serde(rename = "RS256")]
Rs256,
#[serde(rename = "ES256")]
Es256,
#[serde(rename = "EDDSA")]
EdDsa,
}
impl AsymmetricAlgorithm {
#[must_use]
pub const fn as_jwt_algorithm(self) -> JwtAlgorithm {
match self {
Self::Rs256 => JwtAlgorithm::Rs256,
Self::Es256 => JwtAlgorithm::Es256,
Self::EdDsa => JwtAlgorithm::EdDsa,
}
}
}
impl fmt::Debug for AsymmetricAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Rs256 => f.write_str("RS256"),
Self::Es256 => f.write_str("ES256"),
Self::EdDsa => f.write_str("EdDSA"),
}
}
}
impl zeroize::Zeroize for AsymmetricAlgorithm {
fn zeroize(&mut self) {}
}
#[derive(Clone, Deserialize, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
pub struct KeyPair {
pub private_key_pem: SecretString,
pub public_key_pem: SecretString,
}
impl fmt::Debug for KeyPair {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KeyPair")
.field("private_key_pem", &self.private_key_pem)
.field("public_key_pem", &self.public_key_pem)
.finish()
}
}
#[derive(Clone, Deserialize, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
#[non_exhaustive]
pub enum JwtKeyMaterial {
Hs256Internal {
secret: SecretString,
},
Asymmetric {
algorithm: AsymmetricAlgorithm,
#[serde(flatten)]
keys: KeyPair,
},
}
impl JwtKeyMaterial {
#[must_use]
pub const fn algorithm(&self) -> JwtAlgorithm {
match self {
Self::Hs256Internal { .. } => JwtAlgorithm::Hs256Internal,
Self::Asymmetric { algorithm, .. } => algorithm.as_jwt_algorithm(),
}
}
}
impl fmt::Debug for JwtKeyMaterial {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Hs256Internal { secret } => f
.debug_struct("Hs256Internal")
.field("secret", secret)
.finish(),
Self::Asymmetric { algorithm, keys } => f
.debug_struct("Asymmetric")
.field("algorithm", algorithm)
.field("keys", keys)
.finish(),
}
}
}
#[derive(Clone, Deserialize)]
pub struct JwtConfig {
pub key_material: JwtKeyMaterial,
pub issuer: String,
pub audience: Vec<String>,
#[serde(default = "JwtConfig::default_ttl")]
pub ttl: Duration,
#[serde(default = "JwtConfig::default_leeway")]
pub leeway: Duration,
}
impl JwtConfig {
const fn default_ttl() -> Duration {
Duration::from_hours(1)
}
const fn default_leeway() -> Duration {
Duration::from_secs(30)
}
#[must_use]
pub fn asymmetric(
algorithm: AsymmetricAlgorithm,
private_key_pem: impl Into<String>,
public_key_pem: impl Into<String>,
issuer: impl Into<String>,
audience: Vec<String>,
) -> Self {
Self {
key_material: JwtKeyMaterial::Asymmetric {
algorithm,
keys: KeyPair {
private_key_pem: SecretString::new(private_key_pem),
public_key_pem: SecretString::new(public_key_pem),
},
},
issuer: issuer.into(),
audience,
ttl: Self::default_ttl(),
leeway: Self::default_leeway(),
}
}
#[must_use]
pub fn hs256_internal(
secret: impl Into<String>,
issuer: impl Into<String>,
audience: Vec<String>,
) -> Self {
Self {
key_material: JwtKeyMaterial::Hs256Internal {
secret: SecretString::new(secret),
},
issuer: issuer.into(),
audience,
ttl: Self::default_ttl(),
leeway: Self::default_leeway(),
}
}
#[must_use]
pub fn rs256(
private_key_pem: impl Into<String>,
public_key_pem: impl Into<String>,
issuer: impl Into<String>,
audience: Vec<String>,
) -> Self {
Self::asymmetric(
AsymmetricAlgorithm::Rs256,
private_key_pem,
public_key_pem,
issuer,
audience,
)
}
#[must_use]
pub fn es256(
private_key_pem: impl Into<String>,
public_key_pem: impl Into<String>,
issuer: impl Into<String>,
audience: Vec<String>,
) -> Self {
Self::asymmetric(
AsymmetricAlgorithm::Es256,
private_key_pem,
public_key_pem,
issuer,
audience,
)
}
#[must_use]
pub fn eddsa(
private_key_pem: impl Into<String>,
public_key_pem: impl Into<String>,
issuer: impl Into<String>,
audience: Vec<String>,
) -> Self {
Self::asymmetric(
AsymmetricAlgorithm::EdDsa,
private_key_pem,
public_key_pem,
issuer,
audience,
)
}
#[must_use]
pub const fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
#[must_use]
pub const fn with_leeway(mut self, leeway: Duration) -> Self {
self.leeway = leeway;
self
}
#[must_use]
pub const fn algorithm(&self) -> JwtAlgorithm {
self.key_material.algorithm()
}
}
impl fmt::Debug for JwtConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("JwtConfig")
.field("key_material", &self.key_material)
.field("issuer", &self.issuer)
.field("audience", &self.audience)
.field("ttl", &self.ttl)
.field("leeway", &self.leeway)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jwt_key_material_debug_redacts_secret_values() {
let symmetric = format!(
"{:?}",
JwtKeyMaterial::Hs256Internal {
secret: SecretString::new("super-secret-value"),
}
);
assert!(symmetric.contains("***"));
assert!(!symmetric.contains("super-secret-value"));
let asymmetric = format!(
"{:?}",
JwtKeyMaterial::Asymmetric {
algorithm: AsymmetricAlgorithm::Rs256,
keys: KeyPair {
private_key_pem: SecretString::new("private-pem"),
public_key_pem: SecretString::new("public-pem"),
},
}
);
assert!(asymmetric.contains("***"));
assert!(asymmetric.contains("RS256"));
assert!(!asymmetric.contains("private-pem"));
assert!(!asymmetric.contains("public-pem"));
}
#[test]
fn jwt_algorithm_policy_identifies_symmetric_internal_mode() {
assert!(JwtAlgorithm::Hs256Internal.is_symmetric());
assert!(!JwtAlgorithm::Rs256.is_symmetric());
assert!(!JwtAlgorithm::Es256.is_symmetric());
assert!(!JwtAlgorithm::EdDsa.is_symmetric());
}
#[test]
fn jwt_config_builders_preserve_ttl_and_leeway_overrides() {
let config = JwtConfig::hs256_internal(
"secret-material-that-is-long-enough",
"https://issuer.example",
vec!["audience".into()],
)
.with_ttl(Duration::from_mins(5))
.with_leeway(Duration::from_secs(10));
assert_eq!(config.ttl, Duration::from_mins(5));
assert_eq!(config.leeway, Duration::from_secs(10));
}
#[test]
fn jwt_config_debug_redacts_nested_key_material() {
let config = JwtConfig::hs256_internal(
"another-secret-value",
"issuer.example",
vec!["audience".into()],
);
let formatted = format!("{config:?}");
assert!(formatted.contains("***"));
assert!(!formatted.contains("another-secret-value"));
assert!(formatted.contains("issuer.example"));
}
#[test]
fn convenience_constructors_delegate_to_asymmetric() {
let rs = JwtConfig::rs256("priv", "pub", "iss", vec!["aud".into()]);
assert_eq!(rs.algorithm(), JwtAlgorithm::Rs256);
let es = JwtConfig::es256("priv", "pub", "iss", vec!["aud".into()]);
assert_eq!(es.algorithm(), JwtAlgorithm::Es256);
let ed = JwtConfig::eddsa("priv", "pub", "iss", vec!["aud".into()]);
assert_eq!(ed.algorithm(), JwtAlgorithm::EdDsa);
}
#[test]
fn asymmetric_algorithm_roundtrip_serde() {
let json = r#""RS256""#;
let alg: AsymmetricAlgorithm = serde_json::from_str(json).unwrap();
assert_eq!(alg, AsymmetricAlgorithm::Rs256);
let json = r#""EDDSA""#;
let alg: AsymmetricAlgorithm = serde_json::from_str(json).unwrap();
assert_eq!(alg, AsymmetricAlgorithm::EdDsa);
}
#[test]
fn key_material_serde_symmetric() {
let json = r#"{"Hs256Internal": {"secret": "my-secret"}}"#;
let mat: JwtKeyMaterial = serde_json::from_str(json).unwrap();
assert_eq!(mat.algorithm(), JwtAlgorithm::Hs256Internal);
}
#[test]
fn key_material_serde_asymmetric() {
let json = r#"{"Asymmetric": {"algorithm": "RS256", "private_key_pem": "priv", "public_key_pem": "pub"}}"#;
let mat: JwtKeyMaterial = serde_json::from_str(json).unwrap();
assert_eq!(mat.algorithm(), JwtAlgorithm::Rs256);
}
}