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;
12// Third-party ID-token verifier (Sign in with Apple / Google Sign-In). Behind the
13// `idtoken` feature because it pulls jsonwebtoken + an outbound HTTPS client for
14// the JWKS fetch; the base crate gains nothing from these.
15#[cfg(feature = "idtoken")]
16pub mod idtoken;
17pub mod jwt;
18// The mock IdP is a test/eval-only harness that mints deterministic tokens. It
19// needs the oauth types, so it compiles for this crate's OWN tests when oauth is on
20// (`cfg(test)` + `feature = "oauth"`, so `cargo test --features oauth` keeps seeing
21// it) and for downstream code ONLY behind the explicit `mock-idp` feature (which
22// implies `oauth`) — never in a plain `oauth` prod build, where its public
23// `into_app()` would otherwise be reachable.
24#[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
49/// Minimum entropy for `JERRYCAN_SECRET`. Shorter secrets are rejected in prod.
50pub(crate) const MIN_SECRET_LEN: usize = 32;
51
52/// Derive a 32-byte subkey from the master secret and a domain label, so the
53/// session key, the JWT key, and the token-at-rest key are independent even
54/// though one secret seeds all of them.
55///
56/// Returns `Zeroizing<[u8; 32]>` so the derived bytes are wiped from memory when
57/// the value drops. It derefs to `[u8; 32]`, so `&derive_key(..)` coerces to the
58/// `&[u8; 32]` / `&[u8]` that callers (`SessionStore::new`, `jwt::*`) expect.
59pub(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
66/// Whether `JERRYCAN_ENV` names an unmistakably non-production context in which
67/// the insecure built-in dev secret may be used. Only an unset/empty value or an
68/// explicit dev marker qualifies; everything else (incl. any production spelling
69/// or a typo) is treated as production and must supply a real secret. The match
70/// is trimmed + lowercased so `" Prod "`/`PRODUCTION` are still production.
71fn 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/// The auth extension: holds the derived session, token-at-rest, and JWT keys,
79/// registered as a dependency so `Session`/`Bearer` extractors can resolve it.
80///
81/// Each store is rotation-aware (multi-key decrypt): the keys derived from the
82/// *primary* secret encrypt new data, while keys derived from any *retired*
83/// secrets only decrypt pre-rotation data (see [`Auth::with_secrets`]).
84#[derive(Clone)]
85pub struct Auth {
86    sessions: SessionStore,
87    tokens: SessionStore,
88    jwt_key: [u8; 32],
89}
90
91impl Auth {
92    /// Build from an explicit secret (>= 32 bytes recommended), with no retired
93    /// secrets. Equivalent to `with_secrets(secret, &[])`.
94    pub fn with_secret(secret: &str) -> Self {
95        Self::with_secrets(secret, &[])
96    }
97
98    /// Build with key rotation: `primary` encrypts new sessions/tokens; each of
99    /// `retired` can still *decrypt* sessions/tokens minted before rotation but
100    /// is never used to encrypt. Move the previous `JERRYCAN_SECRET` into
101    /// `retired` to rotate without logging users out, then drop it once you want
102    /// its sessions/tokens fully invalidated.
103    pub fn with_secrets(primary: &str, retired: &[&str]) -> Self {
104        // Session and token-at-rest keys: distinct labels keep their ciphertexts
105        // non-cross-decryptable even though one secret seeds both.
106        let session_primary = derive_key(primary.as_bytes(), "session");
107        let token_primary = derive_key(primary.as_bytes(), "oauth-token");
108
109        // Derive fallback key sets from the retired secrets. The `Zeroizing`
110        // wrappers wipe the bytes when these vecs drop at the end of the fn.
111        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        // `SessionStore::with_keys` wants `&[[u8; 32]]`; map through the deref.
120        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    /// Build from `JERRYCAN_SECRET` (primary) plus optional `JERRYCAN_SECRET_OLD`
131    /// (a comma-separated list of retired secrets for key rotation).
132    ///
133    /// The insecure built-in dev key is used ONLY when `JERRYCAN_ENV` is
134    /// unset/empty or an explicit dev marker (`dev`/`development`/`test`/`local`).
135    /// Any other value — including any production spelling (`production`,
136    /// `prod-eu`, …) or a typo — is treated as production: a missing or short
137    /// primary secret is then a loud error (fail closed), and each non-empty
138    /// retired secret must also meet `MIN_SECRET_LEN` (empty entries are skipped —
139    /// `JERRYCAN_SECRET_OLD=""` or a trailing comma is harmless). When
140    /// `JERRYCAN_SECRET_OLD` is unset, behavior is identical to a single secret.
141    pub fn from_env() -> jerrycan_core::Result<Self> {
142        let env = std::env::var("JERRYCAN_ENV").unwrap_or_default();
143        // The insecure dev-secret fallback is allowed ONLY in an unmistakably
144        // non-production context: `JERRYCAN_ENV` unset/empty or an explicit dev
145        // marker. ANY other value — `production`, `prod-eu`, `staging`, a typo —
146        // is treated as production and REQUIRES a real `JERRYCAN_SECRET`, so a
147        // misspelled env can never silently sign sessions with the world-known
148        // development key (fail closed, not open).
149        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    /// The pure core of [`Auth::from_env`]: all the env-var parsing and prod
156    /// validation, parameterized on the raw values so it is testable without
157    /// mutating process-global state (which `#![forbid(unsafe_code)]` + edition
158    /// 2024's `unsafe set_var` makes awkward, and which races under parallel
159    /// tests). `secret` is `JERRYCAN_SECRET`; `retired_raw` is the raw
160    /// `JERRYCAN_SECRET_OLD` string (comma-separated, empties skipped).
161    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    /// The token-at-rest codec (rotation-aware, keyed independently of sessions).
203    /// Encrypt an OAuth `TokenResponse` with `auth.tokens().encode(&t)?` before
204    /// persisting the ciphertext; `decode` on read. Key rotation applies
205    /// automatically, exactly as for sessions.
206    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    // Two real 32+ byte secrets for rotation tests.
240    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        // App encrypts an OAuth token under the OLD secret, persists ciphertext.
256        let before = Auth::with_secret(SECRET_OLD);
257        let ciphertext = before.tokens().encode(&sample_token()).unwrap();
258
259        // Operator rotates JERRYCAN_SECRET to NEW, lists OLD as retired.
260        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        // Token from a secret that is never the primary and never retired.
271        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        // A token ciphertext must NOT decode through the session store...
288        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        // ...and a session ciphertext must NOT decode through the token store.
295        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    // --- from_env parsing/validation ---
303    //
304    // We test `from_env_parts` (the pure core) directly rather than mutating
305    // process-global env vars. Edition 2024 makes `std::env::set_var` `unsafe`,
306    // which `#![forbid(unsafe_code)]` rejects; and env mutation races under
307    // cargo's parallel test threads. Passing the raw values in keeps every
308    // assertion deterministic and exercises the exact logic `from_env` runs.
309    //
310    // `Auth` intentionally does not derive `Debug` (it holds key material), so
311    // `Result<Auth>` can't use `unwrap`/`unwrap_err`. These helpers extract the
312    // success/error sides without requiring `Auth: Debug`.
313    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        // JERRYCAN_SECRET_OLD="SECRET_OLD,SECRET_STRANGER" ⇒ two fallbacks.
329        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        // Both retired secrets became fallbacks: tokens from each still decode.
342        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        // A token from the (current) primary obviously also decodes.
351        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        // Outside prod, length is not enforced (dev convenience), so this builds.
370        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        // Trailing comma / blank entry must not become a (short) fallback key.
377        let auth = Auth::from_env_parts(true, Some(SECRET_NEW), ",  ,")
378            .expect("blank-only retired list is valid in prod");
379        // No fallbacks ⇒ behaves like a single-secret store: an OLD-key token
380        // does NOT decode.
381        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        // Empty JERRYCAN_SECRET_OLD ⇒ no fallbacks, same as with_secret.
391        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    /// The dev-secret fallback must be FAIL-CLOSED: only an unset/empty env or an
413    /// explicit dev marker may use the world-known development key. Any other
414    /// `JERRYCAN_ENV` (a real production spelling, or a typo) is production, so a
415    /// missing secret is an error — never a silent fall back to the insecure key.
416    #[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}