use std::path::Path;
use anyhow::Context;
use gradatum_core::secrets::{FileSecretsProvider, SecretsError, SecretsProvider};
use gradatum_auth::jwt::JwtService;
use secrecy::ExposeSecret;
pub(crate) const JWT_SIGNING_KEY_NAME: &str = "jwt-signing-key";
pub(crate) fn load_or_generate_jwt_key(
key_dir: &Path,
kid: String,
audience: String,
ttl_human_secs: u64,
ttl_service_secs: u64,
) -> anyhow::Result<JwtService> {
let provider = FileSecretsProvider::new(key_dir);
let signing_bytes = match provider.get(JWT_SIGNING_KEY_NAME) {
Ok(secret) => {
tracing::info!(
path = %key_dir.join(format!("{JWT_SIGNING_KEY_NAME}.secret")).display(),
"clé JWT chargée depuis le fichier persistant"
);
secret
}
Err(SecretsError::NotFound { .. }) => {
let (seed, _signing_key) = JwtService::generate_signing_bytes();
provider
.write_atomic(JWT_SIGNING_KEY_NAME, seed.as_ref())
.with_context(|| {
format!(
"écriture de la clé JWT dans {}",
key_dir
.join(format!("{JWT_SIGNING_KEY_NAME}.secret"))
.display()
)
})?;
tracing::warn!(
path = %key_dir.join(format!("{JWT_SIGNING_KEY_NAME}.secret")).display(),
"clé JWT générée et persistée — \
TOUS LES TOKENS EXISTANTS SONT INVALIDÉS \
(premier démarrage ou rotation manuelle du fichier .secret)"
);
provider
.get(JWT_SIGNING_KEY_NAME)
.context("relecture de la clé JWT après génération")?
}
Err(e) => {
return Err(e).context("lecture de la clé de signature JWT");
}
};
JwtService::from_signing_bytes(
signing_bytes.expose_secret(),
kid,
audience,
ttl_human_secs,
ttl_service_secs,
)
.map_err(|e| anyhow::anyhow!("construction JwtService depuis seed : {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use gradatum_auth::jwt::TokenScope;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
fn params() -> (String, String, u64, u64) {
(
"test-kid".to_string(),
"gradatum-test".to_string(),
3600,
86400,
)
}
#[test]
fn premier_boot_genere_le_fichier_chmod_600() {
let dir = TempDir::new().expect("tempdir");
let (kid, aud, ttl_h, ttl_s) = params();
let jwt = load_or_generate_jwt_key(dir.path(), kid, aud, ttl_h, ttl_s)
.expect("load_or_generate doit réussir");
let key_path = dir.path().join(format!("{JWT_SIGNING_KEY_NAME}.secret"));
assert!(
key_path.exists(),
"fichier clé doit être créé au premier boot"
);
let meta = std::fs::metadata(&key_path).expect("metadata");
let mode = meta.permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"permissions doivent être 0o600, trouvé: 0o{mode:04o}"
);
let token = jwt
.sign("sub-test", &["read".to_string()], TokenScope::Human, "main")
.expect("signe doit réussir");
assert!(!token.is_empty());
}
#[test]
fn second_boot_charge_la_meme_cle_jwt_valide_apres_restart() {
let dir = TempDir::new().expect("tempdir");
let (kid, aud, ttl_h, ttl_s) = params();
let jwt_boot_1 =
load_or_generate_jwt_key(dir.path(), kid.clone(), aud.clone(), ttl_h, ttl_s)
.expect("premier boot doit réussir");
let token = jwt_boot_1
.sign(
"user-persistance",
&["read".to_string()],
TokenScope::Human,
"main",
)
.expect("signature boot 1");
let jwt_boot_2 =
load_or_generate_jwt_key(dir.path(), kid.clone(), aud.clone(), ttl_h, ttl_s)
.expect("second boot doit réussir");
let claims = jwt_boot_2
.verify(&token)
.expect("le JWT doit survivre au 'restart' (bug P0 corrigé)");
assert_eq!(claims.sub, "user-persistance");
assert_eq!(claims.tenant_id, "main");
}
#[test]
fn permissions_trop_ouvertes_retourne_erreur() {
let dir = TempDir::new().expect("tempdir");
let key_path = dir.path().join(format!("{JWT_SIGNING_KEY_NAME}.secret"));
std::fs::write(&key_path, vec![0u8; 32]).expect("écriture");
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644))
.expect("chmod 644");
let (kid, aud, ttl_h, ttl_s) = params();
let err = match load_or_generate_jwt_key(dir.path(), kid, aud, ttl_h, ttl_s) {
Err(e) => e,
Ok(_) => panic!("attendu Err sur permissions ouvertes, obtenu Ok"),
};
let err_str = format!("{err:#}");
assert!(
err_str.contains("Permissions") || err_str.contains("permissions"),
"message d'erreur doit mentionner les permissions, obtenu: {err_str}"
);
}
#[test]
fn fichier_absent_puis_genere_est_relu_correctement() {
let dir = TempDir::new().expect("tempdir");
let (kid, aud, ttl_h, ttl_s) = params();
let jwt = load_or_generate_jwt_key(dir.path(), kid, aud, ttl_h, ttl_s)
.expect("doit générer et réussir");
let token = jwt
.sign(
"agent-x",
&["read".into(), "write".into()],
TokenScope::Service,
"main",
)
.expect("signature");
let claims = jwt.verify(&token).expect("vérification");
assert_eq!(claims.sub, "agent-x");
}
#[test]
fn fichier_corrompu_n_bytes_retourne_erreur() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().expect("tempdir");
let (kid, aud, ttl_h, ttl_s) = params();
let key_path = dir.path().join(format!("{JWT_SIGNING_KEY_NAME}.secret"));
std::fs::write(&key_path, vec![0xABu8; 31]).expect("écriture fichier corrompu");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
.expect("chmod 700 répertoire");
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
.expect("chmod 600 fichier");
let result = load_or_generate_jwt_key(dir.path(), kid, aud, ttl_h, ttl_s);
match result {
Err(err) => {
let err_str = format!("{err:#}");
assert!(
err_str.contains("32")
|| err_str.contains("bytes")
|| err_str.contains("seed")
|| err_str.contains("corrompu")
|| err_str.contains("invalide"),
"message d'erreur doit mentionner la taille invalide, obtenu: {err_str}"
);
}
Ok(_) => panic!("attendu Err sur bytes corrompus (31 bytes ≠ 32), obtenu Ok"),
}
}
}