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    /// Needs a client of type "TVs and Limited Input devices"
32    /// (GRR_OAUTH__DEVICE_CLIENT_ID); the browser flow's Desktop client is
33    /// not accepted by Google's device endpoint.
34    #[arg(long)]
35    pub device: bool,
36
37    /// Log in as a NAMED account (e.g. work), keeping a separate token per
38    /// account. Omit for the default account.
39    #[arg(long)]
40    pub account: Option<String>,
41
42    /// Output format
43    #[arg(short, long, value_enum, default_value = "json")]
44    pub format: OutputFormat,
45}
46
47#[derive(clap::Args, Debug)]
48pub struct StatusArgs {
49    /// Show the status of a NAMED account instead of the default one
50    #[arg(long)]
51    pub account: Option<String>,
52
53    /// Output format
54    #[arg(short, long, value_enum, default_value = "json")]
55    pub format: OutputFormat,
56}
57
58fn token_preview(token: &str) -> String {
59    format!("{}...", &token[..std::cmp::min(20, token.len())])
60}
61
62pub async fn handle_auth_cmd(auth: &GoogleAuth, cmd: AuthCommands) -> Result<()> {
63    match cmd {
64        // Handled before client construction in cli.rs; this arm is only a
65        // guard so the match stays exhaustive.
66        AuthCommands::Setup(_) => unreachable!("auth setup handled before client construction"),
67        AuthCommands::Login(login) => {
68            // A named account rebinds to that account's own token store;
69            // the default account keeps today's exact entry naming, so an
70            // existing login is untouched. A fresh handle is built either
71            // way (a cheap store + HTTP-client construction).
72            let auth = match &login.account {
73                Some(name) => auth.with_account_store(name).await?,
74                None => auth.with_default_account_store().await?,
75            };
76            if login.device {
77                return handle_device_login(&auth, login.format).await;
78            }
79            let storage = auth.login().await?;
80            print_output(
81                &json!({
82                    "authenticated": true,
83                    "account": login.account,
84                    "token_backend": auth.token_backend(),
85                    "token_preview": token_preview(&storage.access_token)
86                }),
87                login.format,
88            )?;
89            Ok(())
90        }
91        AuthCommands::Status(status) => {
92            let auth = match &status.account {
93                Some(name) => auth.with_account_store(name).await?,
94                None => auth.with_default_account_store().await?,
95            };
96            let profile = fetch_profile(&auth).await?;
97            print_output(
98                &json!({
99                    "authenticated": true,
100                    "account": status.account,
101                    "email": field(&profile, "emailAddress"),
102                    "messages_total": field(&profile, "messagesTotal"),
103                    "threads_total": field(&profile, "threadsTotal"),
104                    "history_id": field(&profile, "historyId"),
105                }),
106                status.format,
107            )?;
108            Ok(())
109        }
110    }
111}
112
113/// The profile check behind `auth status`, via the shared Discovery path.
114///
115/// The payload keys are remapped to the long-standing `auth status`
116/// contract (snake_case, `authenticated` flag), so scripts reading the
117/// output did not change when the typed Gmail client left the CLI.
118async fn fetch_profile(auth: &GoogleAuth) -> Result<Value> {
119    let (service, method) =
120        discovery::resolve("gmail.users.getProfile").map_err(anyhow::Error::msg)?;
121    let mut params = serde_json::Map::new();
122    params.insert("userId".into(), json!("me"));
123    api::call_method(
124        Some(auth),
125        service,
126        method,
127        params,
128        api::CallOptions::default(),
129    )
130    .await
131}
132
133fn field(profile: &Value, key: &str) -> Value {
134    profile.get(key).cloned().unwrap_or(Value::Null)
135}
136
137async fn handle_device_login(auth: &GoogleAuth, format: OutputFormat) -> Result<()> {
138    let mut challenge = auth.request_device_code().await?;
139    println!(
140        "Visit {} and enter code: {}",
141        challenge.verification_url, challenge.user_code
142    );
143    loop {
144        tokio::time::sleep(challenge.retry_after()).await;
145        if challenge.is_expired() {
146            anyhow::bail!("device code expired; rerun `grr auth login --device`");
147        }
148        if let Some(storage) = auth.poll_device_code(&mut challenge).await? {
149            print_output(
150                &json!({
151                    "authenticated": true,
152                    "token_backend": auth.token_backend(),
153                    "token_preview": token_preview(&storage.access_token)
154                }),
155                format,
156            )?;
157            return Ok(());
158        }
159    }
160}