use hex::encode;
use rand::{Rng, rng};
pub(super) const DEV_SECRET_PREFIX: &str = "ironflow-dev-";
pub(super) const MIN_SECRET_BYTES: usize = 32;
const GENERATED_SECRET_BYTES: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SecretMode {
Production,
Strict,
Development,
}
impl SecretMode {
pub(super) fn from_ironflow_env(value: Option<&str>) -> Self {
match value {
Some(v) if v.eq_ignore_ascii_case("production") => Self::Production,
Some(v) if v.eq_ignore_ascii_case("development") => Self::Development,
_ => Self::Strict,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum ResolvedSecret {
Provided(String),
Generated(String),
}
pub(super) fn resolve_secret(
name: &str,
value: Option<String>,
mode: SecretMode,
) -> Result<ResolvedSecret, String> {
let Some(value) = value else {
return match mode {
SecretMode::Development => Ok(ResolvedSecret::Generated(generate_secret())),
SecretMode::Production => Err(format!("{name} is required in production")),
SecretMode::Strict => Err(format!(
"{name} is required: generate one with `openssl rand -hex 32`, \
or set IRONFLOW_ENV=development to use an ephemeral one"
)),
};
};
if value.is_empty() {
return Err(format!("{name} must not be empty"));
}
if value.starts_with(DEV_SECRET_PREFIX) {
return Err(format!(
"{name} must not use a known development default (`{DEV_SECRET_PREFIX}` prefix)"
));
}
if mode != SecretMode::Development && value.len() < MIN_SECRET_BYTES {
return Err(format!(
"{name} must be at least {MIN_SECRET_BYTES} bytes, got {}",
value.len()
));
}
Ok(ResolvedSecret::Provided(value))
}
fn generate_secret() -> String {
let mut bytes = [0u8; GENERATED_SECRET_BYTES];
rng().fill(&mut bytes);
encode(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
const ALL_MODES: [SecretMode; 3] = [
SecretMode::Production,
SecretMode::Strict,
SecretMode::Development,
];
const STRONG: &str = "prod-jwt-secret-0123456789abcdef0123456789";
fn resolve(value: Option<&str>, mode: SecretMode) -> Result<ResolvedSecret, String> {
resolve_secret("JWT_SECRET", value.map(str::to_string), mode)
}
#[test]
fn mode_from_ironflow_env() {
assert_eq!(
SecretMode::from_ironflow_env(Some("production")),
SecretMode::Production
);
assert_eq!(
SecretMode::from_ironflow_env(Some("PRODUCTION")),
SecretMode::Production
);
assert_eq!(
SecretMode::from_ironflow_env(Some("Development")),
SecretMode::Development
);
for raw in [None, Some(""), Some("dev"), Some("staging")] {
assert_eq!(
SecretMode::from_ironflow_env(raw),
SecretMode::Strict,
"{raw:?}"
);
}
}
#[test]
fn missing_secret_is_required_outside_development() {
let prod = resolve(None, SecretMode::Production).unwrap_err();
assert!(
prod.contains("JWT_SECRET is required in production"),
"{prod}"
);
let strict = resolve(None, SecretMode::Strict).unwrap_err();
assert!(strict.contains("JWT_SECRET is required"), "{strict}");
assert!(strict.contains("IRONFLOW_ENV=development"), "{strict}");
}
#[test]
fn missing_secret_is_generated_in_development() {
let Ok(ResolvedSecret::Generated(first)) = resolve(None, SecretMode::Development) else {
panic!("a missing secret must be generated in development");
};
let Ok(ResolvedSecret::Generated(second)) = resolve(None, SecretMode::Development) else {
panic!("a missing secret must be generated in development");
};
assert_eq!(first.len(), GENERATED_SECRET_BYTES * 2);
assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
assert!(first.len() >= MIN_SECRET_BYTES);
assert!(!first.starts_with(DEV_SECRET_PREFIX));
assert_ne!(first, second);
}
#[test]
fn known_dev_defaults_are_rejected_in_every_mode() {
for mode in ALL_MODES {
for value in [
"ironflow-dev-secret",
"ironflow-dev-worker-token",
"ironflow-dev-jwt-secret",
"ironflow-dev-0123456789abcdef0123456789abcdef",
] {
let err = resolve(Some(value), mode).unwrap_err();
assert!(
err.contains("JWT_SECRET") && err.contains("known development default"),
"{mode:?} {value}: {err}"
);
}
}
}
#[test]
fn empty_secret_is_rejected_in_every_mode() {
for mode in ALL_MODES {
let err = resolve(Some(""), mode).unwrap_err();
assert!(err.contains("must not be empty"), "{mode:?}: {err}");
}
}
#[test]
fn short_secret_is_rejected_outside_development() {
for mode in [SecretMode::Production, SecretMode::Strict] {
let err = resolve(Some("shortsecret"), mode).unwrap_err();
assert!(err.contains("32 bytes, got 11"), "{mode:?}: {err}");
}
assert_eq!(
resolve(Some("shortsecret"), SecretMode::Development),
Ok(ResolvedSecret::Provided("shortsecret".to_string()))
);
}
#[test]
fn length_boundary() {
let exactly_32 = "0123456789abcdef0123456789abcdef";
let just_under = &exactly_32[..31];
for mode in [SecretMode::Production, SecretMode::Strict] {
assert_eq!(
resolve(Some(exactly_32), mode),
Ok(ResolvedSecret::Provided(exactly_32.to_string()))
);
assert!(resolve(Some(just_under), mode).is_err(), "{mode:?}");
}
}
#[test]
fn strong_secret_is_provided_unchanged_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(
resolve(Some(STRONG), mode),
Ok(ResolvedSecret::Provided(STRONG.to_string()))
);
}
}
#[test]
fn non_ascii_secret_length_is_counted_in_bytes() {
let accented = "é".repeat(16);
assert_eq!(
resolve(Some(&accented), SecretMode::Strict),
Ok(ResolvedSecret::Provided(accented.clone()))
);
}
}