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;
12#[cfg(feature = "idtoken")]
16pub mod idtoken;
17pub mod jwt;
18#[cfg(any(all(test, feature = "oauth"), feature = "mock-idp"))]
25pub mod mock_idp;
26#[cfg(feature = "oauth")]
27pub mod oauth;
28pub mod password;
29pub mod session;
30pub mod webhook;
31
32pub use api_key::{
33 ApiKey, ApiKeyFuture, ApiKeyRecord, ApiKeyStore, ApiKeys, InMemoryApiKeyStore, MintedApiKey,
34 hash_key, mint, require_scope, verify,
35};
36pub use guard::{Bearer, Session, require_role};
37#[cfg(feature = "idtoken")]
38pub use idtoken::{HttpJwksSource, IdTokenClaims, Jwk, Jwks, JwksFuture, JwksSource, Verifier};
39#[cfg(any(all(test, feature = "oauth"), feature = "mock-idp"))]
40pub use mock_idp::MockIdp;
41#[cfg(feature = "oauth")]
42pub use oauth::{
43 HttpTransport, OAuthClient, PkceVerifier, Provider, Secret, TokenFuture, TokenResponse,
44 TokenTransport, parse_token_body,
45};
46pub use password::{hash_password, needs_rehash, verify_password};
47pub use session::SessionStore;
48
49pub(crate) const MIN_SECRET_LEN: usize = 32;
51
52pub(crate) fn derive_key(secret: &[u8], label: &str) -> Zeroizing<[u8; 32]> {
60 let mut hasher = Sha256::new();
61 hasher.update(secret);
62 hasher.update(label.as_bytes());
63 Zeroizing::new(hasher.finalize().into())
64}
65
66fn dev_context_allowed(env: &str) -> bool {
72 matches!(
73 env.trim().to_ascii_lowercase().as_str(),
74 "" | "dev" | "development" | "test" | "local"
75 )
76}
77
78#[derive(Clone)]
85pub struct Auth {
86 sessions: SessionStore,
87 tokens: SessionStore,
88 jwt_key: [u8; 32],
89}
90
91impl Auth {
92 pub fn with_secret(secret: &str) -> Self {
95 Self::with_secrets(secret, &[])
96 }
97
98 pub fn with_secrets(primary: &str, retired: &[&str]) -> Self {
104 let session_primary = derive_key(primary.as_bytes(), "session");
107 let token_primary = derive_key(primary.as_bytes(), "oauth-token");
108
109 let session_fallbacks: Vec<Zeroizing<[u8; 32]>> = retired
112 .iter()
113 .map(|s| derive_key(s.as_bytes(), "session"))
114 .collect();
115 let token_fallbacks: Vec<Zeroizing<[u8; 32]>> = retired
116 .iter()
117 .map(|s| derive_key(s.as_bytes(), "oauth-token"))
118 .collect();
119 let session_fallback_keys: Vec<[u8; 32]> = session_fallbacks.iter().map(|k| **k).collect();
121 let token_fallback_keys: Vec<[u8; 32]> = token_fallbacks.iter().map(|k| **k).collect();
122
123 Self {
124 sessions: SessionStore::with_keys(&session_primary, &session_fallback_keys),
125 tokens: SessionStore::with_keys(&token_primary, &token_fallback_keys),
126 jwt_key: *derive_key(primary.as_bytes(), "jwt"),
127 }
128 }
129
130 pub fn from_env() -> jerrycan_core::Result<Self> {
142 let env = std::env::var("JERRYCAN_ENV").unwrap_or_default();
143 let dev_ok = dev_context_allowed(&env);
150 let secret = std::env::var("JERRYCAN_SECRET").ok();
151 let retired_raw = std::env::var("JERRYCAN_SECRET_OLD").unwrap_or_default();
152 Self::from_env_parts(!dev_ok, secret.as_deref(), &retired_raw)
153 }
154
155 fn from_env_parts(
162 is_prod: bool,
163 secret: Option<&str>,
164 retired_raw: &str,
165 ) -> jerrycan_core::Result<Self> {
166 let retired: Vec<&str> = retired_raw
167 .split(',')
168 .map(str::trim)
169 .filter(|s| !s.is_empty())
170 .collect();
171 if is_prod && let Some(short) = retired.iter().find(|s| s.len() < MIN_SECRET_LEN) {
172 return Err(jerrycan_core::Error::internal(format!(
173 "JERRYCAN_SECRET_OLD entries must each be at least {MIN_SECRET_LEN} bytes in production (got one of length {})",
174 short.len()
175 )));
176 }
177
178 match secret {
179 Some(s) if s.len() >= MIN_SECRET_LEN => Ok(Self::with_secrets(s, &retired)),
180 Some(_) if is_prod => Err(jerrycan_core::Error::internal(format!(
181 "JERRYCAN_SECRET must be at least {MIN_SECRET_LEN} bytes in production"
182 ))),
183 None if is_prod => Err(jerrycan_core::Error::internal(
184 "JERRYCAN_SECRET is required in production (JERRYCAN_ENV=prod)",
185 )),
186 _ => {
187 eprintln!(
188 "jerrycan-auth: WARNING using an insecure development secret; set JERRYCAN_SECRET (>= {MIN_SECRET_LEN} bytes) for production"
189 );
190 Ok(Self::with_secrets(
191 "jerrycan-insecure-development-secret-do-not-use!!",
192 &retired,
193 ))
194 }
195 }
196 }
197
198 pub fn sessions(&self) -> &SessionStore {
199 &self.sessions
200 }
201
202 pub fn tokens(&self) -> &SessionStore {
207 &self.tokens
208 }
209
210 pub fn jwt_key(&self) -> &[u8; 32] {
211 &self.jwt_key
212 }
213}
214
215impl Extension for Auth {
216 fn register(self, app: App) -> App {
217 app.provide(self)
218 }
219}
220
221#[cfg(test)]
222mod secret_tests {
223 use super::*;
224 use serde::{Deserialize, Serialize};
225
226 #[derive(Serialize, Deserialize, PartialEq, Debug)]
227 struct Tok {
228 access: String,
229 refresh: String,
230 }
231
232 fn sample_token() -> Tok {
233 Tok {
234 access: "at-123".into(),
235 refresh: "rt-456".into(),
236 }
237 }
238
239 const SECRET_OLD: &str = "old-secret-of-at-least-thirty-two-bytes!!";
241 const SECRET_NEW: &str = "new-secret-of-at-least-thirty-two-bytes!!";
242 const SECRET_STRANGER: &str = "stranger-secret-at-least-thirty-two-byte";
243
244 #[test]
245 fn derived_keys_are_label_separated() {
246 let s = b"a-very-long-development-secret-string!!";
247 assert_ne!(*derive_key(s, "session"), *derive_key(s, "jwt"));
248 assert_ne!(*derive_key(s, "session"), *derive_key(s, "oauth-token"));
249 assert_ne!(*derive_key(s, "jwt"), *derive_key(s, "oauth-token"));
250 assert_eq!(*derive_key(s, "session"), *derive_key(s, "session"));
251 }
252
253 #[test]
254 fn rotated_token_at_rest_still_decodes_so_rotation_does_not_log_everyone_out() {
255 let before = Auth::with_secret(SECRET_OLD);
257 let ciphertext = before.tokens().encode(&sample_token()).unwrap();
258
259 let after = Auth::with_secrets(SECRET_NEW, &[SECRET_OLD]);
261 let back: Tok = after
262 .tokens()
263 .decode(&ciphertext)
264 .expect("token encrypted before rotation must decode via the retired key");
265 assert_eq!(back, sample_token());
266 }
267
268 #[test]
269 fn a_secret_in_neither_primary_nor_retired_fails_401_real_retirement_invalidates() {
270 let stranger = Auth::with_secret(SECRET_STRANGER);
272 let ciphertext = stranger.tokens().encode(&sample_token()).unwrap();
273
274 let auth = Auth::with_secrets(SECRET_NEW, &[SECRET_OLD]);
275 let err = auth.tokens().decode::<Tok>(&ciphertext).unwrap_err();
276 assert_eq!(
277 err.code(),
278 "JC0401",
279 "fully-retired/unknown secrets must eventually invalidate their tokens"
280 );
281 }
282
283 #[test]
284 fn tokens_and_sessions_ciphertexts_are_not_cross_decryptable_label_separation() {
285 let auth = Auth::with_secret(SECRET_NEW);
286
287 let token_ct = auth.tokens().encode(&sample_token()).unwrap();
289 assert!(
290 auth.sessions().decode::<Tok>(&token_ct).is_err(),
291 "a leaked session key must not read tokens-at-rest"
292 );
293
294 let session_ct = auth.sessions().encode(&sample_token()).unwrap();
296 assert!(
297 auth.tokens().decode::<Tok>(&session_ct).is_err(),
298 "a leaked token key must not read sessions"
299 );
300 }
301
302 fn ok_auth(r: jerrycan_core::Result<Auth>) -> Auth {
314 match r {
315 Ok(a) => a,
316 Err(e) => panic!("expected Ok(Auth), got error: {e}"),
317 }
318 }
319 fn err_of(r: jerrycan_core::Result<Auth>) -> jerrycan_core::Error {
320 match r {
321 Ok(_) => panic!("expected an error, got Ok(Auth)"),
322 Err(e) => e,
323 }
324 }
325
326 #[test]
327 fn from_env_with_two_retired_secrets_decodes_tokens_from_either_old_key() {
328 let token_a = Auth::with_secret(SECRET_OLD)
330 .tokens()
331 .encode(&sample_token())
332 .unwrap();
333 let token_b = Auth::with_secret(SECRET_STRANGER)
334 .tokens()
335 .encode(&sample_token())
336 .unwrap();
337
338 let old = format!("{SECRET_OLD},{SECRET_STRANGER}");
339 let auth = ok_auth(Auth::from_env_parts(false, Some(SECRET_NEW), &old));
340
341 assert_eq!(
343 auth.tokens().decode::<Tok>(&token_a).unwrap(),
344 sample_token()
345 );
346 assert_eq!(
347 auth.tokens().decode::<Tok>(&token_b).unwrap(),
348 sample_token()
349 );
350 let token_new = auth.tokens().encode(&sample_token()).unwrap();
352 assert_eq!(
353 auth.tokens().decode::<Tok>(&token_new).unwrap(),
354 sample_token()
355 );
356 }
357
358 #[test]
359 fn from_env_prod_rejects_a_too_short_retired_secret() {
360 let err = err_of(Auth::from_env_parts(true, Some(SECRET_NEW), "too-short"));
361 assert!(
362 err.to_string().contains("JERRYCAN_SECRET_OLD"),
363 "prod must reject a short retired secret, got: {err}"
364 );
365 }
366
367 #[test]
368 fn from_env_dev_tolerates_a_short_retired_secret() {
369 Auth::from_env_parts(false, Some(SECRET_NEW), "too-short")
371 .expect("dev must not enforce retired-secret length");
372 }
373
374 #[test]
375 fn from_env_empty_retired_entries_are_skipped_even_in_prod() {
376 let auth = Auth::from_env_parts(true, Some(SECRET_NEW), ", ,")
378 .expect("blank-only retired list is valid in prod");
379 let ct = Auth::with_secret(SECRET_OLD)
382 .tokens()
383 .encode(&sample_token())
384 .unwrap();
385 assert!(auth.tokens().decode::<Tok>(&ct).is_err());
386 }
387
388 #[test]
389 fn from_env_unset_retired_is_identical_to_single_secret() {
390 let from_parts = ok_auth(Auth::from_env_parts(true, Some(SECRET_NEW), ""));
392 let single = Auth::with_secret(SECRET_NEW);
393 let ct = single.tokens().encode(&sample_token()).unwrap();
394 assert_eq!(
395 from_parts.tokens().decode::<Tok>(&ct).unwrap(),
396 sample_token()
397 );
398 }
399
400 #[test]
401 fn from_env_prod_requires_a_secret() {
402 let err = err_of(Auth::from_env_parts(true, None, ""));
403 assert!(err.to_string().contains("JERRYCAN_SECRET is required"));
404 }
405
406 #[test]
407 fn from_env_prod_rejects_short_primary() {
408 let err = err_of(Auth::from_env_parts(true, Some("short"), ""));
409 assert!(err.to_string().contains("at least"));
410 }
411
412 #[test]
417 fn dev_secret_fallback_is_opt_in_to_known_dev_envs_only() {
418 for dev in ["", " ", "dev", "development", "DEV", "Test", "local"] {
419 assert!(
420 dev_context_allowed(dev),
421 "{dev:?} should permit the dev key"
422 );
423 }
424 for prod in [
425 "prod",
426 "production",
427 "Production",
428 "prod-eu",
429 "staging",
430 "prd",
431 "live",
432 ] {
433 assert!(
434 !dev_context_allowed(prod),
435 "{prod:?} must be treated as production (no dev-key fallback)"
436 );
437 }
438 }
439}