1use std::fmt;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::{Error, ErrorKind, Result};
17use crate::jwt;
18
19pub const PREFERENCE_COOKIES: &[&str] = &["location", "gfc_country", "next-i18next"];
24
25pub 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 #[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#[derive(Clone, Default, Serialize, Deserialize)]
57#[serde(default)]
58pub struct Session {
59 pub cookies: Vec<Cookie>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub hydra_jwt: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub hydra_exp: Option<i64>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub web_app_version: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub payment_type_id: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub user_agent: Option<String>,
75 #[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 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 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 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 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 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 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 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 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 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 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 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 pub fn gfc_session_exp(&self) -> Option<i64> {
264 jwt::exp(self.cookie("gfc_session")?)
265 }
266
267 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 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
298pub 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
309fn 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 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 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 assert_eq!(fresh.cookie("gfc_session"), Some("NEW"));
505 assert!(fresh.cookie("grabid-openid-authn-ck").is_none());
506 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 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 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}