Skip to main content

grabcli_core/
sso.rs

1//! Silent session refresh through the GrabID SSO cookie (`grab auth refresh`).
2//!
3//! `gfc_session` (food.grab.com, 2 h) carries no refresh token, and
4//! `POST /auth/access-token` only echoes the token of a live `gfc_session`
5//! (docs/login.md §1). The only way to a fresh `gfc_session` without a
6//! challenge is the browser's own silent OAuth round trip, done here over
7//! plain HTTP in ONE cookie jar so the PKCE state food stores in its
8//! anonymous `gfc_session` is carried to the callback:
9//!
10//! 1. `GET food.grab.com/auth/login` → 302 to `…/oauth2/authorize` and a new
11//!    anonymous `gfc_session` holding the PKCE verifier/state/nonce.
12//! 2. `GET authorize` with the `.grab.com` SSO cookie
13//!    (`grabid-openid-authn-ck`):
14//!    - 302 to `food.grab.com/auth/callback?code&state` → step 3;
15//!    - 302 to weblogin with `auth_cookie_exists=true` (the "Continue as …"
16//!      screen) → re-issue authorize with `user_selection_done=true&
17//!      remember_me=true`, as that button does (a top-level navigation, no
18//!      captcha involved) → expect the callback;
19//!    - weblogin without `auth_cookie_exists` → the SSO session is dead:
20//!      `auth_expired`, run `grab auth login`.
21//! 3. `GET callback` → food exchanges the code server-side and sets the
22//!    logged-in `gfc_session`.
23//!
24//! GrabMart has its own OAuth client (`mart.grab.com/grab-id/login` →
25//! `authorize` → `mart.grab.com/grab-id/token`); [`silent_login`] runs the
26//! same round trip for it ([`Service::Mart`]). A consent screen
27//! (`weblogin.grab.com/consent`) stops the flow: only the user may approve it.
28//!
29//! No challenge (OTP, push, captcha) can be triggered by this path. Every hop
30//! is checked against a fixed allowlist; redirects are never followed blindly.
31//! Nothing here logs cookie values, codes or tokens.
32
33use 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";
44/// GrabMart's own OAuth client (scope `martweb-protected`): the protected mart
45/// API doesn't accept food's `gfc_session`.
46pub 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/// A web app with its own GrabID OAuth client that the SSO cookie can log in
50/// silently.
51#[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
80/// Cookies the mart login endpoints set that are *not* a session (bookkeeping
81/// for the OAuth round trip and routing).
82const 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
92/// Names of the mart.grab.com session cookies in `session` (whatever the
93/// mart callback set, minus known bookkeeping cookies). Names only.
94pub 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}
105/// The SSO cookie the IdP reads on `authorize`.
106pub const SSO_COOKIE: &str = "grabid-openid-authn-ck";
107
108/// The only URLs the refresh may touch.
109pub 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/// Where a redirect points during the silent round trip.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Hop {
123    /// GrabID `oauth2/authorize`.
124    Authorize,
125    /// The service's callback (`food.grab.com/auth/callback?code=…`,
126    /// `mart.grab.com/grab-id/token?code=…`): the IdP accepted the SSO.
127    Callback,
128    /// The weblogin "Continue as …" screen: the IdP sees a live SSO cookie but
129    /// wants the account picked.
130    WebLoginWithSso,
131    /// The weblogin phone screen: no live SSO, a real login is needed.
132    WebLoginNoSso,
133    /// weblogin's OAuth consent screen: the SSO is fine but the user never
134    /// granted this client (e.g. GrabMart web) access. Needs a person once.
135    Consent,
136    /// food.grab.com past the callback (logged in).
137    Food,
138    Other(String),
139}
140
141pub fn classify_hop(url: &Url) -> Hop {
142    classify_hop_for(Service::Food, url)
143}
144
145/// [`classify_hop`] for `service`'s callback.
146pub 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
171/// `authorize` URL plus what the "Continue as …" button adds.
172pub 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
208/// One cookie jar + a no-redirect client.
209struct Jar<'a> {
210    http: reqwest::Client,
211    session: &'a mut Session,
212    ua: &'a str,
213    allow: Allowlist,
214}
215
216impl Jar<'_> {
217    /// GET `url` (allowlisted, https, grab.com), absorb `Set-Cookie`, return
218    /// the status and the resolved `Location`, if any.
219    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
293/// Run the silent round trip for food. On success `session` holds a
294/// logged-in `gfc_session` (and any re-issued SSO cookies). On failure
295/// `session` is left untouched.
296pub async fn silent_refresh(session: &mut Session, ua: &str) -> Result<()> {
297    silent_login(Service::Food, session, ua).await
298}
299
300/// Run the silent SSO round trip for `service` (see the module docs): its
301/// login URL → GrabID `authorize` (with the SSO cookie) → its callback, all in
302/// one cookie jar. Never sends a challenge. On failure `session` is left
303/// untouched.
304pub 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        // Food's callback is not mart's.
460        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}