Skip to main content

jerrycan_auth/
lib.rs

1//! Authentication for jerrycan: argon2 password hashing, AEAD session cookies,
2//! HS256 JWTs, role guards. Vetted RustCrypto primitives; hand-rolled envelopes
3//! (see module docs). #![forbid(unsafe_code)].
4#![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// The mock IdP is a test/eval-only harness that mints deterministic tokens. It
14// needs the oauth types, so it compiles for this crate's OWN tests when oauth is on
15// (`cfg(test)` + `feature = "oauth"`, so `cargo test --features oauth` keeps seeing
16// it) and for downstream code ONLY behind the explicit `mock-idp` feature (which
17// implies `oauth`) — never in a plain `oauth` prod build, where its public
18// `into_app()` would otherwise be reachable.
19#[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
42/// Minimum entropy for `JERRYCAN_SECRET`. Shorter secrets are rejected in prod.
43pub(crate) const MIN_SECRET_LEN: usize = 32;
44
45/// Derive a 32-byte subkey from the master secret and a domain label, so the
46/// session key, the JWT key, and the token-at-rest key are independent even
47/// though one secret seeds all of them.
48///
49/// Returns `Zeroizing<[u8; 32]>` so the derived bytes are wiped from memory when
50/// the value drops. It derefs to `[u8; 32]`, so `&derive_key(..)` coerces to the
51/// `&[u8; 32]` / `&[u8]` that callers (`SessionStore::new`, `jwt::*`) expect.
52pub(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
59/// Whether `JERRYCAN_ENV` names an unmistakably non-production context in which
60/// the insecure built-in dev secret may be used. Only an unset/empty value or an
61/// explicit dev marker qualifies; everything else (incl. any production spelling
62/// or a typo) is treated as production and must supply a real secret. The match
63/// is trimmed + lowercased so `" Prod "`/`PRODUCTION` are still production.
64fn 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/// The auth extension: holds the derived session, token-at-rest, and JWT keys,
72/// registered as a dependency so `Session`/`Bearer` extractors can resolve it.
73///
74/// Each store is rotation-aware (multi-key decrypt): the keys derived from the
75/// *primary* secret encrypt new data, while keys derived from any *retired*
76/// secrets only decrypt pre-rotation data (see [`Auth::with_secrets`]).
77#[derive(Clone)]
78pub struct Auth {
79    sessions: SessionStore,
80    tokens: SessionStore,
81    jwt_key: [u8; 32],
82}
83
84impl Auth {
85    /// Build from an explicit secret (>= 32 bytes recommended), with no retired
86    /// secrets. Equivalent to `with_secrets(secret, &[])`.
87    pub fn with_secret(secret: &str) -> Self {
88        Self::with_secrets(secret, &[])
89    }
90
91    /// Build with key rotation: `primary` encrypts new sessions/tokens; each of
92    /// `retired` can still *decrypt* sessions/tokens minted before rotation but
93    /// is never used to encrypt. Move the previous `JERRYCAN_SECRET` into
94    /// `retired` to rotate without logging users out, then drop it once you want
95    /// its sessions/tokens fully invalidated.
96    pub fn with_secrets(primary: &str, retired: &[&str]) -> Self {
97        // Session and token-at-rest keys: distinct labels keep their ciphertexts
98        // non-cross-decryptable even though one secret seeds both.
99        let session_primary = derive_key(primary.as_bytes(), "session");
100        let token_primary = derive_key(primary.as_bytes(), "oauth-token");
101
102        // Derive fallback key sets from the retired secrets. The `Zeroizing`
103        // wrappers wipe the bytes when these vecs drop at the end of the fn.
104        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        // `SessionStore::with_keys` wants `&[[u8; 32]]`; map through the deref.
113        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    /// Build from `JERRYCAN_SECRET` (primary) plus optional `JERRYCAN_SECRET_OLD`
124    /// (a comma-separated list of retired secrets for key rotation).
125    ///
126    /// The insecure built-in dev key is used ONLY when `JERRYCAN_ENV` is
127    /// unset/empty or an explicit dev marker (`dev`/`development`/`test`/`local`).
128    /// Any other value — including any production spelling (`production`,
129    /// `prod-eu`, …) or a typo — is treated as production: a missing or short
130    /// primary secret is then a loud error (fail closed), and each non-empty
131    /// retired secret must also meet `MIN_SECRET_LEN` (empty entries are skipped —
132    /// `JERRYCAN_SECRET_OLD=""` or a trailing comma is harmless). When
133    /// `JERRYCAN_SECRET_OLD` is unset, behavior is identical to a single secret.
134    pub fn from_env() -> jerrycan_core::Result<Self> {
135        let env = std::env::var("JERRYCAN_ENV").unwrap_or_default();
136        // The insecure dev-secret fallback is allowed ONLY in an unmistakably
137        // non-production context: `JERRYCAN_ENV` unset/empty or an explicit dev
138        // marker. ANY other value — `production`, `prod-eu`, `staging`, a typo —
139        // is treated as production and REQUIRES a real `JERRYCAN_SECRET`, so a
140        // misspelled env can never silently sign sessions with the world-known
141        // development key (fail closed, not open).
142        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    /// The pure core of [`Auth::from_env`]: all the env-var parsing and prod
149    /// validation, parameterized on the raw values so it is testable without
150    /// mutating process-global state (which `#![forbid(unsafe_code)]` + edition
151    /// 2024's `unsafe set_var` makes awkward, and which races under parallel
152    /// tests). `secret` is `JERRYCAN_SECRET`; `retired_raw` is the raw
153    /// `JERRYCAN_SECRET_OLD` string (comma-separated, empties skipped).
154    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    /// The token-at-rest codec (rotation-aware, keyed independently of sessions).
196    /// Encrypt an OAuth `TokenResponse` with `auth.tokens().encode(&t)?` before
197    /// persisting the ciphertext; `decode` on read. Key rotation applies
198    /// automatically, exactly as for sessions.
199    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    // Two real 32+ byte secrets for rotation tests.
233    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        // App encrypts an OAuth token under the OLD secret, persists ciphertext.
249        let before = Auth::with_secret(SECRET_OLD);
250        let ciphertext = before.tokens().encode(&sample_token()).unwrap();
251
252        // Operator rotates JERRYCAN_SECRET to NEW, lists OLD as retired.
253        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        // Token from a secret that is never the primary and never retired.
264        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        // A token ciphertext must NOT decode through the session store...
281        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        // ...and a session ciphertext must NOT decode through the token store.
288        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    // --- from_env parsing/validation ---
296    //
297    // We test `from_env_parts` (the pure core) directly rather than mutating
298    // process-global env vars. Edition 2024 makes `std::env::set_var` `unsafe`,
299    // which `#![forbid(unsafe_code)]` rejects; and env mutation races under
300    // cargo's parallel test threads. Passing the raw values in keeps every
301    // assertion deterministic and exercises the exact logic `from_env` runs.
302    //
303    // `Auth` intentionally does not derive `Debug` (it holds key material), so
304    // `Result<Auth>` can't use `unwrap`/`unwrap_err`. These helpers extract the
305    // success/error sides without requiring `Auth: Debug`.
306    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        // JERRYCAN_SECRET_OLD="SECRET_OLD,SECRET_STRANGER" ⇒ two fallbacks.
322        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        // Both retired secrets became fallbacks: tokens from each still decode.
335        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        // A token from the (current) primary obviously also decodes.
344        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        // Outside prod, length is not enforced (dev convenience), so this builds.
363        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        // Trailing comma / blank entry must not become a (short) fallback key.
370        let auth = Auth::from_env_parts(true, Some(SECRET_NEW), ",  ,")
371            .expect("blank-only retired list is valid in prod");
372        // No fallbacks ⇒ behaves like a single-secret store: an OLD-key token
373        // does NOT decode.
374        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        // Empty JERRYCAN_SECRET_OLD ⇒ no fallbacks, same as with_secret.
384        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    /// The dev-secret fallback must be FAIL-CLOSED: only an unset/empty env or an
406    /// explicit dev marker may use the world-known development key. Any other
407    /// `JERRYCAN_ENV` (a real production spelling, or a typo) is production, so a
408    /// missing secret is an error — never a silent fall back to the insecure key.
409    #[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}