1#![forbid(unsafe_code)]
5
6use jerrycan_core::{App, Extension};
7use sha2::{Digest, Sha256};
8use zeroize::Zeroizing;
9
10pub mod api_key;
11pub mod guard;
12pub mod jwt;
13#[cfg(any(all(test, feature = "oauth"), feature = "mock-idp"))]
20pub mod mock_idp;
21#[cfg(feature = "oauth")]
22pub mod oauth;
23pub mod password;
24pub mod session;
25pub mod webhook;
26
27pub use api_key::{
28 ApiKey, ApiKeyFuture, ApiKeyRecord, ApiKeyStore, ApiKeys, InMemoryApiKeyStore, MintedApiKey,
29 hash_key, mint, require_scope, verify,
30};
31pub use guard::{Bearer, Session, require_role};
32#[cfg(any(all(test, feature = "oauth"), feature = "mock-idp"))]
33pub use mock_idp::MockIdp;
34#[cfg(feature = "oauth")]
35pub use oauth::{
36 HttpTransport, OAuthClient, PkceVerifier, Provider, Secret, TokenFuture, TokenResponse,
37 TokenTransport, parse_token_body,
38};
39pub use password::{hash_password, verify_password};
40pub use session::SessionStore;
41
42pub(crate) const MIN_SECRET_LEN: usize = 32;
44
45pub(crate) fn derive_key(secret: &[u8], label: &str) -> Zeroizing<[u8; 32]> {
53 let mut hasher = Sha256::new();
54 hasher.update(secret);
55 hasher.update(label.as_bytes());
56 Zeroizing::new(hasher.finalize().into())
57}
58
59fn dev_context_allowed(env: &str) -> bool {
65 matches!(
66 env.trim().to_ascii_lowercase().as_str(),
67 "" | "dev" | "development" | "test" | "local"
68 )
69}
70
71#[derive(Clone)]
78pub struct Auth {
79 sessions: SessionStore,
80 tokens: SessionStore,
81 jwt_key: [u8; 32],
82}
83
84impl Auth {
85 pub fn with_secret(secret: &str) -> Self {
88 Self::with_secrets(secret, &[])
89 }
90
91 pub fn with_secrets(primary: &str, retired: &[&str]) -> Self {
97 let session_primary = derive_key(primary.as_bytes(), "session");
100 let token_primary = derive_key(primary.as_bytes(), "oauth-token");
101
102 let session_fallbacks: Vec<Zeroizing<[u8; 32]>> = retired
105 .iter()
106 .map(|s| derive_key(s.as_bytes(), "session"))
107 .collect();
108 let token_fallbacks: Vec<Zeroizing<[u8; 32]>> = retired
109 .iter()
110 .map(|s| derive_key(s.as_bytes(), "oauth-token"))
111 .collect();
112 let session_fallback_keys: Vec<[u8; 32]> = session_fallbacks.iter().map(|k| **k).collect();
114 let token_fallback_keys: Vec<[u8; 32]> = token_fallbacks.iter().map(|k| **k).collect();
115
116 Self {
117 sessions: SessionStore::with_keys(&session_primary, &session_fallback_keys),
118 tokens: SessionStore::with_keys(&token_primary, &token_fallback_keys),
119 jwt_key: *derive_key(primary.as_bytes(), "jwt"),
120 }
121 }
122
123 pub fn from_env() -> jerrycan_core::Result<Self> {
135 let env = std::env::var("JERRYCAN_ENV").unwrap_or_default();
136 let dev_ok = dev_context_allowed(&env);
143 let secret = std::env::var("JERRYCAN_SECRET").ok();
144 let retired_raw = std::env::var("JERRYCAN_SECRET_OLD").unwrap_or_default();
145 Self::from_env_parts(!dev_ok, secret.as_deref(), &retired_raw)
146 }
147
148 fn from_env_parts(
155 is_prod: bool,
156 secret: Option<&str>,
157 retired_raw: &str,
158 ) -> jerrycan_core::Result<Self> {
159 let retired: Vec<&str> = retired_raw
160 .split(',')
161 .map(str::trim)
162 .filter(|s| !s.is_empty())
163 .collect();
164 if is_prod && let Some(short) = retired.iter().find(|s| s.len() < MIN_SECRET_LEN) {
165 return Err(jerrycan_core::Error::internal(format!(
166 "JERRYCAN_SECRET_OLD entries must each be at least {MIN_SECRET_LEN} bytes in production (got one of length {})",
167 short.len()
168 )));
169 }
170
171 match secret {
172 Some(s) if s.len() >= MIN_SECRET_LEN => Ok(Self::with_secrets(s, &retired)),
173 Some(_) if is_prod => Err(jerrycan_core::Error::internal(format!(
174 "JERRYCAN_SECRET must be at least {MIN_SECRET_LEN} bytes in production"
175 ))),
176 None if is_prod => Err(jerrycan_core::Error::internal(
177 "JERRYCAN_SECRET is required in production (JERRYCAN_ENV=prod)",
178 )),
179 _ => {
180 eprintln!(
181 "jerrycan-auth: WARNING using an insecure development secret; set JERRYCAN_SECRET (>= {MIN_SECRET_LEN} bytes) for production"
182 );
183 Ok(Self::with_secrets(
184 "jerrycan-insecure-development-secret-do-not-use!!",
185 &retired,
186 ))
187 }
188 }
189 }
190
191 pub fn sessions(&self) -> &SessionStore {
192 &self.sessions
193 }
194
195 pub fn tokens(&self) -> &SessionStore {
200 &self.tokens
201 }
202
203 pub fn jwt_key(&self) -> &[u8; 32] {
204 &self.jwt_key
205 }
206}
207
208impl Extension for Auth {
209 fn register(self, app: App) -> App {
210 app.provide(self)
211 }
212}
213
214#[cfg(test)]
215mod secret_tests {
216 use super::*;
217 use serde::{Deserialize, Serialize};
218
219 #[derive(Serialize, Deserialize, PartialEq, Debug)]
220 struct Tok {
221 access: String,
222 refresh: String,
223 }
224
225 fn sample_token() -> Tok {
226 Tok {
227 access: "at-123".into(),
228 refresh: "rt-456".into(),
229 }
230 }
231
232 const SECRET_OLD: &str = "old-secret-of-at-least-thirty-two-bytes!!";
234 const SECRET_NEW: &str = "new-secret-of-at-least-thirty-two-bytes!!";
235 const SECRET_STRANGER: &str = "stranger-secret-at-least-thirty-two-byte";
236
237 #[test]
238 fn derived_keys_are_label_separated() {
239 let s = b"a-very-long-development-secret-string!!";
240 assert_ne!(*derive_key(s, "session"), *derive_key(s, "jwt"));
241 assert_ne!(*derive_key(s, "session"), *derive_key(s, "oauth-token"));
242 assert_ne!(*derive_key(s, "jwt"), *derive_key(s, "oauth-token"));
243 assert_eq!(*derive_key(s, "session"), *derive_key(s, "session"));
244 }
245
246 #[test]
247 fn rotated_token_at_rest_still_decodes_so_rotation_does_not_log_everyone_out() {
248 let before = Auth::with_secret(SECRET_OLD);
250 let ciphertext = before.tokens().encode(&sample_token()).unwrap();
251
252 let after = Auth::with_secrets(SECRET_NEW, &[SECRET_OLD]);
254 let back: Tok = after
255 .tokens()
256 .decode(&ciphertext)
257 .expect("token encrypted before rotation must decode via the retired key");
258 assert_eq!(back, sample_token());
259 }
260
261 #[test]
262 fn a_secret_in_neither_primary_nor_retired_fails_401_real_retirement_invalidates() {
263 let stranger = Auth::with_secret(SECRET_STRANGER);
265 let ciphertext = stranger.tokens().encode(&sample_token()).unwrap();
266
267 let auth = Auth::with_secrets(SECRET_NEW, &[SECRET_OLD]);
268 let err = auth.tokens().decode::<Tok>(&ciphertext).unwrap_err();
269 assert_eq!(
270 err.code(),
271 "JC0401",
272 "fully-retired/unknown secrets must eventually invalidate their tokens"
273 );
274 }
275
276 #[test]
277 fn tokens_and_sessions_ciphertexts_are_not_cross_decryptable_label_separation() {
278 let auth = Auth::with_secret(SECRET_NEW);
279
280 let token_ct = auth.tokens().encode(&sample_token()).unwrap();
282 assert!(
283 auth.sessions().decode::<Tok>(&token_ct).is_err(),
284 "a leaked session key must not read tokens-at-rest"
285 );
286
287 let session_ct = auth.sessions().encode(&sample_token()).unwrap();
289 assert!(
290 auth.tokens().decode::<Tok>(&session_ct).is_err(),
291 "a leaked token key must not read sessions"
292 );
293 }
294
295 fn ok_auth(r: jerrycan_core::Result<Auth>) -> Auth {
307 match r {
308 Ok(a) => a,
309 Err(e) => panic!("expected Ok(Auth), got error: {e}"),
310 }
311 }
312 fn err_of(r: jerrycan_core::Result<Auth>) -> jerrycan_core::Error {
313 match r {
314 Ok(_) => panic!("expected an error, got Ok(Auth)"),
315 Err(e) => e,
316 }
317 }
318
319 #[test]
320 fn from_env_with_two_retired_secrets_decodes_tokens_from_either_old_key() {
321 let token_a = Auth::with_secret(SECRET_OLD)
323 .tokens()
324 .encode(&sample_token())
325 .unwrap();
326 let token_b = Auth::with_secret(SECRET_STRANGER)
327 .tokens()
328 .encode(&sample_token())
329 .unwrap();
330
331 let old = format!("{SECRET_OLD},{SECRET_STRANGER}");
332 let auth = ok_auth(Auth::from_env_parts(false, Some(SECRET_NEW), &old));
333
334 assert_eq!(
336 auth.tokens().decode::<Tok>(&token_a).unwrap(),
337 sample_token()
338 );
339 assert_eq!(
340 auth.tokens().decode::<Tok>(&token_b).unwrap(),
341 sample_token()
342 );
343 let token_new = auth.tokens().encode(&sample_token()).unwrap();
345 assert_eq!(
346 auth.tokens().decode::<Tok>(&token_new).unwrap(),
347 sample_token()
348 );
349 }
350
351 #[test]
352 fn from_env_prod_rejects_a_too_short_retired_secret() {
353 let err = err_of(Auth::from_env_parts(true, Some(SECRET_NEW), "too-short"));
354 assert!(
355 err.to_string().contains("JERRYCAN_SECRET_OLD"),
356 "prod must reject a short retired secret, got: {err}"
357 );
358 }
359
360 #[test]
361 fn from_env_dev_tolerates_a_short_retired_secret() {
362 Auth::from_env_parts(false, Some(SECRET_NEW), "too-short")
364 .expect("dev must not enforce retired-secret length");
365 }
366
367 #[test]
368 fn from_env_empty_retired_entries_are_skipped_even_in_prod() {
369 let auth = Auth::from_env_parts(true, Some(SECRET_NEW), ", ,")
371 .expect("blank-only retired list is valid in prod");
372 let ct = Auth::with_secret(SECRET_OLD)
375 .tokens()
376 .encode(&sample_token())
377 .unwrap();
378 assert!(auth.tokens().decode::<Tok>(&ct).is_err());
379 }
380
381 #[test]
382 fn from_env_unset_retired_is_identical_to_single_secret() {
383 let from_parts = ok_auth(Auth::from_env_parts(true, Some(SECRET_NEW), ""));
385 let single = Auth::with_secret(SECRET_NEW);
386 let ct = single.tokens().encode(&sample_token()).unwrap();
387 assert_eq!(
388 from_parts.tokens().decode::<Tok>(&ct).unwrap(),
389 sample_token()
390 );
391 }
392
393 #[test]
394 fn from_env_prod_requires_a_secret() {
395 let err = err_of(Auth::from_env_parts(true, None, ""));
396 assert!(err.to_string().contains("JERRYCAN_SECRET is required"));
397 }
398
399 #[test]
400 fn from_env_prod_rejects_short_primary() {
401 let err = err_of(Auth::from_env_parts(true, Some("short"), ""));
402 assert!(err.to_string().contains("at least"));
403 }
404
405 #[test]
410 fn dev_secret_fallback_is_opt_in_to_known_dev_envs_only() {
411 for dev in ["", " ", "dev", "development", "DEV", "Test", "local"] {
412 assert!(
413 dev_context_allowed(dev),
414 "{dev:?} should permit the dev key"
415 );
416 }
417 for prod in [
418 "prod",
419 "production",
420 "Production",
421 "prod-eu",
422 "staging",
423 "prd",
424 "live",
425 ] {
426 assert!(
427 !dev_context_allowed(prod),
428 "{prod:?} must be treated as production (no dev-key fallback)"
429 );
430 }
431 }
432}