Skip to main content

grabcli_service/
login.rs

1//! Logins in progress: one driver thread per login, one browser step at a time (start with the phone → a code or
2//! the app's approval → done). The browser polls [`Logins::get`]; a login
3//! nobody polled for [`Timing::abandon_ms`] is cancelled, as is one the
4//! person cancels. A login's id is minted here and bound to the person
5//! and the org that started it: another's is "not found".
6//!
7//! `person` and `org` are the embedder's plain keys: who is logging in
8//! (one person may run several logins) and which Grab account it connects
9//! (one login at a time per org, and a code-send budget per org).
10//!
11//! The phone and the code are never kept: the phone goes straight to the
12//! driver thread, the code waits in memory only until the driver takes it,
13//! and neither is logged.
14
15use alloc::sync::Arc;
16use core::time::Duration;
17use std::collections::HashMap;
18use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
19
20use super::api::{LoginDriver, Step};
21use crate::{Log, Secret};
22
23/// The clocks and limits (the tests shrink them).
24#[derive(Debug, Clone)]
25pub struct Timing {
26    /// A login not polled for this long is cancelled.
27    pub abandon_ms: i64,
28    /// A finished login stays visible this long.
29    pub keep_ms: i64,
30    /// One wait for the app's approval.
31    pub approval_wait: Duration,
32    /// How long a login may wait for the app at all.
33    pub approval_ms: i64,
34    /// Between two code sends of one org.
35    pub send_cooldown_s: i64,
36    /// Code sends per org per hour.
37    pub sends_per_hour: usize,
38}
39
40impl Default for Timing {
41    fn default() -> Self {
42        Self {
43            abandon_ms: 120_000,
44            keep_ms: 600_000,
45            approval_wait: Duration::from_secs(5),
46            approval_ms: 300_000,
47            send_cooldown_s: 60,
48            sends_per_hour: 3,
49        }
50    }
51}
52
53/// Where a login is.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum View {
56    /// The browser is on its way to Grab's login.
57    Starting,
58    /// Enter the code Grab sent (`channel`).
59    Code {
60        /// How Grab sent it (sms, whatsapp, ...).
61        channel: String,
62    },
63    /// Approve it in the Grab app.
64    Approve,
65    /// Connected.
66    Done {
67        /// How the account is shown: for the real backend, grabcli's masked
68        /// phone (`+1••••••••00`: the last two digits shown).
69        display_name: String,
70    },
71    /// It ended without a session.
72    Failed {
73        /// Why (no secrets: the login's phone is redacted from it).
74        message: String,
75    },
76    /// Its person cancelled it.
77    Cancelled,
78    /// Nobody polled it.
79    Expired,
80}
81
82impl View {
83    /// Whether it ended (done, failed, cancelled or expired).
84    pub fn finished(&self) -> bool {
85        matches!(
86            self,
87            Self::Done { .. } | Self::Failed { .. } | Self::Cancelled | Self::Expired
88        )
89    }
90
91    /// Its name (`starting`, `code`, `approve`, `done`, `failed`,
92    /// `cancelled`, `expired`).
93    pub fn state(&self) -> &'static str {
94        match self {
95            Self::Starting => "starting",
96            Self::Code { .. } => "code",
97            Self::Approve => "approve",
98            Self::Done { .. } => "done",
99            Self::Failed { .. } => "failed",
100            Self::Cancelled => "cancelled",
101            Self::Expired => "expired",
102        }
103    }
104}
105
106/// A login as its person sees it.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Snapshot {
109    /// The login's id (for [`Logins::get`], `submit`, `cancel`).
110    pub id: String,
111    /// The account it connects.
112    pub org: String,
113    /// Where it is.
114    pub view: View,
115    /// Bumped on every change of `view` (a poller's cue to redraw).
116    pub revision: u32,
117    /// A code submitted, the browser on it.
118    pub busy: bool,
119}
120
121/// Why a call was refused.
122#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
123pub enum LoginError {
124    /// No such login (or another person's or org's).
125    #[error("{0}")]
126    NotFound(&'static str),
127    /// A bad phone or code (or no thread to drive it).
128    #[error("{0}")]
129    Invalid(String),
130    /// Not now: one in progress already, or not asking for a code.
131    #[error("{0}")]
132    State(&'static str),
133    /// The org's send budget (a cooldown, or the hourly cap).
134    #[error("{0}")]
135    Budget(String),
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum Stop {
140    Cancel,
141    Abandon,
142}
143
144#[derive(Debug)]
145struct State {
146    view: View,
147    revision: u32,
148    last_poll: i64,
149    finished_at: Option<i64>,
150    stop: Option<Stop>,
151    code: Option<Secret>,
152    busy: bool,
153}
154
155#[derive(Debug)]
156struct Pending {
157    id: String,
158    person: String,
159    org: String,
160    st: Mutex<State>,
161    cv: Condvar,
162}
163
164impl Pending {
165    fn lock(&self) -> MutexGuard<'_, State> {
166        self.st.lock().unwrap_or_else(PoisonError::into_inner)
167    }
168
169    fn snapshot(&self) -> Snapshot {
170        let st = self.lock();
171        Snapshot {
172            id: self.id.clone(),
173            org: self.org.clone(),
174            view: st.view.clone(),
175            revision: st.revision,
176            busy: st.busy,
177        }
178    }
179}
180
181/// What a finished login hands back: the session to keep and the name.
182/// Its `Debug` never shows the session.
183pub struct LoggedIn {
184    /// The account it connected.
185    pub org: String,
186    /// Who logged in.
187    pub person: String,
188    /// grabcli's `Session` as JSON (secret: keep it sealed).
189    pub session: serde_json::Value,
190    /// How the account is shown: for the real backend, grabcli's masked
191    /// phone (`+1••••••••00`: the last two digits shown).
192    pub display_name: String,
193    /// When the SSO login lapses (unix seconds), if known.
194    pub sso_expires_at: Option<i64>,
195}
196
197impl core::fmt::Debug for LoggedIn {
198    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
199        f.debug_struct("LoggedIn")
200            .field("org", &self.org)
201            .field("person", &self.person)
202            .field("session", &crate::Redacted)
203            .field("display_name", &self.display_name)
204            .field("sso_expires_at", &self.sso_expires_at)
205            .finish()
206    }
207}
208
209/// `text` without `phone` in any of the forms an error may echo it: as
210/// given, its digits (with or without the `+`), and its digits after a
211/// one- to three-digit country code.
212fn scrub_phone(phone: &Secret, text: &str) -> String {
213    let given = phone.expose().trim();
214    let digits: String = given.chars().filter(char::is_ascii_digit).collect();
215    let mut forms = vec![given.to_owned(), digits.clone()];
216    for cc in 1..=3 {
217        if digits.len() >= cc + 7 {
218            forms.push(digits[cc..].to_owned());
219        }
220    }
221    // Longest first, so a shorter form doesn't split a longer one.
222    forms.sort_by_key(|f| core::cmp::Reverse(f.len()));
223    forms
224        .iter()
225        .filter(|f| !f.is_empty())
226        .fold(text.to_owned(), |t, f| Secret::new(f.as_str()).redact(&t))
227}
228
229/// Where a done login's session goes (the embedder keeps it for the org);
230/// an error fails the login.
231pub type OnDone = Arc<dyn Fn(LoggedIn) -> Result<(), String> + Send + Sync>;
232
233/// The logins in progress.
234pub struct Logins {
235    pending: Mutex<HashMap<String, Arc<Pending>>>,
236    /// Code sends per org (unix seconds), for the budget.
237    sends: Mutex<HashMap<String, Vec<i64>>>,
238    /// The clocks and limits.
239    pub timing: Timing,
240    clock: Arc<dyn Fn() -> i64 + Send + Sync>,
241    log: Option<Log>,
242}
243
244impl Default for Logins {
245    fn default() -> Self {
246        Self::new(Timing::default(), Arc::new(crate::util::now_ms))
247    }
248}
249
250impl Logins {
251    /// Logins on `timing`, with `clock` (unix milliseconds) as now.
252    pub fn new(timing: Timing, clock: Arc<dyn Fn() -> i64 + Send + Sync>) -> Self {
253        Self {
254            pending: Mutex::default(),
255            sends: Mutex::default(),
256            timing,
257            clock,
258            log: None,
259        }
260    }
261
262    /// Send diagnostics (a login connected, failed, abandoned; never a
263    /// phone or a code) to `log`.
264    #[must_use]
265    pub fn with_log(mut self, log: Log) -> Self {
266        self.log = Some(log);
267        self
268    }
269
270    fn log(&self, line: &str) {
271        if let Some(l) = &self.log {
272            l(line);
273        }
274    }
275
276    fn now(&self) -> i64 {
277        (self.clock)()
278    }
279
280    fn table(&self) -> MutexGuard<'_, HashMap<String, Arc<Pending>>> {
281        self.pending.lock().unwrap_or_else(PoisonError::into_inner)
282    }
283
284    fn mine(&self, person: &str, org: &str, id: &str) -> Result<Arc<Pending>, LoginError> {
285        self.table()
286            .get(id)
287            .filter(|p| p.person == person && p.org == org)
288            .cloned()
289            .ok_or(LoginError::NotFound("no such login"))
290    }
291
292    /// `person`'s logins of `org` in progress (and those just finished).
293    pub fn of(&self, person: &str, org: &str) -> Vec<Snapshot> {
294        self.reap();
295        let mut v: Vec<Snapshot> = self
296            .table()
297            .values()
298            .filter(|p| p.person == person && p.org == org)
299            .map(|p| p.snapshot())
300            .collect();
301        v.sort_by(|a, b| a.id.cmp(&b.id));
302        v
303    }
304
305    /// Take one send from `org`'s budget (60 s apart, 3 an hour).
306    fn take_send(&self, org: &str) -> Result<(), LoginError> {
307        let now = self.now() / 1000;
308        let mut all = self.sends.lock().unwrap_or_else(PoisonError::into_inner);
309        let sends = all.entry(org.to_owned()).or_default();
310        sends.retain(|t| now - t < 3600);
311        if let Some(last) = sends.last()
312            && now - last < self.timing.send_cooldown_s
313        {
314            return Err(LoginError::Budget(format!(
315                "Grab just sent a code: wait {} s before asking again",
316                self.timing.send_cooldown_s - (now - last)
317            )));
318        }
319        if sends.len() >= self.timing.sends_per_hour {
320            return Err(LoginError::Budget(
321                "too many code sends in the last hour: try again later".into(),
322            ));
323        }
324        sends.push(now);
325        Ok(())
326    }
327
328    /// A new login of `org` by `person` with `phone` on `driver`: its
329    /// thread drives it; `done` keeps the session. One at a time per org.
330    ///
331    /// # Errors
332    /// A bad phone, one in progress already, the send budget.
333    pub fn start(
334        self: &Arc<Self>,
335        person: &str,
336        org: &str,
337        phone: Secret,
338        driver: Box<dyn LoginDriver>,
339        done: OnDone,
340    ) -> Result<Snapshot, LoginError> {
341        self.reap();
342        let digits = phone.expose().chars().filter(char::is_ascii_digit).count();
343        if !phone.expose().trim().starts_with('+') || !(8..=15).contains(&digits) {
344            return Err(LoginError::Invalid(
345                "the phone number: in international form, + and the country code first".into(),
346            ));
347        }
348        // One critical section: the check, the send and the insert, so two
349        // starts for one org can't both pass.
350        let mut table = self.table();
351        if table
352            .values()
353            .any(|p| p.org == org && !p.lock().view.finished())
354        {
355            return Err(LoginError::State(
356                "a Grab login is already in progress for this org",
357            ));
358        }
359        self.take_send(org)?;
360        let now = self.now();
361        let p = Arc::new(Pending {
362            id: crate::util::new_id(),
363            person: person.to_owned(),
364            org: org.to_owned(),
365            st: Mutex::new(State {
366                view: View::Starting,
367                revision: 0,
368                last_poll: now,
369                finished_at: None,
370                stop: None,
371                code: None,
372                busy: false,
373            }),
374            cv: Condvar::new(),
375        });
376        table.insert(p.id.clone(), Arc::clone(&p));
377        drop(table);
378        let (hub, p2) = (Arc::clone(self), Arc::clone(&p));
379        std::thread::Builder::new()
380            .name("grab-login".into())
381            .spawn(move || {
382                let mut driver = driver;
383                let r = std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| {
384                    hub.drive(&p2, driver.as_mut(), phone, &done);
385                }));
386                driver.cancel();
387                if r.is_err() {
388                    hub.finish(
389                        &p2,
390                        View::Failed {
391                            message: "the login broke (logged)".into(),
392                        },
393                    );
394                    hub.log(&format!("org {}: the login driver panicked", p2.org));
395                }
396            })
397            .map_err(|e| {
398                self.finish(
399                    &p,
400                    View::Failed {
401                        message: "the login couldn't start".into(),
402                    },
403                );
404                LoginError::Invalid(format!("a thread: {e}"))
405            })?;
406        Ok(p.snapshot())
407    }
408
409    /// Where a login is (it counts as a poll).
410    ///
411    /// # Errors
412    /// Not found (or another's).
413    pub fn get(&self, person: &str, org: &str, id: &str) -> Result<Snapshot, LoginError> {
414        self.reap();
415        let p = self.mine(person, org, id)?;
416        p.lock().last_poll = self.now();
417        Ok(p.snapshot())
418    }
419
420    /// The code Grab sent.
421    ///
422    /// # Errors
423    /// Not found, not asking for one, already submitted, empty.
424    pub fn submit(
425        &self,
426        person: &str,
427        org: &str,
428        id: &str,
429        code: Secret,
430    ) -> Result<Snapshot, LoginError> {
431        let p = self.mine(person, org, id)?;
432        let c = code.expose().trim();
433        if c.is_empty() || c.len() > 12 || !c.chars().all(|x| x.is_ascii_alphanumeric()) {
434            return Err(LoginError::Invalid("the code: the digits Grab sent".into()));
435        }
436        {
437            let mut st = p.lock();
438            if !matches!(st.view, View::Code { .. }) {
439                return Err(LoginError::State("this login isn't asking for a code"));
440            }
441            if st.busy || st.code.is_some() {
442                return Err(LoginError::State("already submitted"));
443            }
444            st.code = Some(Secret::new(c));
445            st.busy = true;
446            st.last_poll = self.now();
447        }
448        p.cv.notify_all();
449        Ok(p.snapshot())
450    }
451
452    /// Stop a login.
453    ///
454    /// # Errors
455    /// Not found (or another's).
456    pub fn cancel(&self, person: &str, org: &str, id: &str) -> Result<Snapshot, LoginError> {
457        let p = self.mine(person, org, id)?;
458        stop(&p, Stop::Cancel);
459        Ok(p.snapshot())
460    }
461
462    /// Cancel the logins nobody polled; forget those finished a while ago.
463    pub fn reap(&self) {
464        let now = self.now();
465        let all: Vec<Arc<Pending>> = self.table().values().cloned().collect();
466        let mut gone = Vec::new();
467        for p in all {
468            let (abandoned, old) = {
469                let st = p.lock();
470                (
471                    st.stop.is_none()
472                        && !st.view.finished()
473                        && now - st.last_poll > self.timing.abandon_ms,
474                    st.finished_at
475                        .is_some_and(|t| now - t > self.timing.keep_ms),
476                )
477            };
478            if abandoned {
479                self.log(&format!("org {}: a login was abandoned, cancelling", p.org));
480                stop(&p, Stop::Abandon);
481            }
482            if old {
483                gone.push(p.id.clone());
484            }
485        }
486        if !gone.is_empty() {
487            let mut t = self.table();
488            for id in gone {
489                t.remove(&id);
490            }
491        }
492    }
493
494    fn drive(&self, p: &Pending, d: &mut dyn LoginDriver, phone: Secret, done: &OnDone) {
495        // The phone stays only to scrub it from a failure's message.
496        let mut step = d.start(&phone);
497        let approve_until = self.now() + self.timing.approval_ms;
498        loop {
499            if self.stopped(p) {
500                return;
501            }
502            step = match step {
503                Step::Done {
504                    session,
505                    display_name,
506                    sso_expires_at,
507                } => {
508                    let r = done(LoggedIn {
509                        org: p.org.clone(),
510                        person: p.person.clone(),
511                        session,
512                        display_name: display_name.clone(),
513                        sso_expires_at,
514                    });
515                    return match r {
516                        Ok(()) => {
517                            self.log(&format!("org {}: connected", p.org));
518                            self.finish(p, View::Done { display_name });
519                        }
520                        Err(e) => {
521                            self.log(&format!("org {}: keeping the session: {e}", p.org));
522                            self.finish(
523                                p,
524                                View::Failed {
525                                    message: "couldn't keep the session (logged)".into(),
526                                },
527                            );
528                        }
529                    };
530                }
531                Step::Failed(message) => {
532                    let message = scrub_phone(&phone, &message);
533                    self.log(&format!("org {}: the login failed: {message}", p.org));
534                    return self.finish(p, View::Failed { message });
535                }
536                Step::AwaitingApproval => {
537                    if self.now() > approve_until {
538                        return self.finish(
539                            p,
540                            View::Failed {
541                                message: "nobody approved it in the Grab app in time".into(),
542                            },
543                        );
544                    }
545                    Self::publish(p, View::Approve);
546                    self.abandon_if_idle(p);
547                    if self.stopped(p) {
548                        return;
549                    }
550                    d.wait(self.timing.approval_wait)
551                }
552                Step::AwaitingCode { channel } => {
553                    Self::publish(p, View::Code { channel });
554                    let code = loop {
555                        let st = p.lock();
556                        let (mut st, _) =
557                            p.cv.wait_timeout_while(st, Duration::from_secs(1), |s| {
558                                s.code.is_none() && s.stop.is_none()
559                            })
560                            .unwrap_or_else(PoisonError::into_inner);
561                        if st.code.is_some() || st.stop.is_some() {
562                            break st.code.take();
563                        }
564                        drop(st);
565                        self.abandon_if_idle(p);
566                    };
567                    if self.stopped(p) {
568                        return;
569                    }
570                    let Some(code) = code else { return };
571                    d.submit_code(&code)
572                }
573            };
574        }
575    }
576
577    /// Nobody polled it for a while: stop it (the driver's own check, so a
578    /// login ends without a poll to reap it).
579    fn abandon_if_idle(&self, p: &Pending) {
580        let idle = {
581            let st = p.lock();
582            st.stop.is_none() && self.now() - st.last_poll > self.timing.abandon_ms
583        };
584        if idle {
585            self.log(&format!("org {}: a login was abandoned, cancelling", p.org));
586            stop(p, Stop::Abandon);
587        }
588    }
589
590    fn publish(p: &Pending, view: View) {
591        let mut st = p.lock();
592        if st.view.finished() {
593            return;
594        }
595        if st.view != view {
596            st.view = view;
597            st.revision += 1;
598        }
599        st.busy = false;
600    }
601
602    fn finish(&self, p: &Pending, view: View) {
603        let mut st = p.lock();
604        if st.view.finished() {
605            return;
606        }
607        st.view = view;
608        st.revision += 1;
609        st.busy = false;
610        st.code = None;
611        st.finished_at = Some(self.now());
612        drop(st);
613        p.cv.notify_all();
614    }
615
616    fn stopped(&self, p: &Pending) -> bool {
617        let Some(why) = p.lock().stop else {
618            return false;
619        };
620        self.finish(
621            p,
622            match why {
623                Stop::Cancel => View::Cancelled,
624                Stop::Abandon => View::Expired,
625            },
626        );
627        true
628    }
629}
630
631fn stop(p: &Pending, why: Stop) {
632    {
633        let mut st = p.lock();
634        if st.view.finished() || st.stop.is_some() {
635            return;
636        }
637        st.stop = Some(why);
638    }
639    p.cv.notify_all();
640}