1use 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
20pub const PREFERENCE_COOKIES: &[&str] = &["location", "gfc_country", "next-i18next"];
25
26pub 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 #[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#[derive(Clone, Default, Serialize, Deserialize)]
58#[serde(default)]
59pub struct Session {
60 pub cookies: Vec<Cookie>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub hydra_jwt: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub hydra_exp: Option<i64>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub web_app_version: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub payment_type_id: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub user_agent: Option<String>,
76 #[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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn gfc_session_exp(&self) -> Option<i64> {
296 jwt::exp(self.cookie("gfc_session")?)
297 }
298
299 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
318pub 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
329fn 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 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 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 assert_eq!(fresh.cookie("gfc_session"), Some("NEW"));
525 assert!(fresh.cookie("grabid-openid-authn-ck").is_none());
526 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 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 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}