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
43use std::collections::HashSet;
44
45use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
46
47use super::Role;
48
49/// Minimum HS256 secret length. RFC 7518 §3.2 requires a key at least as
50/// long as the HMAC output (256 bits / 32 bytes); a shorter key is a
51/// silent downgrade of the signature's security, so we reject it at
52/// construction rather than mint weakly-keyed tokens.
53pub const MIN_SECRET_BYTES: usize = 32;
54
55/// `SameSite` cookie attribute for the refresh cookie. `Strict` is the
56/// secure default — the refresh cookie is never attached to a
57/// cross-site navigation, which is the cleanest CSRF posture for a
58/// same-origin SPA. `Lax`/`None` exist for deployments that serve the
59/// SPA from a different site; `None` *requires* `Secure` (enforced in
60/// [`BrowserTokenConfig::sanitised`]).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SameSite {
63    Strict,
64    Lax,
65    None,
66}
67
68impl SameSite {
69    pub fn as_str(&self) -> &'static str {
70        match self {
71            SameSite::Strict => "Strict",
72            SameSite::Lax => "Lax",
73            SameSite::None => "None",
74        }
75    }
76}
77
78/// Configuration for the hybrid-token authority. Secure by default:
79/// `Secure` cookies, `SameSite=Strict`, a short access TTL, and an
80/// `HttpOnly` refresh cookie.
81#[derive(Debug, Clone)]
82pub struct BrowserTokenConfig {
83    /// HS256 signing/verification secret. Must be ≥ [`MIN_SECRET_BYTES`].
84    pub secret: Vec<u8>,
85    /// `iss` claim stamped on every token and required on verify.
86    pub issuer: String,
87    /// `aud` claim stamped on every token and required on verify.
88    pub audience: String,
89    /// Access-JWT lifetime, seconds. Short by design (default 15 min):
90    /// the blast radius of a leaked in-memory access token is one TTL.
91    pub access_ttl_secs: i64,
92    /// Refresh-cookie lifetime, seconds (default 30 days). Bounds how
93    /// long a stolen refresh cookie is useful and sets the cookie's
94    /// `Max-Age`.
95    pub refresh_ttl_secs: i64,
96    /// `Secure` attribute on the refresh cookie. Default true — the
97    /// cookie must only ride HTTPS. Tests on a clear-text loopback set
98    /// this false explicitly.
99    pub cookie_secure: bool,
100    /// `SameSite` attribute on the refresh cookie.
101    pub same_site: SameSite,
102    /// Cookie name. Default `reddb_refresh`.
103    pub cookie_name: String,
104    /// Cookie `Path` — scopes which requests carry the refresh cookie.
105    /// Default `/auth/browser`, so it reaches `refresh`/`logout` but no
106    /// other endpoint ever sees it.
107    pub cookie_path: String,
108}
109
110impl BrowserTokenConfig {
111    /// Build a config with secure defaults around an explicit secret.
112    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
113        Self {
114            secret: secret.into(),
115            issuer: "reddb-browser".to_string(),
116            audience: "reddb-redwire".to_string(),
117            access_ttl_secs: 15 * 60,
118            refresh_ttl_secs: 30 * 24 * 60 * 60,
119            cookie_secure: true,
120            same_site: SameSite::Strict,
121            cookie_name: "reddb_refresh".to_string(),
122            cookie_path: "/auth/browser".to_string(),
123        }
124    }
125
126    /// Apply cross-field invariants. `SameSite=None` is meaningless
127    /// without `Secure` (modern browsers reject it), so we force `Secure`
128    /// on rather than mint a cookie the browser will silently drop.
129    fn sanitised(mut self) -> Self {
130        if self.same_site == SameSite::None {
131            self.cookie_secure = true;
132        }
133        self
134    }
135}
136
137/// The identity carried by a validated browser token.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct BrowserIdentity {
140    pub username: String,
141    pub tenant: Option<String>,
142    pub role: Role,
143}
144
145/// Reasons a token is refused. Kept distinct so the WS handshake and the
146/// refresh endpoint can log *why* without leaking detail to the client.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum BrowserTokenError {
149    /// Signature, issuer, audience, or structural decode failure. The
150    /// string is for server-side logs only.
151    Decode(String),
152    /// A refresh token was presented where an access token was required,
153    /// or vice-versa. A refresh token must never authenticate a session
154    /// directly — it only mints access tokens.
155    WrongType { expected: TokenType, got: String },
156    /// `exp` is at or before now.
157    Expired,
158    /// `nbf` is in the future.
159    NotYetValid,
160    /// The embedded `role` claim is not a known [`Role`].
161    BadRole(String),
162}
163
164impl std::fmt::Display for BrowserTokenError {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            BrowserTokenError::Decode(m) => write!(f, "token decode failed: {m}"),
168            BrowserTokenError::WrongType { expected, got } => {
169                write!(
170                    f,
171                    "wrong token type: expected {}, got {got:?}",
172                    expected.as_str()
173                )
174            }
175            BrowserTokenError::Expired => write!(f, "token expired"),
176            BrowserTokenError::NotYetValid => write!(f, "token not yet valid"),
177            BrowserTokenError::BadRole(r) => write!(f, "token carries unknown role {r:?}"),
178        }
179    }
180}
181
182impl std::error::Error for BrowserTokenError {}
183
184/// Which leg of the hybrid pair a token is. Stamped into the `typ` claim
185/// and checked on verify so a refresh token can never be replayed as a
186/// session credential (and an access token can never be replayed at the
187/// refresh endpoint).
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum TokenType {
190    Access,
191    Refresh,
192}
193
194impl TokenType {
195    pub fn as_str(&self) -> &'static str {
196        match self {
197            TokenType::Access => "access",
198            TokenType::Refresh => "refresh",
199        }
200    }
201}
202
203/// JWT claim set. `exp`/`iat` are unix seconds. `typ` discriminates the
204/// pair; `tenant` is omitted entirely when the identity is
205/// platform-scoped so the wire form stays minimal.
206#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
207struct Claims {
208    iss: String,
209    aud: String,
210    sub: String,
211    exp: i64,
212    iat: i64,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    nbf: Option<i64>,
215    typ: String,
216    role: String,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    tenant: Option<String>,
219}
220
221/// The pair returned to the browser by a successful login / refresh: the
222/// access JWT (held in memory) and the refresh JWT (set as a cookie).
223#[derive(Debug, Clone)]
224pub struct IssuedTokens {
225    pub access_token: String,
226    /// Seconds until the access token expires — the SPA schedules its
227    /// silent refresh a little before this.
228    pub access_expires_in: i64,
229    pub refresh_token: String,
230}
231
232/// Mints and verifies the hybrid-token pair for the browser credential
233/// layer. Cheap to clone the `Arc` the runtime holds; the keys inside are
234/// derived once at construction.
235pub struct BrowserTokenAuthority {
236    config: BrowserTokenConfig,
237    encoding: EncodingKey,
238    decoding: DecodingKey,
239}
240
241impl std::fmt::Debug for BrowserTokenAuthority {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        // Never render the keys.
244        f.debug_struct("BrowserTokenAuthority")
245            .field("issuer", &self.config.issuer)
246            .field("audience", &self.config.audience)
247            .field("access_ttl_secs", &self.config.access_ttl_secs)
248            .field("refresh_ttl_secs", &self.config.refresh_ttl_secs)
249            .finish_non_exhaustive()
250    }
251}
252
253impl BrowserTokenAuthority {
254    /// Construct an authority. Fails if the secret is shorter than
255    /// [`MIN_SECRET_BYTES`] — a weak key is rejected loudly rather than
256    /// silently weakening every token.
257    pub fn new(config: BrowserTokenConfig) -> Result<Self, String> {
258        if config.secret.len() < MIN_SECRET_BYTES {
259            return Err(format!(
260                "browser-token secret must be at least {MIN_SECRET_BYTES} bytes, got {}",
261                config.secret.len()
262            ));
263        }
264        let config = config.sanitised();
265        let encoding = EncodingKey::from_secret(&config.secret);
266        let decoding = DecodingKey::from_secret(&config.secret);
267        Ok(Self {
268            config,
269            encoding,
270            decoding,
271        })
272    }
273
274    pub fn access_ttl_secs(&self) -> i64 {
275        self.config.access_ttl_secs
276    }
277
278    pub fn cookie_name(&self) -> &str {
279        &self.config.cookie_name
280    }
281
282    /// Mint an access + refresh pair for an authenticated identity at
283    /// `now` (unix seconds). Used by `/auth/browser/login`.
284    pub fn issue(&self, identity: &BrowserIdentity, now: i64) -> Result<IssuedTokens, String> {
285        let access_token = self.encode(
286            identity,
287            TokenType::Access,
288            now,
289            self.config.access_ttl_secs,
290        )?;
291        let refresh_token = self.encode(
292            identity,
293            TokenType::Refresh,
294            now,
295            self.config.refresh_ttl_secs,
296        )?;
297        Ok(IssuedTokens {
298            access_token,
299            access_expires_in: self.config.access_ttl_secs,
300            refresh_token,
301        })
302    }
303
304    /// Mint a fresh access token (only) for an identity recovered from a
305    /// valid refresh token at `now`. Used by `/auth/browser/refresh`.
306    pub fn issue_access(&self, identity: &BrowserIdentity, now: i64) -> Result<String, String> {
307        self.encode(
308            identity,
309            TokenType::Access,
310            now,
311            self.config.access_ttl_secs,
312        )
313    }
314
315    fn encode(
316        &self,
317        identity: &BrowserIdentity,
318        typ: TokenType,
319        now: i64,
320        ttl: i64,
321    ) -> Result<String, String> {
322        let claims = Claims {
323            iss: self.config.issuer.clone(),
324            aud: self.config.audience.clone(),
325            sub: identity.username.clone(),
326            exp: now + ttl,
327            iat: now,
328            nbf: Some(now),
329            typ: typ.as_str().to_string(),
330            role: identity.role.as_str().to_string(),
331            tenant: identity.tenant.clone(),
332        };
333        encode(&Header::new(Algorithm::HS256), &claims, &self.encoding)
334            .map_err(|e| format!("encode browser token: {e}"))
335    }
336
337    /// Verify an access token presented in the RedWire WS handshake.
338    pub fn validate_access(
339        &self,
340        token: &str,
341        now: i64,
342    ) -> Result<BrowserIdentity, BrowserTokenError> {
343        self.validate(token, TokenType::Access, now)
344    }
345
346    /// Verify a refresh token presented (as a cookie) at the refresh
347    /// endpoint.
348    pub fn validate_refresh(
349        &self,
350        token: &str,
351        now: i64,
352    ) -> Result<BrowserIdentity, BrowserTokenError> {
353        self.validate(token, TokenType::Refresh, now)
354    }
355
356    /// Signature + issuer + audience are verified by the vetted
357    /// `jsonwebtoken` decode; the temporal checks (`exp`/`nbf`) run here
358    /// against the injected `now` so the clock is testable — mirroring
359    /// the injected-clock pattern of [`super::oauth::OAuthValidator`].
360    fn validate(
361        &self,
362        token: &str,
363        expected: TokenType,
364        now: i64,
365    ) -> Result<BrowserIdentity, BrowserTokenError> {
366        let mut validation = Validation::new(Algorithm::HS256);
367        validation.set_issuer(&[self.config.issuer.as_str()]);
368        validation.set_audience(&[self.config.audience.as_str()]);
369        // We own the clock: disable the library's own exp/nbf checks (it
370        // reads the wall clock, which tests cannot freeze) and enforce
371        // them below against the injected `now`. The signature, `iss`,
372        // and `aud` checks the library *does* run are the security core.
373        validation.validate_exp = false;
374        validation.validate_nbf = false;
375        validation.required_spec_claims = HashSet::new();
376
377        let data = decode::<Claims>(token, &self.decoding, &validation)
378            .map_err(|e| BrowserTokenError::Decode(e.to_string()))?;
379        let claims = data.claims;
380
381        if claims.typ != expected.as_str() {
382            return Err(BrowserTokenError::WrongType {
383                expected,
384                got: claims.typ,
385            });
386        }
387        if now >= claims.exp {
388            return Err(BrowserTokenError::Expired);
389        }
390        if let Some(nbf) = claims.nbf {
391            if now < nbf {
392                return Err(BrowserTokenError::NotYetValid);
393            }
394        }
395        let role = Role::from_str(&claims.role).ok_or(BrowserTokenError::BadRole(claims.role))?;
396        Ok(BrowserIdentity {
397            username: claims.sub,
398            tenant: claims.tenant,
399            role,
400        })
401    }
402
403    /// `Set-Cookie` value that installs the refresh token. `HttpOnly`
404    /// (unreadable from JS), plus the configured `Secure`/`SameSite`/
405    /// `Path` and a `Max-Age` matching the refresh TTL.
406    pub fn refresh_cookie(&self, refresh_token: &str) -> String {
407        self.build_cookie(refresh_token, self.config.refresh_ttl_secs)
408    }
409
410    /// `Set-Cookie` value that clears the refresh cookie (logout). Empty
411    /// value, `Max-Age=0`, same attributes so the browser matches and
412    /// evicts it.
413    pub fn clear_cookie(&self) -> String {
414        self.build_cookie("", 0)
415    }
416
417    fn build_cookie(&self, value: &str, max_age: i64) -> String {
418        let mut cookie = format!(
419            "{}={}; HttpOnly; Path={}; Max-Age={}; SameSite={}",
420            self.config.cookie_name,
421            value,
422            self.config.cookie_path,
423            max_age,
424            self.config.same_site.as_str()
425        );
426        if self.config.cookie_secure {
427            cookie.push_str("; Secure");
428        }
429        cookie
430    }
431}
432
433/// Extract a named cookie's value from a raw `Cookie:` request header.
434/// Returns the first match. Cookie values are not URL-decoded — JWT
435/// compact serialization is already cookie-safe (base64url + `.`).
436pub fn cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
437    cookie_header.split(';').find_map(|pair| {
438        let pair = pair.trim();
439        let (k, v) = pair.split_once('=')?;
440        if k.trim() == name {
441            Some(v.trim())
442        } else {
443            None
444        }
445    })
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    const NOW: i64 = 1_750_000_000;
453
454    fn authority() -> BrowserTokenAuthority {
455        let secret = b"0123456789abcdef0123456789abcdef".to_vec();
456        BrowserTokenAuthority::new(BrowserTokenConfig::new(secret)).unwrap()
457    }
458
459    fn identity() -> BrowserIdentity {
460        BrowserIdentity {
461            username: "alice".to_string(),
462            tenant: Some("acme".to_string()),
463            role: Role::Write,
464        }
465    }
466
467    #[test]
468    fn rejects_short_secret() {
469        let err = BrowserTokenAuthority::new(BrowserTokenConfig::new(b"too-short".to_vec()));
470        assert!(err.is_err());
471    }
472
473    #[test]
474    fn issue_then_validate_access_roundtrip() {
475        let auth = authority();
476        let tokens = auth.issue(&identity(), NOW).unwrap();
477        let id = auth
478            .validate_access(&tokens.access_token, NOW + 60)
479            .unwrap();
480        assert_eq!(id, identity());
481        assert_eq!(tokens.access_expires_in, 15 * 60);
482    }
483
484    #[test]
485    fn platform_scoped_identity_has_no_tenant() {
486        let auth = authority();
487        let id = BrowserIdentity {
488            username: "root".to_string(),
489            tenant: None,
490            role: Role::Admin,
491        };
492        let tokens = auth.issue(&id, NOW).unwrap();
493        let got = auth.validate_access(&tokens.access_token, NOW + 1).unwrap();
494        assert_eq!(got.tenant, None);
495        assert_eq!(got.role, Role::Admin);
496    }
497
498    #[test]
499    fn expired_access_token_rejected() {
500        let auth = authority();
501        let tokens = auth.issue(&identity(), NOW).unwrap();
502        // 15-minute TTL; ask at now + 16 minutes.
503        let err = auth
504            .validate_access(&tokens.access_token, NOW + 16 * 60)
505            .unwrap_err();
506        assert_eq!(err, BrowserTokenError::Expired);
507    }
508
509    #[test]
510    fn not_yet_valid_token_rejected() {
511        let auth = authority();
512        let tokens = auth.issue(&identity(), NOW).unwrap();
513        // nbf = NOW; validate at NOW - 10.
514        let err = auth
515            .validate_access(&tokens.access_token, NOW - 10)
516            .unwrap_err();
517        assert_eq!(err, BrowserTokenError::NotYetValid);
518    }
519
520    #[test]
521    fn refresh_token_cannot_authenticate_a_session() {
522        // A refresh token presented where an access token is required
523        // (the WS handshake) must be refused on type, even though its
524        // signature is perfectly valid.
525        let auth = authority();
526        let tokens = auth.issue(&identity(), NOW).unwrap();
527        let err = auth
528            .validate_access(&tokens.refresh_token, NOW + 60)
529            .unwrap_err();
530        assert!(matches!(err, BrowserTokenError::WrongType { .. }));
531    }
532
533    #[test]
534    fn access_token_cannot_be_used_at_refresh_endpoint() {
535        let auth = authority();
536        let tokens = auth.issue(&identity(), NOW).unwrap();
537        let err = auth
538            .validate_refresh(&tokens.access_token, NOW + 60)
539            .unwrap_err();
540        assert!(matches!(err, BrowserTokenError::WrongType { .. }));
541    }
542
543    #[test]
544    fn refresh_validates_and_mints_new_access() {
545        let auth = authority();
546        let tokens = auth.issue(&identity(), NOW).unwrap();
547        // Later, the cookie is replayed to mint a new access token.
548        let later = NOW + 10 * 60;
549        let id = auth.validate_refresh(&tokens.refresh_token, later).unwrap();
550        let new_access = auth.issue_access(&id, later).unwrap();
551        // The freshly-minted access token is valid well past the
552        // original access token's expiry — refresh genuinely extends.
553        let got = auth.validate_access(&new_access, NOW + 20 * 60).unwrap();
554        assert_eq!(got, identity());
555    }
556
557    #[test]
558    fn token_signed_by_a_different_secret_is_rejected() {
559        let auth = authority();
560        let other = BrowserTokenAuthority::new(BrowserTokenConfig::new(
561            b"FEDCBA9876543210FEDCBA9876543210".to_vec(),
562        ))
563        .unwrap();
564        let tokens = other.issue(&identity(), NOW).unwrap();
565        let err = auth
566            .validate_access(&tokens.access_token, NOW + 60)
567            .unwrap_err();
568        assert!(matches!(err, BrowserTokenError::Decode(_)));
569    }
570
571    #[test]
572    fn wrong_audience_rejected() {
573        let auth = authority();
574        let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
575        cfg.audience = "someone-else".to_string();
576        let other = BrowserTokenAuthority::new(cfg).unwrap();
577        let tokens = other.issue(&identity(), NOW).unwrap();
578        let err = auth
579            .validate_access(&tokens.access_token, NOW + 60)
580            .unwrap_err();
581        assert!(matches!(err, BrowserTokenError::Decode(_)));
582    }
583
584    #[test]
585    fn refresh_cookie_carries_security_attributes() {
586        let auth = authority();
587        let cookie = auth.refresh_cookie("the.jwt.value");
588        assert!(cookie.contains("reddb_refresh=the.jwt.value"));
589        assert!(cookie.contains("HttpOnly"));
590        assert!(cookie.contains("Secure"));
591        assert!(cookie.contains("SameSite=Strict"));
592        assert!(cookie.contains("Path=/auth/browser"));
593        assert!(cookie.contains("Max-Age=2592000"));
594    }
595
596    #[test]
597    fn clear_cookie_expires_immediately() {
598        let auth = authority();
599        let cookie = auth.clear_cookie();
600        assert!(cookie.contains("reddb_refresh=;"));
601        assert!(cookie.contains("Max-Age=0"));
602        assert!(cookie.contains("HttpOnly"));
603    }
604
605    #[test]
606    fn samesite_none_forces_secure() {
607        let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
608        cfg.same_site = SameSite::None;
609        cfg.cookie_secure = false; // should be overridden
610        let auth = BrowserTokenAuthority::new(cfg).unwrap();
611        let cookie = auth.refresh_cookie("x");
612        assert!(cookie.contains("SameSite=None"));
613        assert!(cookie.contains("Secure"));
614    }
615
616    #[test]
617    fn cookie_value_extracts_named_cookie() {
618        let header = "other=1; reddb_refresh=abc.def.ghi; theme=dark";
619        assert_eq!(cookie_value(header, "reddb_refresh"), Some("abc.def.ghi"));
620        assert_eq!(cookie_value(header, "missing"), None);
621    }
622
623    #[test]
624    fn cookie_value_handles_single_cookie() {
625        assert_eq!(
626            cookie_value("reddb_refresh=solo", "reddb_refresh"),
627            Some("solo")
628        );
629    }
630}