1use std::time::Duration;
34
35use reqwest::Url;
36use reqwest::header::{HeaderMap, HeaderValue};
37
38use crate::error::{Error, ErrorKind, Result};
39use crate::http::{Allowlist, parse_set_cookie};
40use crate::session::Session;
41
42pub const FOOD_LOGIN: &str = "https://food.grab.com/auth/login";
43pub const FOOD_CALLBACK: &str = "https://food.grab.com/auth/callback";
44pub const MART_LOGIN: &str = "https://mart.grab.com/grab-id/login";
47pub const MART_CALLBACK: &str = "https://mart.grab.com/grab-id/token";
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Service {
53 Food,
54 Mart,
55}
56
57impl Service {
58 pub fn login_url(self) -> &'static str {
59 match self {
60 Self::Food => FOOD_LOGIN,
61 Self::Mart => MART_LOGIN,
62 }
63 }
64
65 pub fn callback_url(self) -> &'static str {
66 match self {
67 Self::Food => FOOD_CALLBACK,
68 Self::Mart => MART_CALLBACK,
69 }
70 }
71
72 fn host(self) -> &'static str {
73 match self {
74 Self::Food => "food.grab.com",
75 Self::Mart => "mart.grab.com",
76 }
77 }
78}
79
80const MART_NON_SESSION: &[&str] = &[
83 "x_forwarded_host",
84 "grabid_login_params",
85 "subpath",
86 "last_supported_subpath",
87 "user_place",
88 "_dd_s",
89 "_gsvid",
90];
91
92pub fn mart_session_cookies(session: &Session) -> Vec<&str> {
95 let now = crate::jwt::now() as f64;
96 session
97 .cookies
98 .iter()
99 .filter(|c| c.domain.trim_start_matches('.') == "mart.grab.com")
100 .filter(|c| !MART_NON_SESSION.contains(&c.name.as_str()))
101 .filter(|c| c.expires <= 0.0 || c.expires > now)
102 .map(|c| c.name.as_str())
103 .collect()
104}
105pub const SSO_COOKIE: &str = "grabid-openid-authn-ck";
107
108pub fn allowlist() -> Allowlist {
110 let mut a = Allowlist::new();
111 a.allow_exact(FOOD_LOGIN)
112 .allow_exact(FOOD_CALLBACK)
113 .allow_exact(MART_LOGIN)
114 .allow_exact(MART_CALLBACK)
115 .allow_exact("https://partner-api.grab.com/grabid/v1/oauth2/authorize")
116 .allow_exact("https://api.grab.com/grabid/v1/oauth2/authorize");
117 a
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Hop {
123 Authorize,
125 Callback,
128 WebLoginWithSso,
131 WebLoginNoSso,
133 Consent,
136 Food,
138 Other(String),
139}
140
141pub fn classify_hop(url: &Url) -> Hop {
142 classify_hop_for(Service::Food, url)
143}
144
145pub fn classify_hop_for(service: Service, url: &Url) -> Hop {
147 let host = url.host_str().unwrap_or_default();
148 let path = url.path();
149 if service == Service::Mart {
150 if host == "mart.grab.com" && path == "/grab-id/token" {
151 return Hop::Callback;
152 }
153 if host == "mart.grab.com" || host == "food.grab.com" {
154 return Hop::Other(format!("{host}{path}"));
155 }
156 }
157 let has = |k: &str, v: &str| url.query_pairs().any(|(a, b)| a == k && b == v);
158 match host {
159 "partner-api.grab.com" | "api.grab.com" if path == "/grabid/v1/oauth2/authorize" => {
160 Hop::Authorize
161 }
162 "food.grab.com" if path == "/auth/callback" => Hop::Callback,
163 "food.grab.com" if !path.starts_with("/auth/") => Hop::Food,
164 "weblogin.grab.com" if path.starts_with("/consent") => Hop::Consent,
165 "weblogin.grab.com" if has("auth_cookie_exists", "true") => Hop::WebLoginWithSso,
166 "weblogin.grab.com" => Hop::WebLoginNoSso,
167 _ => Hop::Other(format!("{host}{path}")),
168 }
169}
170
171pub fn with_account_selected(authorize: &Url) -> Url {
173 let mut u = authorize.clone();
174 let keep: Vec<(String, String)> = u
175 .query_pairs()
176 .filter(|(k, _)| k != "user_selection_done" && k != "remember_me")
177 .map(|(k, v)| (k.into_owned(), v.into_owned()))
178 .collect();
179 u.query_pairs_mut()
180 .clear()
181 .extend_pairs(keep)
182 .append_pair("user_selection_done", "true")
183 .append_pair("remember_me", "true");
184 u
185}
186
187fn dead_sso() -> Error {
188 Error::new(
189 ErrorKind::AuthExpired,
190 "the Grab SSO login is no longer valid (expired or logged out), so the session can't be \
191 refreshed silently; log in again with `grab auth login --phone +63…` (preview it with --dry-run first)",
192 )
193}
194
195fn consent_needed(service: Service) -> Error {
196 Error::new(
197 ErrorKind::AuthMissing,
198 format!(
199 "Grab wants a one-time consent before {host} can use this login (the SSO itself is \
200 fine). In the logged-in browser, open {login} and approve it, then retry. grabcli \
201 never approves consent screens for you.",
202 host = service.host(),
203 login = service.login_url(),
204 ),
205 )
206}
207
208struct Jar<'a> {
210 http: reqwest::Client,
211 session: &'a mut Session,
212 ua: &'a str,
213 allow: Allowlist,
214}
215
216impl Jar<'_> {
217 async fn hop(&mut self, url: &Url) -> Result<(u16, Option<Url>)> {
220 if url.scheme() != "https" || !self.allow.is_allowed(url.as_str()) {
221 return Err(Error::new(
222 ErrorKind::UrlNotAllowed,
223 format!(
224 "refresh refused to follow {}{}",
225 url.host_str().unwrap_or_default(),
226 url.path()
227 ),
228 ));
229 }
230 let mut h = HeaderMap::new();
231 let mut put = |k: &'static str, v: &str| {
232 if let Ok(v) = HeaderValue::from_str(v) {
233 h.insert(k, v);
234 }
235 };
236 put("user-agent", self.ua);
237 put("accept", "text/html,application/xhtml+xml,*/*;q=0.8");
238 put("accept-language", "en");
239 let cookie = self.session.cookie_header_for(url.as_str());
240 if !cookie.is_empty() {
241 put("cookie", &cookie);
242 }
243 let resp = self
244 .http
245 .get(url.clone())
246 .headers(h)
247 .send()
248 .await
249 .map_err(|e| Error::new(ErrorKind::Network, format!("refresh: {e}")))?;
250 let host = url.host_str().unwrap_or_default().to_string();
251 for raw in resp
252 .headers()
253 .get_all(reqwest::header::SET_COOKIE)
254 .iter()
255 .filter_map(|v| v.to_str().ok())
256 {
257 if let Some(c) = parse_set_cookie(raw, &host) {
258 self.session.apply_set_cookie(&c);
259 }
260 }
261 let status = resp.status().as_u16();
262 if std::env::var_os("GRABCLI_TRACE_SSO").is_some() {
263 let keys: Vec<String> = resp
264 .headers()
265 .get(reqwest::header::LOCATION)
266 .and_then(|v| v.to_str().ok())
267 .and_then(|l| url.join(l).ok())
268 .map(|u| {
269 let q: Vec<String> = u.query_pairs().map(|(k, _)| k.into_owned()).collect();
270 vec![format!(
271 "{}{} [{}]",
272 u.host_str().unwrap_or_default(),
273 u.path(),
274 q.join(",")
275 )]
276 })
277 .unwrap_or_default();
278 eprintln!(
279 "TRACE {}{} -> {status} {keys:?}",
280 url.host_str().unwrap_or_default(),
281 url.path()
282 );
283 }
284 let loc = resp
285 .headers()
286 .get(reqwest::header::LOCATION)
287 .and_then(|v| v.to_str().ok())
288 .and_then(|l| url.join(l).ok());
289 Ok((status, loc))
290 }
291}
292
293pub async fn silent_refresh(session: &mut Session, ua: &str) -> Result<()> {
297 silent_login(Service::Food, session, ua).await
298}
299
300pub async fn silent_login(service: Service, session: &mut Session, ua: &str) -> Result<()> {
305 let has_sso = session.cookies.iter().any(|c| {
306 c.name == SSO_COOKIE && (c.expires <= 0.0 || c.expires > crate::jwt::now() as f64)
307 });
308 if !has_sso {
309 return Err(Error::new(
310 ErrorKind::AuthExpired,
311 "no Grab SSO cookie in the session, so it can't be refreshed silently; log in with `grab auth login --phone +63…` (preview it with --dry-run first)",
312 ));
313 }
314 let http = reqwest::Client::builder()
315 .redirect(reqwest::redirect::Policy::none())
316 .timeout(Duration::from_secs(20))
317 .gzip(true)
318 .build()
319 .map_err(|e| Error::new(ErrorKind::Network, e.to_string()))?;
320 let mut work = session.clone();
321 let mut jar = Jar {
322 http,
323 session: &mut work,
324 ua,
325 allow: allowlist(),
326 };
327 let classify = |u: &Url| classify_hop_for(service, u);
328
329 let start = Url::parse(service.login_url()).map_err(|e| Error::parse(e.to_string()))?;
330 let (_, loc) = jar.hop(&start).await?;
331 let authorize = loc
332 .filter(|u| classify(u) == Hop::Authorize)
333 .ok_or_else(|| {
334 Error::new(
335 ErrorKind::Api,
336 format!("{} login did not redirect to GrabID", service.host()),
337 )
338 })?;
339
340 let (_, loc) = jar.hop(&authorize).await?;
341 let mut next =
342 loc.ok_or_else(|| Error::new(ErrorKind::Api, "GrabID authorize did not redirect"))?;
343 match classify(&next) {
344 Hop::Callback => {}
345 Hop::WebLoginWithSso => {
346 let (_, loc) = jar.hop(&with_account_selected(&authorize)).await?;
347 next = loc.ok_or_else(dead_sso)?;
348 match classify(&next) {
349 Hop::Callback => {}
350 Hop::Consent => return Err(consent_needed(service)),
351 _ => return Err(dead_sso()),
352 }
353 }
354 Hop::WebLoginNoSso => return Err(dead_sso()),
355 Hop::Consent => return Err(consent_needed(service)),
356 other => {
357 return Err(Error::new(
358 ErrorKind::Api,
359 format!("GrabID authorize redirected somewhere unexpected ({other:?})"),
360 ));
361 }
362 }
363
364 let (status, loc) = jar.hop(&next).await?;
365 let landed = loc.as_ref().map(classify);
366 let ok = match service {
367 Service::Food => jar.session.gfc_logged_in(),
368 Service::Mart => {
369 (200..400).contains(&status)
370 && !mart_session_cookies(jar.session).is_empty()
371 && !loc.as_ref().is_some_and(|u| {
372 u.path().starts_with("/grab-id/") || u.path().contains("login-fail")
373 })
374 }
375 };
376 if !ok {
377 return Err(Error::new(
378 ErrorKind::AuthExpired,
379 format!(
380 "{} {} did not log in (HTTP {status}, next {landed:?}); run `grab auth login`",
381 service.host(),
382 Url::parse(service.callback_url())
383 .map(|u| u.path().to_string())
384 .unwrap_or_default(),
385 ),
386 ));
387 }
388 *session = work;
389 Ok(())
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn u(s: &str) -> Url {
397 Url::parse(s).expect("test url")
398 }
399
400 #[test]
401 fn classifies_hops() {
402 assert_eq!(
403 classify_hop(&u(
404 "https://partner-api.grab.com/grabid/v1/oauth2/authorize?client_id=x"
405 )),
406 Hop::Authorize
407 );
408 assert_eq!(
409 classify_hop(&u("https://food.grab.com/auth/callback?code=C&state=S")),
410 Hop::Callback
411 );
412 assert_eq!(
413 classify_hop(&u(
414 "https://weblogin.grab.com/passenger/login?ctx_id=1&auth_cookie_exists=true"
415 )),
416 Hop::WebLoginWithSso
417 );
418 assert_eq!(
419 classify_hop(&u("https://weblogin.grab.com/passenger/login?ctx_id=1")),
420 Hop::WebLoginNoSso
421 );
422 assert_eq!(classify_hop(&u("https://food.grab.com/ph/en/")), Hop::Food);
423 assert!(matches!(
424 classify_hop(&u("https://evil.example/auth/callback")),
425 Hop::Other(_)
426 ));
427 }
428
429 #[test]
430 fn account_selection_params_are_added_once() {
431 let a = u(
432 "https://partner-api.grab.com/grabid/v1/oauth2/authorize?client_id=c&state=s&remember_me=false",
433 );
434 let b = with_account_selected(&a);
435 let pairs: Vec<(String, String)> = b
436 .query_pairs()
437 .map(|(k, v)| (k.into_owned(), v.into_owned()))
438 .collect();
439 assert!(pairs.contains(&("client_id".into(), "c".into())));
440 assert!(pairs.contains(&("state".into(), "s".into())));
441 assert_eq!(pairs.iter().filter(|(k, _)| k == "remember_me").count(), 1);
442 assert!(pairs.contains(&("remember_me".into(), "true".into())));
443 assert!(pairs.contains(&("user_selection_done".into(), "true".into())));
444 }
445
446 #[test]
447 fn classifies_mart_hops() {
448 let m = |s: &str| classify_hop_for(Service::Mart, &u(s));
449 assert_eq!(
450 m("https://mart.grab.com/grab-id/token?code=C&state=S"),
451 Hop::Callback
452 );
453 assert_eq!(
454 m("https://partner-api.grab.com/grabid/v1/oauth2/authorize?client_id=x"),
455 Hop::Authorize
456 );
457 assert!(matches!(m("https://mart.grab.com/ph/en"), Hop::Other(_)));
458 assert_eq!(m("https://weblogin.grab.com/consent"), Hop::Consent);
459 assert!(matches!(
461 m("https://food.grab.com/auth/callback?code=C"),
462 Hop::Other(_)
463 ));
464 assert_ne!(
465 classify_hop(&u("https://mart.grab.com/grab-id/token?code=C")),
466 Hop::Callback
467 );
468 }
469
470 #[test]
471 fn mart_session_cookie_names_skip_bookkeeping() {
472 let ck = |name: &str, domain: &str| crate::session::Cookie {
473 name: name.into(),
474 value: "v".into(),
475 domain: domain.into(),
476 path: "/".into(),
477 expires: -1.0,
478 };
479 let s = Session {
480 cookies: vec![
481 ck("x_forwarded_host", "mart.grab.com"),
482 ck("grabid_login_params", "mart.grab.com"),
483 ck("gfc_session", "food.grab.com"),
484 ],
485 ..Default::default()
486 };
487 assert!(mart_session_cookies(&s).is_empty());
488 let mut s2 = s.clone();
489 s2.cookies.push(ck("mart_session", "mart.grab.com"));
490 assert_eq!(mart_session_cookies(&s2), vec!["mart_session"]);
491 }
492
493 #[test]
494 fn allowlist_is_tight() {
495 let a = allowlist();
496 assert!(a.is_allowed("https://food.grab.com/auth/callback?code=1"));
497 assert!(a.is_allowed("https://partner-api.grab.com/grabid/v1/oauth2/authorize?x=1"));
498 assert!(a.is_allowed("https://mart.grab.com/grab-id/token?code=1"));
499 assert!(!a.is_allowed("https://mart.grab.com/grab-id/logout"));
500 assert!(!a.is_allowed("https://food.grab.com/auth/logout"));
501 assert!(!a.is_allowed("https://partner-api.grab.com/grabid/v1/authnv4/login"));
502 assert!(!a.is_allowed("https://partner-api.grab.com/grabid/v1/oauth2/logout"));
503 }
504
505 #[tokio::test]
506 async fn refuses_without_sso_cookie() {
507 let mut s = Session::default();
508 let e = silent_refresh(&mut s, "ua").await.err().map(|e| e.kind);
509 assert_eq!(e, Some(ErrorKind::AuthExpired));
510 }
511}