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