jerrycan-auth 0.2.0

Authentication extension for the jerrycan framework: argon2 password hashing, encrypted sessions, JWT, role guards. https://jerrycan.cc
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! Authentication for jerrycan: argon2 password hashing, AEAD session cookies,
//! HS256 JWTs, role guards. Vetted RustCrypto primitives; hand-rolled envelopes
//! (see module docs). #![forbid(unsafe_code)].
#![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;
// The mock IdP is a test/eval-only harness that mints deterministic tokens. It
// needs the oauth types, so it compiles for this crate's OWN tests when oauth is on
// (`cfg(test)` + `feature = "oauth"`, so `cargo test --features oauth` keeps seeing
// it) and for downstream code ONLY behind the explicit `mock-idp` feature (which
// implies `oauth`) — never in a plain `oauth` prod build, where its public
// `into_app()` would otherwise be reachable.
#[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;

/// Minimum entropy for `JERRYCAN_SECRET`. Shorter secrets are rejected in prod.
pub(crate) const MIN_SECRET_LEN: usize = 32;

/// Derive a 32-byte subkey from the master secret and a domain label, so the
/// session key, the JWT key, and the token-at-rest key are independent even
/// though one secret seeds all of them.
///
/// Returns `Zeroizing<[u8; 32]>` so the derived bytes are wiped from memory when
/// the value drops. It derefs to `[u8; 32]`, so `&derive_key(..)` coerces to the
/// `&[u8; 32]` / `&[u8]` that callers (`SessionStore::new`, `jwt::*`) expect.
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())
}

/// Whether `JERRYCAN_ENV` names an unmistakably non-production context in which
/// the insecure built-in dev secret may be used. Only an unset/empty value or an
/// explicit dev marker qualifies; everything else (incl. any production spelling
/// or a typo) is treated as production and must supply a real secret. The match
/// is trimmed + lowercased so `" Prod "`/`PRODUCTION` are still production.
fn dev_context_allowed(env: &str) -> bool {
    matches!(
        env.trim().to_ascii_lowercase().as_str(),
        "" | "dev" | "development" | "test" | "local"
    )
}

/// The auth extension: holds the derived session, token-at-rest, and JWT keys,
/// registered as a dependency so `Session`/`Bearer` extractors can resolve it.
///
/// Each store is rotation-aware (multi-key decrypt): the keys derived from the
/// *primary* secret encrypt new data, while keys derived from any *retired*
/// secrets only decrypt pre-rotation data (see [`Auth::with_secrets`]).
#[derive(Clone)]
pub struct Auth {
    sessions: SessionStore,
    tokens: SessionStore,
    jwt_key: [u8; 32],
}

impl Auth {
    /// Build from an explicit secret (>= 32 bytes recommended), with no retired
    /// secrets. Equivalent to `with_secrets(secret, &[])`.
    pub fn with_secret(secret: &str) -> Self {
        Self::with_secrets(secret, &[])
    }

    /// Build with key rotation: `primary` encrypts new sessions/tokens; each of
    /// `retired` can still *decrypt* sessions/tokens minted before rotation but
    /// is never used to encrypt. Move the previous `JERRYCAN_SECRET` into
    /// `retired` to rotate without logging users out, then drop it once you want
    /// its sessions/tokens fully invalidated.
    pub fn with_secrets(primary: &str, retired: &[&str]) -> Self {
        // Session and token-at-rest keys: distinct labels keep their ciphertexts
        // non-cross-decryptable even though one secret seeds both.
        let session_primary = derive_key(primary.as_bytes(), "session");
        let token_primary = derive_key(primary.as_bytes(), "oauth-token");

        // Derive fallback key sets from the retired secrets. The `Zeroizing`
        // wrappers wipe the bytes when these vecs drop at the end of the fn.
        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();
        // `SessionStore::with_keys` wants `&[[u8; 32]]`; map through the deref.
        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"),
        }
    }

    /// Build from `JERRYCAN_SECRET` (primary) plus optional `JERRYCAN_SECRET_OLD`
    /// (a comma-separated list of retired secrets for key rotation).
    ///
    /// The insecure built-in dev key is used ONLY when `JERRYCAN_ENV` is
    /// unset/empty or an explicit dev marker (`dev`/`development`/`test`/`local`).
    /// Any other value — including any production spelling (`production`,
    /// `prod-eu`, …) or a typo — is treated as production: a missing or short
    /// primary secret is then a loud error (fail closed), and each non-empty
    /// retired secret must also meet `MIN_SECRET_LEN` (empty entries are skipped —
    /// `JERRYCAN_SECRET_OLD=""` or a trailing comma is harmless). When
    /// `JERRYCAN_SECRET_OLD` is unset, behavior is identical to a single secret.
    pub fn from_env() -> jerrycan_core::Result<Self> {
        let env = std::env::var("JERRYCAN_ENV").unwrap_or_default();
        // The insecure dev-secret fallback is allowed ONLY in an unmistakably
        // non-production context: `JERRYCAN_ENV` unset/empty or an explicit dev
        // marker. ANY other value — `production`, `prod-eu`, `staging`, a typo —
        // is treated as production and REQUIRES a real `JERRYCAN_SECRET`, so a
        // misspelled env can never silently sign sessions with the world-known
        // development key (fail closed, not open).
        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)
    }

    /// The pure core of [`Auth::from_env`]: all the env-var parsing and prod
    /// validation, parameterized on the raw values so it is testable without
    /// mutating process-global state (which `#![forbid(unsafe_code)]` + edition
    /// 2024's `unsafe set_var` makes awkward, and which races under parallel
    /// tests). `secret` is `JERRYCAN_SECRET`; `retired_raw` is the raw
    /// `JERRYCAN_SECRET_OLD` string (comma-separated, empties skipped).
    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
    }

    /// The token-at-rest codec (rotation-aware, keyed independently of sessions).
    /// Encrypt an OAuth `TokenResponse` with `auth.tokens().encode(&t)?` before
    /// persisting the ciphertext; `decode` on read. Key rotation applies
    /// automatically, exactly as for 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(),
        }
    }

    // Two real 32+ byte secrets for rotation tests.
    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() {
        // App encrypts an OAuth token under the OLD secret, persists ciphertext.
        let before = Auth::with_secret(SECRET_OLD);
        let ciphertext = before.tokens().encode(&sample_token()).unwrap();

        // Operator rotates JERRYCAN_SECRET to NEW, lists OLD as retired.
        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() {
        // Token from a secret that is never the primary and never retired.
        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);

        // A token ciphertext must NOT decode through the session store...
        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"
        );

        // ...and a session ciphertext must NOT decode through the token store.
        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"
        );
    }

    // --- from_env parsing/validation ---
    //
    // We test `from_env_parts` (the pure core) directly rather than mutating
    // process-global env vars. Edition 2024 makes `std::env::set_var` `unsafe`,
    // which `#![forbid(unsafe_code)]` rejects; and env mutation races under
    // cargo's parallel test threads. Passing the raw values in keeps every
    // assertion deterministic and exercises the exact logic `from_env` runs.
    //
    // `Auth` intentionally does not derive `Debug` (it holds key material), so
    // `Result<Auth>` can't use `unwrap`/`unwrap_err`. These helpers extract the
    // success/error sides without requiring `Auth: Debug`.
    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() {
        // JERRYCAN_SECRET_OLD="SECRET_OLD,SECRET_STRANGER" ⇒ two fallbacks.
        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));

        // Both retired secrets became fallbacks: tokens from each still decode.
        assert_eq!(
            auth.tokens().decode::<Tok>(&token_a).unwrap(),
            sample_token()
        );
        assert_eq!(
            auth.tokens().decode::<Tok>(&token_b).unwrap(),
            sample_token()
        );
        // A token from the (current) primary obviously also decodes.
        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() {
        // Outside prod, length is not enforced (dev convenience), so this builds.
        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() {
        // Trailing comma / blank entry must not become a (short) fallback key.
        let auth = Auth::from_env_parts(true, Some(SECRET_NEW), ",  ,")
            .expect("blank-only retired list is valid in prod");
        // No fallbacks ⇒ behaves like a single-secret store: an OLD-key token
        // does NOT decode.
        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() {
        // Empty JERRYCAN_SECRET_OLD ⇒ no fallbacks, same as with_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"));
    }

    /// The dev-secret fallback must be FAIL-CLOSED: only an unset/empty env or an
    /// explicit dev marker may use the world-known development key. Any other
    /// `JERRYCAN_ENV` (a real production spelling, or a typo) is production, so a
    /// missing secret is an error — never a silent fall back to the insecure key.
    #[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)"
            );
        }
    }
}