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 /// Ask for one service's scopes in addition to the default set (e.g.
43 /// docs, chat, searchconsole). Every login merges with what the account
44 /// already granted, so widening in steps keeps each consent page small
45 /// enough for Google to finish.
46 #[arg(long, conflicts_with = "all_scopes")]
47 pub service: Option<String>,
48
49 /// Ask for every scope the embedded index can express. One big consent
50 /// page: on an unverified project Google's screen stops submitting past
51 /// roughly 27 ticked boxes, so prefer `--service` unless you know the
52 /// project is verified.
53 #[arg(long)]
54 pub all_scopes: bool,
55
56 /// Output format
57 #[arg(short, long, value_enum, default_value = "json")]
58 pub format: OutputFormat,
59}
60
61#[derive(clap::Args, Debug)]
62pub struct StatusArgs {
63 /// Show the status of a NAMED account instead of the default one
64 #[arg(long)]
65 pub account: Option<String>,
66
67 /// Output format
68 #[arg(short, long, value_enum, default_value = "json")]
69 pub format: OutputFormat,
70}
71
72fn token_preview(token: &str) -> String {
73 format!("{}...", &token[..std::cmp::min(20, token.len())])
74}
75
76/// The scope set `auth login` should ask for, from the flags.
77///
78/// Default: [`crate::core::auth::login_scopes`], the small set that keeps the
79/// consent page submittable. `--service X` adds that service's scopes (the
80/// index decides them — the same source `grr api describe` reads), while
81/// `--all-scopes` asks for the whole index. Every login sends
82/// `include_granted_scopes`, so each of these merges with the account's
83/// existing grants rather than replacing them.
84fn requested_login_scopes(login: &LoginArgs) -> Result<Vec<String>> {
85 if login.all_scopes {
86 return Ok(crate::core::auth::scopes().to_vec());
87 }
88 let mut set = crate::core::auth::login_scopes();
89 if let Some(service) = &login.service {
90 let scopes = crate::core::auth::scopes_for_service(service).ok_or_else(|| {
91 crate::core::error::GrrError::Config(format!(
92 "unknown service `{service}`: `grr api list` shows the ids the index knows"
93 ))
94 })?;
95 set.extend(scopes);
96 set.sort_unstable();
97 set.dedup();
98 }
99 Ok(set)
100}
101
102pub async fn handle_auth_cmd(auth: &GoogleAuth, cmd: AuthCommands) -> Result<()> {
103 match cmd {
104 // Handled before client construction in cli.rs; this arm is only a
105 // guard so the match stays exhaustive.
106 AuthCommands::Setup(_) => unreachable!("auth setup handled before client construction"),
107 AuthCommands::Login(login) => {
108 // A named account rebinds to that account's own token store;
109 // the default account keeps today's exact entry naming, so an
110 // existing login is untouched. A fresh handle is built either
111 // way (a cheap store + HTTP-client construction).
112 let auth = match &login.account {
113 Some(name) => auth.with_account_store(name).await?,
114 None => auth.with_default_account_store().await?,
115 };
116 let scopes = requested_login_scopes(&login)?;
117 if login.device {
118 return handle_device_login(&auth, &scopes, login.format).await;
119 }
120 let storage = auth.login(&scopes).await?;
121 print_output(
122 &json!({
123 "authenticated": true,
124 "account": login.account,
125 "token_backend": auth.token_backend(),
126 "scopes_requested": scopes.len(),
127 "token_preview": token_preview(&storage.access_token)
128 }),
129 login.format,
130 )?;
131 Ok(())
132 }
133 AuthCommands::Status(status) => {
134 let auth = match &status.account {
135 Some(name) => auth.with_account_store(name).await?,
136 None => auth.with_default_account_store().await?,
137 };
138 let profile = fetch_profile(&auth).await?;
139 // The credential is in memory by now — `fetch_profile` is what
140 // authenticated — so reporting its scope never touches the
141 // keyring again. `login` reports what was *requested*; this is
142 // what the credential actually holds: Google's granted set wins
143 // over the request, and the consent screen lets a user approve a
144 // subset of it.
145 let scopes_granted = auth.granted_scopes().await;
146 print_output(
147 &json!({
148 "authenticated": true,
149 "account": status.account,
150 "email": field(&profile, "emailAddress"),
151 "messages_total": field(&profile, "messagesTotal"),
152 "threads_total": field(&profile, "threadsTotal"),
153 "history_id": field(&profile, "historyId"),
154 "scopes_granted": scopes_granted,
155 }),
156 status.format,
157 )?;
158 Ok(())
159 }
160 }
161}
162
163/// The profile check behind `auth status`, via the shared Discovery path.
164///
165/// The payload keys are remapped to the long-standing `auth status`
166/// contract (snake_case, `authenticated` flag), so scripts reading the
167/// output did not change when the typed Gmail client left the CLI.
168async fn fetch_profile(auth: &GoogleAuth) -> Result<Value> {
169 let (service, method) =
170 discovery::resolve("gmail.users.getProfile").map_err(anyhow::Error::msg)?;
171 let mut params = serde_json::Map::new();
172 params.insert("userId".into(), json!("me"));
173 api::call_method(
174 Some(auth),
175 service,
176 method,
177 params,
178 api::CallOptions::default(),
179 )
180 .await
181}
182
183fn field(profile: &Value, key: &str) -> Value {
184 profile.get(key).cloned().unwrap_or(Value::Null)
185}
186
187/// The device-flow login behind `grr auth login --device`.
188///
189/// `requested` is the same flag-derived set the browser flow gets
190/// (`--service` / `--all-scopes` included); the device flow may only send it
191/// intersected with Google's allowlist, and that intersection is what both the
192/// request and the stored fallback use — the flags are not silently ignored
193/// here, and the credential never records the index superset.
194async fn handle_device_login(
195 auth: &GoogleAuth,
196 requested: &[String],
197 format: OutputFormat,
198) -> Result<()> {
199 let scopes = crate::core::auth::device_flow_scopes(requested);
200 let mut challenge = auth.request_device_code(&scopes).await?;
201 // stderr, not stdout: `--format json` writes its payload to stdout, and a
202 // prose line ahead of it breaks `... -f json | jq`.
203 eprintln!(
204 "Visit {} and enter code: {}",
205 challenge.verification_url, challenge.user_code
206 );
207 loop {
208 tokio::time::sleep(challenge.retry_after()).await;
209 if challenge.is_expired() {
210 anyhow::bail!("device code expired; rerun `grr auth login --device`");
211 }
212 if let Some(storage) = auth.poll_device_code(&mut challenge, &scopes).await? {
213 print_output(
214 &json!({
215 "authenticated": true,
216 "token_backend": auth.token_backend(),
217 "token_preview": token_preview(&storage.access_token)
218 }),
219 format,
220 )?;
221 return Ok(());
222 }
223 }
224}