Skip to main content

grr_cli/commands/
auth.rs

1//! Auth CLI commands: account-level authentication.
2//!
3//! No typed service client is involved: `login` needs only the OAuth
4//! handle, and `status` reaches Gmail's profile through the shared
5//! Discovery call path (`gmail.users.getProfile`) — the same engine as
6//! `grr api call` and the generated tree.
7
8use crate::commands::api;
9use crate::core::auth::GoogleAuth;
10use crate::discovery;
11use crate::output::{OutputFormat, print_output};
12use anyhow::Result;
13use clap::Subcommand;
14use serde_json::{Value, json};
15
16#[derive(Subcommand, Debug)]
17pub enum AuthCommands {
18    /// Fresh login. Default is the PKCE browser (loopback) flow; --device
19    /// prints a URL + code for headless environments instead.
20    Login(LoginArgs),
21    /// Show the authenticated account (fails when no valid credential)
22    Status(StatusArgs),
23    /// Create and store an OAuth client. Release builds ship with one, so
24    /// this is only needed when using your own client.
25    Setup(crate::commands::setup::SetupArgs),
26}
27
28#[derive(clap::Args, Debug)]
29pub struct LoginArgs {
30    /// Use the OAuth device flow instead of the browser loopback flow
31    #[arg(long)]
32    pub device: bool,
33
34    /// Log in as a NAMED account (e.g. work), keeping a separate token per
35    /// account. Omit for the default account.
36    #[arg(long)]
37    pub account: Option<String>,
38
39    /// Output format
40    #[arg(short, long, value_enum, default_value = "json")]
41    pub format: OutputFormat,
42}
43
44#[derive(clap::Args, Debug)]
45pub struct StatusArgs {
46    /// Show the status of a NAMED account instead of the default one
47    #[arg(long)]
48    pub account: Option<String>,
49
50    /// Output format
51    #[arg(short, long, value_enum, default_value = "json")]
52    pub format: OutputFormat,
53}
54
55fn token_preview(token: &str) -> String {
56    format!("{}...", &token[..std::cmp::min(20, token.len())])
57}
58
59pub async fn handle_auth_cmd(auth: &GoogleAuth, cmd: AuthCommands) -> Result<()> {
60    match cmd {
61        // Handled before client construction in cli.rs; this arm is only a
62        // guard so the match stays exhaustive.
63        AuthCommands::Setup(_) => unreachable!("auth setup handled before client construction"),
64        AuthCommands::Login(login) => {
65            // A named account rebinds to that account's own token store;
66            // the default account keeps today's exact entry naming, so an
67            // existing login is untouched. A fresh handle is built either
68            // way (a cheap store + HTTP-client construction).
69            let auth = match &login.account {
70                Some(name) => auth.with_account_store(name).await?,
71                None => auth.with_default_account_store().await?,
72            };
73            if login.device {
74                return handle_device_login(&auth, login.format).await;
75            }
76            let storage = auth.login().await?;
77            print_output(
78                &json!({
79                    "authenticated": true,
80                    "account": login.account,
81                    "token_backend": auth.token_backend(),
82                    "token_preview": token_preview(&storage.access_token)
83                }),
84                login.format,
85            )?;
86            Ok(())
87        }
88        AuthCommands::Status(status) => {
89            let auth = match &status.account {
90                Some(name) => auth.with_account_store(name).await?,
91                None => auth.with_default_account_store().await?,
92            };
93            let profile = fetch_profile(&auth).await?;
94            print_output(
95                &json!({
96                    "authenticated": true,
97                    "account": status.account,
98                    "email": field(&profile, "emailAddress"),
99                    "messages_total": field(&profile, "messagesTotal"),
100                    "threads_total": field(&profile, "threadsTotal"),
101                    "history_id": field(&profile, "historyId"),
102                }),
103                status.format,
104            )?;
105            Ok(())
106        }
107    }
108}
109
110/// The profile check behind `auth status`, via the shared Discovery path.
111///
112/// The payload keys are remapped to the long-standing `auth status`
113/// contract (snake_case, `authenticated` flag), so scripts reading the
114/// output did not change when the typed Gmail client left the CLI.
115async fn fetch_profile(auth: &GoogleAuth) -> Result<Value> {
116    let (service, method) =
117        discovery::resolve("gmail.users.getProfile").map_err(anyhow::Error::msg)?;
118    let mut params = serde_json::Map::new();
119    params.insert("userId".into(), json!("me"));
120    api::call_method(
121        Some(auth),
122        service,
123        method,
124        params,
125        api::CallOptions::default(),
126    )
127    .await
128}
129
130fn field(profile: &Value, key: &str) -> Value {
131    profile.get(key).cloned().unwrap_or(Value::Null)
132}
133
134async fn handle_device_login(auth: &GoogleAuth, format: OutputFormat) -> Result<()> {
135    let mut challenge = auth.request_device_code().await?;
136    println!(
137        "Visit {} and enter code: {}",
138        challenge.verification_url, challenge.user_code
139    );
140    loop {
141        tokio::time::sleep(challenge.retry_after()).await;
142        if challenge.is_expired() {
143            anyhow::bail!("device code expired; rerun `grr auth login --device`");
144        }
145        if let Some(storage) = auth.poll_device_code(&mut challenge).await? {
146            print_output(
147                &json!({
148                    "authenticated": true,
149                    "token_backend": auth.token_backend(),
150                    "token_preview": token_preview(&storage.access_token)
151                }),
152                format,
153            )?;
154            return Ok(());
155        }
156    }
157}