Skip to main content

grabcli_core/
session.rs

1//! The saved Grab web session grabcli reads at runtime.
2//!
3//! It mirrors what a logged-in browser already holds: the site's cookies plus
4//! the short-lived hydra device JWT. grabcli never creates these credentials;
5//! it imports them from a browser the user is already logged into.
6//!
7//! Stored as JSON at [`crate::paths::session_path`] with mode 0600. The
8//! `Debug` impl redacts every value, and no command ever prints a cookie or
9//! token value — only names, expiries and derived status.
10
11use std::fmt;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::{Error, ErrorKind, Result};
17use crate::jwt;
18use crate::paths;
19
20/// Site preference cookies that are not credentials: the saved delivery
21/// address (`location`, URL-encoded JSON), the country and the UI language.
22/// A fresh login (isolated browser context) doesn't have them, so they are
23/// carried over from the previous session.
24pub const PREFERENCE_COOKIES: &[&str] = &["location", "gfc_country", "next-i18next"];
25
26/// The saved delivery address cookie.
27pub const LOCATION_COOKIE: &str = "location";
28
29fn slash() -> String {
30    "/".to_string()
31}
32
33#[derive(Clone, Serialize, Deserialize)]
34pub struct Cookie {
35    pub name: String,
36    pub value: String,
37    pub domain: String,
38    #[serde(default = "slash")]
39    pub path: String,
40    /// Unix seconds; `-1`/`0` for session cookies.
41    #[serde(default)]
42    pub expires: f64,
43}
44
45impl fmt::Debug for Cookie {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        f.debug_struct("Cookie")
48            .field("name", &self.name)
49            .field("value", &"<redacted>")
50            .field("domain", &self.domain)
51            .finish()
52    }
53}
54
55/// The on-disk session document. Field names match the browser export helper
56/// and the Python reference client so an existing `session.json` imports as-is.
57#[derive(Clone, Default, Serialize, Deserialize)]
58#[serde(default)]
59pub struct Session {
60    pub cookies: Vec<Cookie>,
61    /// The hydra device JWT (attached as the `x-hydra-jwt` header).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub hydra_jwt: Option<String>,
64    /// `exp` of the hydra JWT, Unix seconds.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub hydra_exp: Option<i64>,
67    /// The Next.js build id, sent as `x-grab-web-app-version`.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub web_app_version: Option<String>,
70    /// Default payment method id captured from the browser, if any.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub payment_type_id: Option<String>,
73    /// The browser's User-Agent at import time.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub user_agent: Option<String>,
76    /// When the cookies were exported, Unix seconds.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub exported_at: Option<f64>,
79}
80
81impl fmt::Debug for Session {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.debug_struct("Session")
84            .field("cookies", &self.cookies.len())
85            .field("hydra_jwt", &self.hydra_jwt.as_ref().map(|_| "<redacted>"))
86            .field("hydra_exp", &self.hydra_exp)
87            .finish_non_exhaustive()
88    }
89}
90
91impl Session {
92    pub fn load(path: &Path) -> Result<Self> {
93        let bytes = match std::fs::read(path) {
94            Ok(b) => b,
95            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
96                return Err(Error::new(
97                    ErrorKind::AuthMissing,
98                    format!(
99                        "no session at {}: run `grab auth import` (or `--from <session.json>`)",
100                        path.display()
101                    ),
102                ));
103            }
104            Err(e) => return Err(e.into()),
105        };
106        serde_json::from_slice(&bytes).map_err(|e| Error::parse(format!("{}: {e}", path.display())))
107    }
108
109    /// Load from the default/env path.
110    pub fn load_default() -> Result<Self> {
111        Self::load(&paths::session_path()?)
112    }
113
114    pub fn save(&self, path: &Path) -> Result<()> {
115        let data = serde_json::to_vec_pretty(self)?;
116        paths::write_private(path, &data)
117    }
118
119    pub fn save_default(&self) -> Result<()> {
120        self.save(&paths::session_path()?)
121    }
122
123    /// Insert or replace a cookie, matched by name, path and domain (a
124    /// leading dot is ignored for the match, so `.grab.com` and a legacy
125    /// `grab.com` entry are the same cookie). Used to merge `Set-Cookie`s.
126    pub fn upsert_cookie(
127        &mut self,
128        name: &str,
129        value: &str,
130        domain: &str,
131        path: &str,
132        expires: f64,
133    ) {
134        let path = if path.is_empty() { "/" } else { path };
135        let same = |c: &Cookie| {
136            c.name == name
137                && c.path == path
138                && c.domain.trim_start_matches('.') == domain.trim_start_matches('.')
139        };
140        // Drop any duplicates an older parser left behind.
141        let mut seen = false;
142        self.cookies.retain(|c| {
143            if same(c) {
144                let keep = !seen;
145                seen = true;
146                keep
147            } else {
148                true
149            }
150        });
151        if let Some(c) = self.cookies.iter_mut().find(|c| same(c)) {
152            c.value = value.to_string();
153            c.domain = domain.to_string();
154            c.expires = if expires == 0.0 { -1.0 } else { expires };
155        } else {
156            self.cookies.push(Cookie {
157                name: name.to_string(),
158                value: value.to_string(),
159                domain: domain.to_string(),
160                path: path.to_string(),
161                expires: if expires == 0.0 { -1.0 } else { expires },
162            });
163        }
164    }
165
166    /// Remove a cookie (same matching rules as [`upsert_cookie`](Self::upsert_cookie)).
167    pub fn remove_cookie(&mut self, name: &str, domain: &str, path: &str) {
168        self.cookies.retain(|c| {
169            !(c.name == name
170                && c.path == path
171                && c.domain.trim_start_matches('.') == domain.trim_start_matches('.'))
172        });
173    }
174
175    /// Apply one parsed `Set-Cookie` (insert, replace, or delete).
176    pub fn apply_set_cookie(&mut self, c: &crate::http::RawCookie) {
177        if c.deleted {
178            self.remove_cookie(&c.name, &c.domain, &c.path);
179        } else {
180            self.upsert_cookie(&c.name, &c.value, &c.domain, &c.path, c.expires);
181        }
182    }
183
184    /// Copy the [`PREFERENCE_COOKIES`] that this session lacks (by name)
185    /// from `prev`. Never touches auth cookies and never overwrites a
186    /// preference the new session already has. Returns the names copied.
187    pub fn carry_over_preferences(&mut self, prev: &Session) -> Vec<String> {
188        let mut copied = Vec::new();
189        for name in PREFERENCE_COOKIES {
190            if self.cookie(name).is_some() {
191                continue;
192            }
193            let from: Vec<Cookie> = prev
194                .cookies
195                .iter()
196                .filter(|c| c.name == *name)
197                .cloned()
198                .collect();
199            if !from.is_empty() {
200                self.cookies.extend(from);
201                copied.push((*name).to_string());
202            }
203        }
204        copied
205    }
206
207    /// Replace every cookie called `name` with the ones called `name` in
208    /// `source` (e.g. a browser's cookies), leaving all other cookies alone.
209    /// Returns whether `source` had any; if not, nothing changes.
210    pub fn replace_named_cookies(&mut self, name: &str, source: &[Cookie]) -> bool {
211        let new: Vec<Cookie> = source.iter().filter(|c| c.name == name).cloned().collect();
212        if new.is_empty() {
213            return false;
214        }
215        self.cookies.retain(|c| c.name != name);
216        self.cookies.extend(new);
217        true
218    }
219
220    /// Set the `location` cookie's value, keeping the existing cookie's
221    /// domain, path and expiry (or `food.grab.com`, `/`, one year if none).
222    pub fn set_location_cookie(&mut self, value: &str) {
223        let (domain, path, expires) = self
224            .cookies
225            .iter()
226            .find(|c| c.name == LOCATION_COOKIE)
227            .map(|c| (c.domain.clone(), c.path.clone(), c.expires))
228            .unwrap_or_else(|| {
229                let year = (crate::jwt::now() + 365 * 24 * 3600) as f64;
230                ("food.grab.com".to_string(), "/".to_string(), year)
231            });
232        self.cookies.retain(|c| c.name != LOCATION_COOKIE);
233        self.upsert_cookie(LOCATION_COOKIE, value, &domain, &path, expires);
234    }
235
236    /// Value of the first stored cookie called `name` (any domain).
237    pub fn cookie(&self, name: &str) -> Option<&str> {
238        self.cookies
239            .iter()
240            .find(|c| c.name == name)
241            .map(|c| c.value.as_str())
242    }
243
244    /// The `Cookie:` header a browser would send to `url`: only cookies whose
245    /// domain and path match, and that haven't expired. Domain cookies are
246    /// stored with a leading dot (`.grab.com`); others are host-only.
247    pub fn cookie_header_for(&self, url: &str) -> String {
248        let Ok(u) = reqwest::Url::parse(url) else {
249            return String::new();
250        };
251        let host = u.host_str().unwrap_or_default().to_ascii_lowercase();
252        let path = u.path();
253        let now = jwt::now() as f64;
254        self.cookies
255            .iter()
256            .filter(|c| domain_matches(&host, &c.domain))
257            .filter(|c| path_matches(path, &c.path))
258            .filter(|c| c.expires <= 0.0 || c.expires > now)
259            .map(|c| format!("{}={}", c.name, c.value))
260            .collect::<Vec<_>>()
261            .join("; ")
262    }
263
264    /// Whether `gfc_session` is a *logged-in* food session (its JWT carries
265    /// an access token). Before login, food.grab.com/auth/login sets an
266    /// anonymous `gfc_session` that only holds the PKCE verifier and state.
267    pub fn gfc_logged_in(&self) -> bool {
268        self.cookie("gfc_session")
269            .and_then(|t| jwt::payload(t).ok())
270            .and_then(|p| {
271                p.get("accessToken")
272                    .and_then(|v| v.as_str())
273                    .map(|s| !s.is_empty())
274            })
275            .unwrap_or(false)
276    }
277
278    /// The `x-gfc-session` header value: the `sessionKey` claim inside the
279    /// `gfc_session` cookie (a JWT).
280    pub fn gfc_session_key(&self) -> Result<String> {
281        let tok = self.cookie("gfc_session").ok_or_else(|| {
282            Error::new(
283                ErrorKind::AuthExpired,
284                "gfc_session cookie missing: not logged in to food.grab.com; run `grab auth login`",
285            )
286        })?;
287        jwt::payload(tok)?
288            .get("sessionKey")
289            .and_then(|v| v.as_str())
290            .map(ToString::to_string)
291            .ok_or_else(|| Error::parse("gfc_session has no sessionKey claim"))
292    }
293
294    /// Expiry of the gfc_session cookie's embedded access token (Unix seconds).
295    pub fn gfc_session_exp(&self) -> Option<i64> {
296        jwt::exp(self.cookie("gfc_session")?)
297    }
298
299    /// When the food session stops working: the earlier of the
300    /// `gfc_session` cookie's own expiry (`Max-Age=7200`) and its JWT `exp`.
301    pub fn gfc_expires_at(&self) -> Option<i64> {
302        let cookie = self.cookies.iter().find(|c| c.name == "gfc_session")?;
303        let by_cookie = (cookie.expires > 0.0).then_some(cookie.expires as i64);
304        match (by_cookie, jwt::exp(&cookie.value)) {
305            (Some(a), Some(b)) => Some(a.min(b)),
306            (a, b) => a.or(b),
307        }
308    }
309
310    pub fn hydra_valid(&self, skew_secs: i64) -> bool {
311        match (self.hydra_jwt.as_ref(), self.hydra_exp) {
312            (Some(_), Some(exp)) => exp - skew_secs > jwt::now(),
313            _ => false,
314        }
315    }
316}
317
318/// Cookie domain matching (RFC 6265 §5.1.3): `.grab.com` matches
319/// `grab.com` and any subdomain; a host-only `food.grab.com` matches only
320/// itself.
321pub fn domain_matches(host: &str, cookie_domain: &str) -> bool {
322    let d = cookie_domain.to_ascii_lowercase();
323    match d.strip_prefix('.') {
324        Some(bare) => host == bare || host.ends_with(&format!(".{bare}")),
325        None => host == d,
326    }
327}
328
329/// Cookie path matching (RFC 6265 §5.1.4).
330fn path_matches(req_path: &str, cookie_path: &str) -> bool {
331    let cp = if cookie_path.is_empty() {
332        "/"
333    } else {
334        cookie_path
335    };
336    req_path == cp
337        || (req_path.starts_with(cp)
338            && (cp.ends_with('/') || req_path[cp.len()..].starts_with('/')))
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn sample() -> Session {
346        // All synthetic: invented cookie values, not from any real account.
347        let gfc = format!(
348            "{}.{}.sig",
349            jwt::b64url(br#"{"alg":"HS256"}"#),
350            jwt::b64url(br#"{"sessionKey":"SESS-FAKE","exp":1800000000}"#)
351        );
352        Session {
353            cookies: vec![
354                Cookie {
355                    name: "gfc_session".into(),
356                    value: gfc,
357                    domain: "food.grab.com".into(),
358                    path: "/".into(),
359                    expires: 1_800_000_000.0,
360                },
361                Cookie {
362                    name: "location".into(),
363                    value: "%7B%7D".into(),
364                    domain: "food.grab.com".into(),
365                    path: "/".into(),
366                    expires: -1.0,
367                },
368            ],
369            ..Default::default()
370        }
371    }
372
373    #[test]
374    fn reads_gfc_session_key() -> Result<()> {
375        let s = sample();
376        assert_eq!(s.gfc_session_key()?, "SESS-FAKE");
377        assert_eq!(s.gfc_session_exp(), Some(1_800_000_000));
378        Ok(())
379    }
380
381    #[test]
382    fn cookie_header_joins() {
383        let s = sample();
384        let h = s.cookie_header_for("https://food.grab.com/proxy/x");
385        assert!(h.contains("gfc_session="));
386        assert!(h.contains("; location="));
387    }
388
389    fn ck(name: &str, domain: &str, expires: f64) -> Cookie {
390        Cookie {
391            name: name.into(),
392            value: format!("{name}-v"),
393            domain: domain.into(),
394            path: "/".into(),
395            expires,
396        }
397    }
398
399    #[test]
400    fn cookie_header_is_per_host() {
401        let s = Session {
402            cookies: vec![
403                ck("sso", ".grab.com", -1.0),
404                ck("gfc_session", "food.grab.com", -1.0),
405                ck("waf", ".food.grab.com", -1.0),
406                ck("mart_only", "mart.grab.com", -1.0),
407                ck("old", ".grab.com", 1.0),
408            ],
409            ..Default::default()
410        };
411        assert_eq!(
412            s.cookie_header_for("https://food.grab.com/auth/login"),
413            "sso=sso-v; gfc_session=gfc_session-v; waf=waf-v"
414        );
415        assert_eq!(
416            s.cookie_header_for("https://partner-api.grab.com/grabid/v1/x"),
417            "sso=sso-v"
418        );
419        assert_eq!(
420            s.cookie_header_for("https://mart.grab.com/"),
421            "sso=sso-v; mart_only=mart_only-v"
422        );
423        assert_eq!(s.cookie_header_for("https://grab.com.evil.example/"), "");
424        assert_eq!(s.cookie_header_for("https://evilgrab.com/"), "");
425    }
426
427    #[test]
428    fn path_matching() {
429        assert!(path_matches("/auth/login", "/"));
430        assert!(path_matches("/auth/login", "/auth"));
431        assert!(path_matches("/auth", "/auth"));
432        assert!(!path_matches("/authx", "/auth"));
433        assert!(!path_matches("/", "/auth"));
434    }
435
436    #[test]
437    fn set_cookie_replaces_instead_of_duplicating() {
438        let mut s = Session {
439            cookies: vec![ck("passenger_authn_token", ".grab.com", -1.0)],
440            ..Default::default()
441        };
442        let raw = crate::http::parse_set_cookie(
443            "passenger_authn_token=NEW; Domain=.grab.com; Path=/; Max-Age=60",
444            "partner-api.grab.com",
445        )
446        .expect("parse");
447        s.apply_set_cookie(&raw);
448        assert_eq!(s.cookies.len(), 1);
449        assert_eq!(s.cookie("passenger_authn_token"), Some("NEW"));
450        assert_eq!(s.cookies[0].domain, ".grab.com");
451
452        // A legacy dot-stripped duplicate is collapsed too.
453        s.cookies
454            .push(ck("passenger_authn_token", "grab.com", -1.0));
455        s.apply_set_cookie(&raw);
456        assert_eq!(s.cookies.len(), 1);
457    }
458
459    #[test]
460    fn set_cookie_deletes() {
461        let mut s = sample();
462        let del = crate::http::parse_set_cookie(
463            "gfc_session=; Max-Age=0; Path=/; Expires=Sat, 17 Oct 2026 14:58:04 GMT",
464            "food.grab.com",
465        )
466        .expect("parse");
467        s.apply_set_cookie(&del);
468        assert!(s.cookie("gfc_session").is_none());
469        assert!(s.cookie("location").is_some());
470    }
471
472    #[test]
473    fn logged_in_needs_access_token() {
474        let anon = format!(
475            "{}.{}.sig",
476            jwt::b64url(br#"{"alg":"HS256"}"#),
477            jwt::b64url(br#"{"state":"S","codeVerifier":"V","accessToken":""}"#)
478        );
479        let mut s = Session {
480            cookies: vec![Cookie {
481                value: anon,
482                ..ck("gfc_session", "food.grab.com", -1.0)
483            }],
484            ..Default::default()
485        };
486        assert!(!s.gfc_logged_in());
487        s.cookies[0].value = format!(
488            "{}.{}.sig",
489            jwt::b64url(br#"{"alg":"HS256"}"#),
490            jwt::b64url(br#"{"accessToken":"AT","sessionKey":"K"}"#)
491        );
492        assert!(s.gfc_logged_in());
493        assert!(!Session::default().gfc_logged_in());
494    }
495
496    #[test]
497    fn carry_over_copies_only_missing_preferences() {
498        let prev = Session {
499            cookies: vec![
500                ck("location", "food.grab.com", -1.0),
501                ck("gfc_country", "food.grab.com", -1.0),
502                ck("next-i18next", "food.grab.com", -1.0),
503                ck("gfc_session", "food.grab.com", -1.0),
504                ck("grabid-openid-authn-ck", ".grab.com", -1.0),
505            ],
506            ..Default::default()
507        };
508        let mut fresh = Session {
509            cookies: vec![
510                Cookie {
511                    value: "NEW".into(),
512                    ..ck("gfc_session", "food.grab.com", -1.0)
513                },
514                Cookie {
515                    value: "en-new".into(),
516                    ..ck("next-i18next", "food.grab.com", -1.0)
517                },
518            ],
519            ..Default::default()
520        };
521        let copied = fresh.carry_over_preferences(&prev);
522        assert_eq!(copied, vec!["location", "gfc_country"]);
523        // Auth cookies come only from the fresh login.
524        assert_eq!(fresh.cookie("gfc_session"), Some("NEW"));
525        assert!(fresh.cookie("grabid-openid-authn-ck").is_none());
526        // An existing preference is not overwritten.
527        assert_eq!(fresh.cookie("next-i18next"), Some("en-new"));
528        assert_eq!(fresh.cookie("location"), Some("location-v"));
529        assert_eq!(fresh.cookies.len(), 4);
530        // Idempotent.
531        assert!(fresh.carry_over_preferences(&prev).is_empty());
532        assert_eq!(fresh.cookies.len(), 4);
533    }
534
535    #[test]
536    fn carry_over_from_empty_is_noop() {
537        let mut s = sample();
538        let n = s.cookies.len();
539        assert!(s.carry_over_preferences(&Session::default()).is_empty());
540        assert_eq!(s.cookies.len(), n);
541    }
542
543    #[test]
544    fn replace_named_merges_only_that_cookie() {
545        let mut s = Session {
546            cookies: vec![
547                ck("gfc_session", "food.grab.com", -1.0),
548                ck("location", "food.grab.com", -1.0),
549                ck("passenger_authn_token", ".grab.com", -1.0),
550            ],
551            ..Default::default()
552        };
553        let browser = vec![
554            Cookie {
555                value: "BROWSER-LOC".into(),
556                ..ck("location", "food.grab.com", -1.0)
557            },
558            Cookie {
559                value: "BROWSER-GFC".into(),
560                ..ck("gfc_session", "food.grab.com", -1.0)
561            },
562        ];
563        assert!(s.replace_named_cookies(LOCATION_COOKIE, &browser));
564        assert_eq!(s.cookie("location"), Some("BROWSER-LOC"));
565        assert_eq!(s.cookie("gfc_session"), Some("gfc_session-v"));
566        assert_eq!(s.cookies.len(), 3);
567        // A source without the cookie leaves the session unchanged.
568        assert!(!s.replace_named_cookies(LOCATION_COOKIE, &browser[1..]));
569        assert_eq!(s.cookie("location"), Some("BROWSER-LOC"));
570    }
571
572    #[test]
573    fn set_location_keeps_cookie_attrs() {
574        let mut s = Session {
575            cookies: vec![
576                ck("location", "food.grab.com", 1234.0),
577                ck("gfc_session", "food.grab.com", -1.0),
578            ],
579            ..Default::default()
580        };
581        s.set_location_cookie("NEW-LOC");
582        assert_eq!(s.cookie("location"), Some("NEW-LOC"));
583        assert_eq!(s.cookies.len(), 2);
584        let c = s
585            .cookies
586            .iter()
587            .find(|c| c.name == "location")
588            .expect("cookie");
589        assert_eq!((c.domain.as_str(), c.expires), ("food.grab.com", 1234.0));
590
591        let mut empty = Session::default();
592        empty.set_location_cookie("X");
593        let c = &empty.cookies[0];
594        assert_eq!((c.domain.as_str(), c.path.as_str()), ("food.grab.com", "/"));
595        assert!(c.expires > 0.0);
596    }
597
598    #[test]
599    fn debug_redacts() {
600        let s = sample();
601        let d = format!("{s:?}");
602        assert!(!d.contains("SESS-FAKE"));
603    }
604
605    #[test]
606    fn missing_session_is_auth_missing() -> Result<()> {
607        let dir = tempfile::tempdir()?;
608        let e = Session::load(&dir.path().join("none.json")).expect_err("should error");
609        assert_eq!(e.kind, ErrorKind::AuthMissing);
610        Ok(())
611    }
612
613    #[test]
614    fn roundtrip_save_load() -> Result<()> {
615        let dir = tempfile::tempdir()?;
616        let p = dir.path().join("session.json");
617        sample().save(&p)?;
618        let back = Session::load(&p)?;
619        assert_eq!(back.cookies.len(), 2);
620        Ok(())
621    }
622}