Skip to main content

reddb_server/auth/
browser_token.rs

1//! Browser credential layer — the hybrid token model (issue #936, PRD
2//! #930, ADR 0036 §"Connection security", ADR 0029 §Authorization).
3//!
4//! A browser SPA cannot safely hold a long-lived bearer credential: any
5//! token reachable from JavaScript is exfiltrable by an XSS payload. The
6//! hybrid model splits the credential in two:
7//!
8//!   * a **short-lived access JWT** held only in memory (a JS variable),
9//!     presented in the RedWire-over-WSS handshake (ADR 0036) exactly
10//!     where native drivers present a bearer/OAuth-JWT, and
11//!   * a **long-lived refresh token** delivered as an
12//!     `HttpOnly; Secure; SameSite` cookie that JavaScript can never
13//!     read. The browser silently mints a fresh access JWT from it at
14//!     the `/auth/browser/refresh` endpoint.
15//!
16//! Both tokens are HS256 JWTs minted and verified by *this* server with
17//! a single symmetric secret — RedDB is both issuer and verifier, so the
18//! asymmetric RS256/JWKS machinery of [`super::oauth`] (which exists to
19//! trust a *foreign* IdP) is unnecessary weight here. The vetted
20//! `jsonwebtoken` crate owns signature construction and verification;
21//! this module owns the issuer/audience/type/expiry policy on top.
22//!
23//! ## Why access-token rotation does not tear down in-flight streams
24//!
25//! ADR 0029 §Authorization makes the bearer token authenticate only the
26//! *open* of a stream; an internal, unforwarded **stream lease** bound to
27//! the MVCC snapshot pin is the credential consulted for every subsequent
28//! chunk. So when a browser's access JWT expires and it mints a new one
29//! at `/auth/browser/refresh`, the new token is used for the *next*
30//! handshake — the streams already accepted on the live RedWire
31//! connection keep flowing under their leases, untouched. The refresh
32//! cadence is decoupled from result-set delivery time. This module mints
33//! the tokens; that decoupling lives in the stream lease (see
34//! `crate::server::output_stream`) and is exercised end-to-end by the
35//! issue-#936 integration test.
36//!
37//! ## What this module deliberately does not do
38//!
39//! mTLS stays native-only (ADR 0036): browser client certificates are
40//! hostile UX, so there is no browser mTLS path here. The access JWT /
41//! refresh cookie pair is the browser's sole credential.
42//!
43//! ## Refresh tokens are revocable
44//!
45//! Access tokens are deliberately stateless: they live 15 minutes and the
46//! cost of validating one must stay a signature check. Refresh tokens are
47//! not — they live 30 days, and a purely stateless one cannot be taken
48//! back. Logging out, rotating, disabling the account or changing its role
49//! would all leave a valid credential in the wild until natural expiry.
50//!
51//! Every refresh token therefore carries a `jti` recorded in a server-side
52//! live-token registry, and [`BrowserTokenAuthority::validate_refresh`]
53//! refuses a `jti` the registry does not hold. Rotation removes the old
54//! entry as it adds the new one, so a captured cookie stops working the
55//! moment the legitimate browser refreshes. The registry is in memory, so a
56//! restart invalidates outstanding refresh tokens — the same semantics as
57//! the session store, and the safe direction.
58
59use std::collections::{HashMap, HashSet};
60use std::sync::{Arc, Mutex};
61
62use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
63
64use super::Role;
65
66/// Minimum HS256 secret length. RFC 7518 §3.2 requires a key at least as
67/// long as the HMAC output (256 bits / 32 bytes); a shorter key is a
68/// silent downgrade of the signature's security, so we reject it at
69/// construction rather than mint weakly-keyed tokens.
70pub const MIN_SECRET_BYTES: usize = 32;
71
72/// `SameSite` cookie attribute for the refresh cookie. `Strict` is the
73/// secure default — the refresh cookie is never attached to a
74/// cross-site navigation, which is the cleanest CSRF posture for a
75/// same-origin SPA. `Lax`/`None` exist for deployments that serve the
76/// SPA from a different site; `None` *requires* `Secure` (enforced in
77/// [`BrowserTokenConfig::sanitised`]).
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SameSite {
80    Strict,
81    Lax,
82    None,
83}
84
85impl SameSite {
86    pub fn as_str(&self) -> &'static str {
87        match self {
88            SameSite::Strict => "Strict",
89            SameSite::Lax => "Lax",
90            SameSite::None => "None",
91        }
92    }
93}
94
95/// Configuration for the hybrid-token authority. Secure by default:
96/// `Secure` cookies, `SameSite=Strict`, a short access TTL, and an
97/// `HttpOnly` refresh cookie.
98#[derive(Debug, Clone)]
99pub struct BrowserTokenConfig {
100    /// HS256 signing/verification secret. Must be ≥ [`MIN_SECRET_BYTES`].
101    pub secret: Vec<u8>,
102    /// `iss` claim stamped on every token and required on verify.
103    pub issuer: String,
104    /// `aud` claim stamped on every token and required on verify.
105    pub audience: String,
106    /// Access-JWT lifetime, seconds. Short by design (default 15 min):
107    /// the blast radius of a leaked in-memory access token is one TTL.
108    pub access_ttl_secs: i64,
109    /// Refresh-cookie lifetime, seconds (default 30 days). Bounds how
110    /// long a stolen refresh cookie is useful and sets the cookie's
111    /// `Max-Age`.
112    pub refresh_ttl_secs: i64,
113    /// `Secure` attribute on the refresh cookie. Default true — the
114    /// cookie must only ride HTTPS. Tests on a clear-text loopback set
115    /// this false explicitly.
116    pub cookie_secure: bool,
117    /// `SameSite` attribute on the refresh cookie.
118    pub same_site: SameSite,
119    /// Cookie name. Default `reddb_refresh`.
120    pub cookie_name: String,
121    /// Cookie `Path` — scopes which requests carry the refresh cookie.
122    /// Default `/auth/browser`, so it reaches `refresh`/`logout` but no
123    /// other endpoint ever sees it.
124    pub cookie_path: String,
125}
126
127impl BrowserTokenConfig {
128    /// Build a config with secure defaults around an explicit secret.
129    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
130        Self {
131            secret: secret.into(),
132            issuer: "reddb-browser".to_string(),
133            audience: "reddb-redwire".to_string(),
134            access_ttl_secs: 15 * 60,
135            refresh_ttl_secs: 30 * 24 * 60 * 60,
136            cookie_secure: true,
137            same_site: SameSite::Strict,
138            cookie_name: "reddb_refresh".to_string(),
139            cookie_path: "/auth/browser".to_string(),
140        }
141    }
142
143    /// Apply cross-field invariants. `SameSite=None` is meaningless
144    /// without `Secure` (modern browsers reject it), so we force `Secure`
145    /// on rather than mint a cookie the browser will silently drop.
146    fn sanitised(mut self) -> Self {
147        if self.same_site == SameSite::None {
148            self.cookie_secure = true;
149        }
150        self
151    }
152}
153
154/// Mint a unique refresh-token id. 128 bits from the OS CSPRNG: the value
155/// is a registry key rather than a secret (the JWT signature is what
156/// authenticates), but it must not collide across concurrently live tokens.
157fn new_token_id() -> String {
158    let mut bytes = [0u8; 16];
159    crate::crypto::os_random::fill_bytes(&mut bytes)
160        .expect("OS CSPRNG unavailable; refusing to mint a refresh token id");
161    bytes.iter().map(|b| format!("{b:02x}")).collect()
162}
163
164/// The identity carried by a validated browser token.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct BrowserIdentity {
167    pub username: String,
168    pub tenant: Option<String>,
169    pub role: Role,
170}
171
172/// Reasons a token is refused. Kept distinct so the WS handshake and the
173/// refresh endpoint can log *why* without leaking detail to the client.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum BrowserTokenError {
176    /// Signature, issuer, audience, or structural decode failure. The
177    /// string is for server-side logs only.
178    Decode(String),
179    /// A refresh token was presented where an access token was required,
180    /// or vice-versa. A refresh token must never authenticate a session
181    /// directly — it only mints access tokens.
182    WrongType { expected: TokenType, got: String },
183    /// `exp` is at or before now.
184    Expired,
185    /// `nbf` is in the future.
186    NotYetValid,
187    /// The embedded `role` claim is not a known [`Role`].
188    BadRole(String),
189    /// A structurally valid refresh token whose `jti` is no longer live:
190    /// the browser logged out, the token was rotated, or every token for
191    /// the principal was revoked. Distinct from [`Self::Expired`] so logs
192    /// tell "credential withdrawn" apart from "credential aged out".
193    Revoked,
194}
195
196impl std::fmt::Display for BrowserTokenError {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            BrowserTokenError::Decode(m) => write!(f, "token decode failed: {m}"),
200            BrowserTokenError::WrongType { expected, got } => {
201                write!(
202                    f,
203                    "wrong token type: expected {}, got {got:?}",
204                    expected.as_str()
205                )
206            }
207            BrowserTokenError::Expired => write!(f, "token expired"),
208            BrowserTokenError::NotYetValid => write!(f, "token not yet valid"),
209            BrowserTokenError::BadRole(r) => write!(f, "token carries unknown role {r:?}"),
210            BrowserTokenError::Revoked => write!(f, "refresh token has been revoked"),
211        }
212    }
213}
214
215impl std::error::Error for BrowserTokenError {}
216
217/// Which leg of the hybrid pair a token is. Stamped into the `typ` claim
218/// and checked on verify so a refresh token can never be replayed as a
219/// session credential (and an access token can never be replayed at the
220/// refresh endpoint).
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum TokenType {
223    Access,
224    Refresh,
225}
226
227impl TokenType {
228    pub fn as_str(&self) -> &'static str {
229        match self {
230            TokenType::Access => "access",
231            TokenType::Refresh => "refresh",
232        }
233    }
234}
235
236/// JWT claim set. `exp`/`iat` are unix seconds. `typ` discriminates the
237/// pair; `tenant` is omitted entirely when the identity is
238/// platform-scoped so the wire form stays minimal.
239#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
240struct Claims {
241    iss: String,
242    aud: String,
243    sub: String,
244    exp: i64,
245    iat: i64,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    nbf: Option<i64>,
248    typ: String,
249    role: String,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    tenant: Option<String>,
252    /// Unique token id. Present on refresh tokens, which are tracked in the
253    /// live-token registry; absent on access tokens, which are stateless.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    jti: Option<String>,
256}
257
258/// The pair returned to the browser by a successful login / refresh: the
259/// access JWT (held in memory) and the refresh JWT (set as a cookie).
260#[derive(Debug, Clone)]
261pub struct IssuedTokens {
262    pub access_token: String,
263    /// Seconds until the access token expires — the SPA schedules its
264    /// silent refresh a little before this.
265    pub access_expires_in: i64,
266    pub refresh_token: String,
267}
268
269/// Mints and verifies the hybrid-token pair for the browser credential
270/// layer. Cheap to clone the `Arc` the runtime holds; the keys inside are
271/// derived once at construction.
272pub struct BrowserTokenAuthority {
273    config: BrowserTokenConfig,
274    encoding: EncodingKey,
275    decoding: DecodingKey,
276    /// Live refresh tokens: `jti` -> the identity it was minted for. An
277    /// allowlist rather than a revocation list, so anything the server does
278    /// not remember is refused — a restart or an evicted entry fails closed.
279    live_refresh: Arc<Mutex<HashMap<String, RefreshOwner>>>,
280}
281
282/// Who a live refresh token belongs to, so every token for a principal can
283/// be revoked at once when the account is disabled or its role changes.
284#[derive(Debug, Clone, PartialEq, Eq)]
285struct RefreshOwner {
286    username: String,
287    tenant: Option<String>,
288}
289
290impl std::fmt::Debug for BrowserTokenAuthority {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        // Never render the keys.
293        f.debug_struct("BrowserTokenAuthority")
294            .field("issuer", &self.config.issuer)
295            .field("audience", &self.config.audience)
296            .field("access_ttl_secs", &self.config.access_ttl_secs)
297            .field("refresh_ttl_secs", &self.config.refresh_ttl_secs)
298            .finish_non_exhaustive()
299    }
300}
301
302impl BrowserTokenAuthority {
303    /// Construct an authority. Fails if the secret is shorter than
304    /// [`MIN_SECRET_BYTES`] — a weak key is rejected loudly rather than
305    /// silently weakening every token.
306    pub fn new(config: BrowserTokenConfig) -> Result<Self, String> {
307        if config.secret.len() < MIN_SECRET_BYTES {
308            return Err(format!(
309                "browser-token secret must be at least {MIN_SECRET_BYTES} bytes, got {}",
310                config.secret.len()
311            ));
312        }
313        let config = config.sanitised();
314        let encoding = EncodingKey::from_secret(&config.secret);
315        let decoding = DecodingKey::from_secret(&config.secret);
316        Ok(Self {
317            config,
318            encoding,
319            decoding,
320            live_refresh: Arc::new(Mutex::new(HashMap::new())),
321        })
322    }
323
324    pub fn access_ttl_secs(&self) -> i64 {
325        self.config.access_ttl_secs
326    }
327
328    pub fn cookie_name(&self) -> &str {
329        &self.config.cookie_name
330    }
331
332    /// Mint an access + refresh pair for an authenticated identity at
333    /// `now` (unix seconds). Used by `/auth/browser/login`.
334    pub fn issue(&self, identity: &BrowserIdentity, now: i64) -> Result<IssuedTokens, String> {
335        let access_token = self.encode(
336            identity,
337            TokenType::Access,
338            now,
339            self.config.access_ttl_secs,
340        )?;
341        let refresh_token = self.encode(
342            identity,
343            TokenType::Refresh,
344            now,
345            self.config.refresh_ttl_secs,
346        )?;
347        Ok(IssuedTokens {
348            access_token,
349            access_expires_in: self.config.access_ttl_secs,
350            refresh_token,
351        })
352    }
353
354    /// Mint a fresh access token (only) for an identity recovered from a
355    /// valid refresh token at `now`. Used by `/auth/browser/refresh`.
356    pub fn issue_access(&self, identity: &BrowserIdentity, now: i64) -> Result<String, String> {
357        self.encode(
358            identity,
359            TokenType::Access,
360            now,
361            self.config.access_ttl_secs,
362        )
363    }
364
365    fn encode(
366        &self,
367        identity: &BrowserIdentity,
368        typ: TokenType,
369        now: i64,
370        ttl: i64,
371    ) -> Result<String, String> {
372        // Only refresh tokens are tracked; an access token is short-lived
373        // and validating one must stay a pure signature check.
374        let jti = match typ {
375            TokenType::Refresh => Some(new_token_id()),
376            TokenType::Access => None,
377        };
378        let claims = Claims {
379            iss: self.config.issuer.clone(),
380            aud: self.config.audience.clone(),
381            sub: identity.username.clone(),
382            exp: now + ttl,
383            iat: now,
384            nbf: Some(now),
385            typ: typ.as_str().to_string(),
386            role: identity.role.as_str().to_string(),
387            tenant: identity.tenant.clone(),
388            jti: jti.clone(),
389        };
390        let token = encode(&Header::new(Algorithm::HS256), &claims, &self.encoding)
391            .map_err(|e| format!("encode browser token: {e}"))?;
392        if let Some(jti) = jti {
393            self.register_refresh(jti, identity);
394        }
395        Ok(token)
396    }
397
398    /// Record a freshly minted refresh token as live.
399    fn register_refresh(&self, jti: String, identity: &BrowserIdentity) {
400        if let Ok(mut live) = self.live_refresh.lock() {
401            live.insert(
402                jti,
403                RefreshOwner {
404                    username: identity.username.clone(),
405                    tenant: identity.tenant.clone(),
406                },
407            );
408        }
409    }
410
411    /// Revoke one refresh token by the `jti` inside it. Called on logout and
412    /// on rotation, so the cookie the browser just replaced stops working
413    /// even if it was captured in transit.
414    pub fn revoke_refresh_token(&self, token: &str) {
415        if let Some(jti) = self.refresh_token_id(token) {
416            if let Ok(mut live) = self.live_refresh.lock() {
417                live.remove(&jti);
418            }
419        }
420    }
421
422    /// Revoke every refresh token belonging to a principal. Called when the
423    /// account is disabled, deleted, or has its role or password changed —
424    /// the cases where a 30-day credential must not outlive the decision.
425    pub fn revoke_all_for(&self, username: &str, tenant: Option<&str>) {
426        if let Ok(mut live) = self.live_refresh.lock() {
427            live.retain(|_, owner| {
428                !(owner.username == username && owner.tenant.as_deref() == tenant)
429            });
430        }
431    }
432
433    /// Number of live refresh tokens. Test and telemetry helper.
434    pub fn live_refresh_count(&self) -> usize {
435        self.live_refresh.lock().map(|live| live.len()).unwrap_or(0)
436    }
437
438    /// Decode `token` far enough to read its `jti` without enforcing
439    /// expiry — revocation must work on a token that is already expired.
440    fn refresh_token_id(&self, token: &str) -> Option<String> {
441        let mut validation = Validation::new(Algorithm::HS256);
442        validation.set_issuer(&[self.config.issuer.as_str()]);
443        validation.set_audience(&[self.config.audience.as_str()]);
444        validation.validate_exp = false;
445        validation.validate_nbf = false;
446        validation.required_spec_claims = HashSet::new();
447        decode::<Claims>(token, &self.decoding, &validation)
448            .ok()
449            .and_then(|data| data.claims.jti)
450    }
451
452    /// Verify an access token presented in the RedWire WS handshake.
453    pub fn validate_access(
454        &self,
455        token: &str,
456        now: i64,
457    ) -> Result<BrowserIdentity, BrowserTokenError> {
458        self.validate(token, TokenType::Access, now)
459    }
460
461    /// Verify a refresh token presented (as a cookie) at the refresh
462    /// endpoint.
463    pub fn validate_refresh(
464        &self,
465        token: &str,
466        now: i64,
467    ) -> Result<BrowserIdentity, BrowserTokenError> {
468        self.validate(token, TokenType::Refresh, now)
469    }
470
471    /// Signature + issuer + audience are verified by the vetted
472    /// `jsonwebtoken` decode; the temporal checks (`exp`/`nbf`) run here
473    /// against the injected `now` so the clock is testable — mirroring
474    /// the injected-clock pattern of [`super::oauth::OAuthValidator`].
475    fn validate(
476        &self,
477        token: &str,
478        expected: TokenType,
479        now: i64,
480    ) -> Result<BrowserIdentity, BrowserTokenError> {
481        let mut validation = Validation::new(Algorithm::HS256);
482        validation.set_issuer(&[self.config.issuer.as_str()]);
483        validation.set_audience(&[self.config.audience.as_str()]);
484        // We own the clock: disable the library's own exp/nbf checks (it
485        // reads the wall clock, which tests cannot freeze) and enforce
486        // them below against the injected `now`. The signature, `iss`,
487        // and `aud` checks the library *does* run are the security core.
488        validation.validate_exp = false;
489        validation.validate_nbf = false;
490        validation.required_spec_claims = HashSet::new();
491
492        let data = decode::<Claims>(token, &self.decoding, &validation)
493            .map_err(|e| BrowserTokenError::Decode(e.to_string()))?;
494        let claims = data.claims;
495
496        if claims.typ != expected.as_str() {
497            return Err(BrowserTokenError::WrongType {
498                expected,
499                got: claims.typ,
500            });
501        }
502        if now >= claims.exp {
503            return Err(BrowserTokenError::Expired);
504        }
505        if let Some(nbf) = claims.nbf {
506            if now < nbf {
507                return Err(BrowserTokenError::NotYetValid);
508            }
509        }
510        let role = Role::from_str(&claims.role).ok_or(BrowserTokenError::BadRole(claims.role))?;
511        if expected == TokenType::Refresh {
512            // A signature-valid refresh token is not enough: it must still be
513            // one this server considers live. Logout, rotation and account
514            // changes drop the entry, which is what makes those actions
515            // actually revoke the 30-day credential.
516            let live = claims
517                .jti
518                .as_ref()
519                .and_then(|jti| {
520                    self.live_refresh
521                        .lock()
522                        .ok()
523                        .map(|live| live.contains_key(jti))
524                })
525                .unwrap_or(false);
526            if !live {
527                return Err(BrowserTokenError::Revoked);
528            }
529        }
530        Ok(BrowserIdentity {
531            username: claims.sub,
532            tenant: claims.tenant,
533            role,
534        })
535    }
536
537    /// `Set-Cookie` value that installs the refresh token. `HttpOnly`
538    /// (unreadable from JS), plus the configured `Secure`/`SameSite`/
539    /// `Path` and a `Max-Age` matching the refresh TTL.
540    pub fn refresh_cookie(&self, refresh_token: &str) -> String {
541        self.build_cookie(refresh_token, self.config.refresh_ttl_secs)
542    }
543
544    /// `Set-Cookie` value that clears the refresh cookie (logout). Empty
545    /// value, `Max-Age=0`, same attributes so the browser matches and
546    /// evicts it.
547    pub fn clear_cookie(&self) -> String {
548        self.build_cookie("", 0)
549    }
550
551    fn build_cookie(&self, value: &str, max_age: i64) -> String {
552        let mut cookie = format!(
553            "{}={}; HttpOnly; Path={}; Max-Age={}; SameSite={}",
554            self.config.cookie_name,
555            value,
556            self.config.cookie_path,
557            max_age,
558            self.config.same_site.as_str()
559        );
560        if self.config.cookie_secure {
561            cookie.push_str("; Secure");
562        }
563        cookie
564    }
565}
566
567/// Extract a named cookie's value from a raw `Cookie:` request header.
568/// Returns the first match. Cookie values are not URL-decoded — JWT
569/// compact serialization is already cookie-safe (base64url + `.`).
570pub fn cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
571    cookie_header.split(';').find_map(|pair| {
572        let pair = pair.trim();
573        let (k, v) = pair.split_once('=')?;
574        if k.trim() == name {
575            Some(v.trim())
576        } else {
577            None
578        }
579    })
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    const NOW: i64 = 1_750_000_000;
587
588    fn authority() -> BrowserTokenAuthority {
589        let secret = b"0123456789abcdef0123456789abcdef".to_vec();
590        BrowserTokenAuthority::new(BrowserTokenConfig::new(secret)).unwrap()
591    }
592
593    fn identity() -> BrowserIdentity {
594        BrowserIdentity {
595            username: "alice".to_string(),
596            tenant: Some("acme".to_string()),
597            role: Role::Write,
598        }
599    }
600
601    #[test]
602    fn rejects_short_secret() {
603        let err = BrowserTokenAuthority::new(BrowserTokenConfig::new(b"too-short".to_vec()));
604        assert!(err.is_err());
605    }
606
607    #[test]
608    fn issue_then_validate_access_roundtrip() {
609        let auth = authority();
610        let tokens = auth.issue(&identity(), NOW).unwrap();
611        let id = auth
612            .validate_access(&tokens.access_token, NOW + 60)
613            .unwrap();
614        assert_eq!(id, identity());
615        assert_eq!(tokens.access_expires_in, 15 * 60);
616    }
617
618    #[test]
619    fn platform_scoped_identity_has_no_tenant() {
620        let auth = authority();
621        let id = BrowserIdentity {
622            username: "root".to_string(),
623            tenant: None,
624            role: Role::Admin,
625        };
626        let tokens = auth.issue(&id, NOW).unwrap();
627        let got = auth.validate_access(&tokens.access_token, NOW + 1).unwrap();
628        assert_eq!(got.tenant, None);
629        assert_eq!(got.role, Role::Admin);
630    }
631
632    #[test]
633    fn expired_access_token_rejected() {
634        let auth = authority();
635        let tokens = auth.issue(&identity(), NOW).unwrap();
636        // 15-minute TTL; ask at now + 16 minutes.
637        let err = auth
638            .validate_access(&tokens.access_token, NOW + 16 * 60)
639            .unwrap_err();
640        assert_eq!(err, BrowserTokenError::Expired);
641    }
642
643    #[test]
644    fn not_yet_valid_token_rejected() {
645        let auth = authority();
646        let tokens = auth.issue(&identity(), NOW).unwrap();
647        // nbf = NOW; validate at NOW - 10.
648        let err = auth
649            .validate_access(&tokens.access_token, NOW - 10)
650            .unwrap_err();
651        assert_eq!(err, BrowserTokenError::NotYetValid);
652    }
653
654    #[test]
655    fn refresh_token_cannot_authenticate_a_session() {
656        // A refresh token presented where an access token is required
657        // (the WS handshake) must be refused on type, even though its
658        // signature is perfectly valid.
659        let auth = authority();
660        let tokens = auth.issue(&identity(), NOW).unwrap();
661        let err = auth
662            .validate_access(&tokens.refresh_token, NOW + 60)
663            .unwrap_err();
664        assert!(matches!(err, BrowserTokenError::WrongType { .. }));
665    }
666
667    #[test]
668    fn access_token_cannot_be_used_at_refresh_endpoint() {
669        let auth = authority();
670        let tokens = auth.issue(&identity(), NOW).unwrap();
671        let err = auth
672            .validate_refresh(&tokens.access_token, NOW + 60)
673            .unwrap_err();
674        assert!(matches!(err, BrowserTokenError::WrongType { .. }));
675    }
676
677    #[test]
678    fn refresh_validates_and_mints_new_access() {
679        let auth = authority();
680        let tokens = auth.issue(&identity(), NOW).unwrap();
681        // Later, the cookie is replayed to mint a new access token.
682        let later = NOW + 10 * 60;
683        let id = auth.validate_refresh(&tokens.refresh_token, later).unwrap();
684        let new_access = auth.issue_access(&id, later).unwrap();
685        // The freshly-minted access token is valid well past the
686        // original access token's expiry — refresh genuinely extends.
687        let got = auth.validate_access(&new_access, NOW + 20 * 60).unwrap();
688        assert_eq!(got, identity());
689    }
690
691    #[test]
692    fn token_signed_by_a_different_secret_is_rejected() {
693        let auth = authority();
694        let other = BrowserTokenAuthority::new(BrowserTokenConfig::new(
695            b"FEDCBA9876543210FEDCBA9876543210".to_vec(),
696        ))
697        .unwrap();
698        let tokens = other.issue(&identity(), NOW).unwrap();
699        let err = auth
700            .validate_access(&tokens.access_token, NOW + 60)
701            .unwrap_err();
702        assert!(matches!(err, BrowserTokenError::Decode(_)));
703    }
704
705    #[test]
706    fn wrong_audience_rejected() {
707        let auth = authority();
708        let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
709        cfg.audience = "someone-else".to_string();
710        let other = BrowserTokenAuthority::new(cfg).unwrap();
711        let tokens = other.issue(&identity(), NOW).unwrap();
712        let err = auth
713            .validate_access(&tokens.access_token, NOW + 60)
714            .unwrap_err();
715        assert!(matches!(err, BrowserTokenError::Decode(_)));
716    }
717
718    #[test]
719    fn refresh_cookie_carries_security_attributes() {
720        let auth = authority();
721        let cookie = auth.refresh_cookie("the.jwt.value");
722        assert!(cookie.contains("reddb_refresh=the.jwt.value"));
723        assert!(cookie.contains("HttpOnly"));
724        assert!(cookie.contains("Secure"));
725        assert!(cookie.contains("SameSite=Strict"));
726        assert!(cookie.contains("Path=/auth/browser"));
727        assert!(cookie.contains("Max-Age=2592000"));
728    }
729
730    #[test]
731    fn clear_cookie_expires_immediately() {
732        let auth = authority();
733        let cookie = auth.clear_cookie();
734        assert!(cookie.contains("reddb_refresh=;"));
735        assert!(cookie.contains("Max-Age=0"));
736        assert!(cookie.contains("HttpOnly"));
737    }
738
739    #[test]
740    fn samesite_none_forces_secure() {
741        let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
742        cfg.same_site = SameSite::None;
743        cfg.cookie_secure = false; // should be overridden
744        let auth = BrowserTokenAuthority::new(cfg).unwrap();
745        let cookie = auth.refresh_cookie("x");
746        assert!(cookie.contains("SameSite=None"));
747        assert!(cookie.contains("Secure"));
748    }
749
750    #[test]
751    fn cookie_value_extracts_named_cookie() {
752        let header = "other=1; reddb_refresh=abc.def.ghi; theme=dark";
753        assert_eq!(cookie_value(header, "reddb_refresh"), Some("abc.def.ghi"));
754        assert_eq!(cookie_value(header, "missing"), None);
755    }
756
757    #[test]
758    fn cookie_value_handles_single_cookie() {
759        assert_eq!(
760            cookie_value("reddb_refresh=solo", "reddb_refresh"),
761            Some("solo")
762        );
763    }
764}