#![forbid(unsafe_code)]
use jerrycan_core::{App, Extension};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
pub mod api_key;
pub mod guard;
pub mod jwt;
#[cfg(any(all(test, feature = "oauth"), feature = "mock-idp"))]
pub mod mock_idp;
#[cfg(feature = "oauth")]
pub mod oauth;
pub mod password;
pub mod session;
pub mod webhook;
pub use api_key::{
ApiKey, ApiKeyFuture, ApiKeyRecord, ApiKeyStore, ApiKeys, InMemoryApiKeyStore, MintedApiKey,
hash_key, mint, require_scope, verify,
};
pub use guard::{Bearer, Session, require_role};
#[cfg(any(all(test, feature = "oauth"), feature = "mock-idp"))]
pub use mock_idp::MockIdp;
#[cfg(feature = "oauth")]
pub use oauth::{
HttpTransport, OAuthClient, PkceVerifier, Provider, Secret, TokenFuture, TokenResponse,
TokenTransport, parse_token_body,
};
pub use password::{hash_password, verify_password};
pub use session::SessionStore;
pub(crate) const MIN_SECRET_LEN: usize = 32;
pub(crate) fn derive_key(secret: &[u8], label: &str) -> Zeroizing<[u8; 32]> {
let mut hasher = Sha256::new();
hasher.update(secret);
hasher.update(label.as_bytes());
Zeroizing::new(hasher.finalize().into())
}
fn dev_context_allowed(env: &str) -> bool {
matches!(
env.trim().to_ascii_lowercase().as_str(),
"" | "dev" | "development" | "test" | "local"
)
}
#[derive(Clone)]
pub struct Auth {
sessions: SessionStore,
tokens: SessionStore,
jwt_key: [u8; 32],
}
impl Auth {
pub fn with_secret(secret: &str) -> Self {
Self::with_secrets(secret, &[])
}
pub fn with_secrets(primary: &str, retired: &[&str]) -> Self {
let session_primary = derive_key(primary.as_bytes(), "session");
let token_primary = derive_key(primary.as_bytes(), "oauth-token");
let session_fallbacks: Vec<Zeroizing<[u8; 32]>> = retired
.iter()
.map(|s| derive_key(s.as_bytes(), "session"))
.collect();
let token_fallbacks: Vec<Zeroizing<[u8; 32]>> = retired
.iter()
.map(|s| derive_key(s.as_bytes(), "oauth-token"))
.collect();
let session_fallback_keys: Vec<[u8; 32]> = session_fallbacks.iter().map(|k| **k).collect();
let token_fallback_keys: Vec<[u8; 32]> = token_fallbacks.iter().map(|k| **k).collect();
Self {
sessions: SessionStore::with_keys(&session_primary, &session_fallback_keys),
tokens: SessionStore::with_keys(&token_primary, &token_fallback_keys),
jwt_key: *derive_key(primary.as_bytes(), "jwt"),
}
}
pub fn from_env() -> jerrycan_core::Result<Self> {
let env = std::env::var("JERRYCAN_ENV").unwrap_or_default();
let dev_ok = dev_context_allowed(&env);
let secret = std::env::var("JERRYCAN_SECRET").ok();
let retired_raw = std::env::var("JERRYCAN_SECRET_OLD").unwrap_or_default();
Self::from_env_parts(!dev_ok, secret.as_deref(), &retired_raw)
}
fn from_env_parts(
is_prod: bool,
secret: Option<&str>,
retired_raw: &str,
) -> jerrycan_core::Result<Self> {
let retired: Vec<&str> = retired_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if is_prod && let Some(short) = retired.iter().find(|s| s.len() < MIN_SECRET_LEN) {
return Err(jerrycan_core::Error::internal(format!(
"JERRYCAN_SECRET_OLD entries must each be at least {MIN_SECRET_LEN} bytes in production (got one of length {})",
short.len()
)));
}
match secret {
Some(s) if s.len() >= MIN_SECRET_LEN => Ok(Self::with_secrets(s, &retired)),
Some(_) if is_prod => Err(jerrycan_core::Error::internal(format!(
"JERRYCAN_SECRET must be at least {MIN_SECRET_LEN} bytes in production"
))),
None if is_prod => Err(jerrycan_core::Error::internal(
"JERRYCAN_SECRET is required in production (JERRYCAN_ENV=prod)",
)),
_ => {
eprintln!(
"jerrycan-auth: WARNING using an insecure development secret; set JERRYCAN_SECRET (>= {MIN_SECRET_LEN} bytes) for production"
);
Ok(Self::with_secrets(
"jerrycan-insecure-development-secret-do-not-use!!",
&retired,
))
}
}
}
pub fn sessions(&self) -> &SessionStore {
&self.sessions
}
pub fn tokens(&self) -> &SessionStore {
&self.tokens
}
pub fn jwt_key(&self) -> &[u8; 32] {
&self.jwt_key
}
}
impl Extension for Auth {
fn register(self, app: App) -> App {
app.provide(self)
}
}
#[cfg(test)]
mod secret_tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Tok {
access: String,
refresh: String,
}
fn sample_token() -> Tok {
Tok {
access: "at-123".into(),
refresh: "rt-456".into(),
}
}
const SECRET_OLD: &str = "old-secret-of-at-least-thirty-two-bytes!!";
const SECRET_NEW: &str = "new-secret-of-at-least-thirty-two-bytes!!";
const SECRET_STRANGER: &str = "stranger-secret-at-least-thirty-two-byte";
#[test]
fn derived_keys_are_label_separated() {
let s = b"a-very-long-development-secret-string!!";
assert_ne!(*derive_key(s, "session"), *derive_key(s, "jwt"));
assert_ne!(*derive_key(s, "session"), *derive_key(s, "oauth-token"));
assert_ne!(*derive_key(s, "jwt"), *derive_key(s, "oauth-token"));
assert_eq!(*derive_key(s, "session"), *derive_key(s, "session"));
}
#[test]
fn rotated_token_at_rest_still_decodes_so_rotation_does_not_log_everyone_out() {
let before = Auth::with_secret(SECRET_OLD);
let ciphertext = before.tokens().encode(&sample_token()).unwrap();
let after = Auth::with_secrets(SECRET_NEW, &[SECRET_OLD]);
let back: Tok = after
.tokens()
.decode(&ciphertext)
.expect("token encrypted before rotation must decode via the retired key");
assert_eq!(back, sample_token());
}
#[test]
fn a_secret_in_neither_primary_nor_retired_fails_401_real_retirement_invalidates() {
let stranger = Auth::with_secret(SECRET_STRANGER);
let ciphertext = stranger.tokens().encode(&sample_token()).unwrap();
let auth = Auth::with_secrets(SECRET_NEW, &[SECRET_OLD]);
let err = auth.tokens().decode::<Tok>(&ciphertext).unwrap_err();
assert_eq!(
err.code(),
"JC0401",
"fully-retired/unknown secrets must eventually invalidate their tokens"
);
}
#[test]
fn tokens_and_sessions_ciphertexts_are_not_cross_decryptable_label_separation() {
let auth = Auth::with_secret(SECRET_NEW);
let token_ct = auth.tokens().encode(&sample_token()).unwrap();
assert!(
auth.sessions().decode::<Tok>(&token_ct).is_err(),
"a leaked session key must not read tokens-at-rest"
);
let session_ct = auth.sessions().encode(&sample_token()).unwrap();
assert!(
auth.tokens().decode::<Tok>(&session_ct).is_err(),
"a leaked token key must not read sessions"
);
}
fn ok_auth(r: jerrycan_core::Result<Auth>) -> Auth {
match r {
Ok(a) => a,
Err(e) => panic!("expected Ok(Auth), got error: {e}"),
}
}
fn err_of(r: jerrycan_core::Result<Auth>) -> jerrycan_core::Error {
match r {
Ok(_) => panic!("expected an error, got Ok(Auth)"),
Err(e) => e,
}
}
#[test]
fn from_env_with_two_retired_secrets_decodes_tokens_from_either_old_key() {
let token_a = Auth::with_secret(SECRET_OLD)
.tokens()
.encode(&sample_token())
.unwrap();
let token_b = Auth::with_secret(SECRET_STRANGER)
.tokens()
.encode(&sample_token())
.unwrap();
let old = format!("{SECRET_OLD},{SECRET_STRANGER}");
let auth = ok_auth(Auth::from_env_parts(false, Some(SECRET_NEW), &old));
assert_eq!(
auth.tokens().decode::<Tok>(&token_a).unwrap(),
sample_token()
);
assert_eq!(
auth.tokens().decode::<Tok>(&token_b).unwrap(),
sample_token()
);
let token_new = auth.tokens().encode(&sample_token()).unwrap();
assert_eq!(
auth.tokens().decode::<Tok>(&token_new).unwrap(),
sample_token()
);
}
#[test]
fn from_env_prod_rejects_a_too_short_retired_secret() {
let err = err_of(Auth::from_env_parts(true, Some(SECRET_NEW), "too-short"));
assert!(
err.to_string().contains("JERRYCAN_SECRET_OLD"),
"prod must reject a short retired secret, got: {err}"
);
}
#[test]
fn from_env_dev_tolerates_a_short_retired_secret() {
Auth::from_env_parts(false, Some(SECRET_NEW), "too-short")
.expect("dev must not enforce retired-secret length");
}
#[test]
fn from_env_empty_retired_entries_are_skipped_even_in_prod() {
let auth = Auth::from_env_parts(true, Some(SECRET_NEW), ", ,")
.expect("blank-only retired list is valid in prod");
let ct = Auth::with_secret(SECRET_OLD)
.tokens()
.encode(&sample_token())
.unwrap();
assert!(auth.tokens().decode::<Tok>(&ct).is_err());
}
#[test]
fn from_env_unset_retired_is_identical_to_single_secret() {
let from_parts = ok_auth(Auth::from_env_parts(true, Some(SECRET_NEW), ""));
let single = Auth::with_secret(SECRET_NEW);
let ct = single.tokens().encode(&sample_token()).unwrap();
assert_eq!(
from_parts.tokens().decode::<Tok>(&ct).unwrap(),
sample_token()
);
}
#[test]
fn from_env_prod_requires_a_secret() {
let err = err_of(Auth::from_env_parts(true, None, ""));
assert!(err.to_string().contains("JERRYCAN_SECRET is required"));
}
#[test]
fn from_env_prod_rejects_short_primary() {
let err = err_of(Auth::from_env_parts(true, Some("short"), ""));
assert!(err.to_string().contains("at least"));
}
#[test]
fn dev_secret_fallback_is_opt_in_to_known_dev_envs_only() {
for dev in ["", " ", "dev", "development", "DEV", "Test", "local"] {
assert!(
dev_context_allowed(dev),
"{dev:?} should permit the dev key"
);
}
for prod in [
"prod",
"production",
"Production",
"prod-eu",
"staging",
"prd",
"live",
] {
assert!(
!dev_context_allowed(prod),
"{prod:?} must be treated as production (no dev-key fallback)"
);
}
}
}