Skip to main content

omni_dev/gmail/
auth.rs

1//! Gmail OAuth2 authentication: authorization-code + PKCE login, credential
2//! storage, and in-memory access-token refresh.
3//!
4//! See [ADR-0063](../../../docs/adrs/adr-0063.md) for the design rationale.
5//! The loopback-listener + browser-launch shape follows the Snowflake
6//! client's external-browser SSO flow (`crate::snowflake::client`'s private
7//! `auth` module), extended with PKCE (RFC 7636), a `state` nonce, and an
8//! `error=` branch — none of which a static-token or SSO-only flow needs.
9
10use std::net::{IpAddr, Ipv4Addr, SocketAddr};
11use std::path::Path;
12use std::process::{Command, Stdio};
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use base64::Engine as _;
17use chrono::{DateTime, TimeDelta, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use tokio::io::{AsyncReadExt, AsyncWriteExt};
21use tokio::net::TcpListener;
22use url::Url;
23
24use crate::gmail::account::{self, ResolvedAccount};
25use crate::gmail::chrome_profile;
26use crate::gmail::error::{GmailError, GrantContext};
27use crate::request_log;
28use crate::utils::browser_command::split_browser_command;
29use crate::utils::env::SystemEnv;
30use crate::utils::secret::Secret;
31use crate::utils::secret_env::{secret_var, secret_var_is_set};
32use crate::utils::settings::{
33    account_secret, active_profile_from, GmailAccountSettings, GmailSettings, Settings,
34};
35
36/// Environment variable / settings key for the user's Google Cloud OAuth2
37/// client id.
38pub const GMAIL_CLIENT_ID: &str = "GMAIL_CLIENT_ID";
39/// Environment variable / settings key for the user's Google Cloud OAuth2
40/// client secret.
41pub const GMAIL_CLIENT_SECRET: &str = "GMAIL_CLIENT_SECRET";
42/// Environment variable / settings key for the stored OAuth2 refresh token.
43pub const GMAIL_REFRESH_TOKEN: &str = "GMAIL_REFRESH_TOKEN";
44/// Environment variable / settings key recording the scope granted at login.
45pub const GMAIL_SCOPE: &str = "GMAIL_SCOPE";
46/// Environment variable overriding the real Gmail API host.
47///
48/// Process-env only — never written to `settings.json` by `auth login`,
49/// unlike the four keys above (`crate::gmail::client::GmailClient`'s
50/// default base URL). Useful for:
51/// - Tests that point at a wiremock server (e.g. `http://127.0.0.1:PORT`).
52/// - Environments where outbound traffic must go through a forced proxy.
53///
54/// Mirrors `DATADOG_API_URL` (`crate::datadog::auth`); unlike Datadog, Gmail
55/// has no per-tenant site/region the override is *deriving from* — it's a
56/// flat replacement of the one real host, not a site substitution.
57pub const GMAIL_API_URL: &str = "GMAIL_API_URL";
58
59/// Google's OAuth2 authorization endpoint.
60const AUTHORIZATION_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
61/// Google's OAuth2 token endpoint.
62const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
63/// Read-only Gmail scope — the default.
64pub const SCOPE_READONLY: &str = "https://www.googleapis.com/auth/gmail.readonly";
65/// Read-write Gmail scope — required for label add/remove; opt-in.
66pub const SCOPE_MODIFY: &str = "https://www.googleapis.com/auth/gmail.modify";
67
68/// How long to wait for the browser sign-in callback before giving up.
69const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
70/// How much slack to leave before an access token's tracked expiry before
71/// proactively refreshing it.
72const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
73/// Upper bound on a trusted `expires_in` from the token endpoint. Comfortably
74/// inside what `TimeDelta::seconds` and `DateTime<Utc>` addition can
75/// represent without panicking, and far beyond any real OAuth token
76/// lifetime — an out-of-range value is clamped rather than trusted, so a
77/// misbehaving or malicious token endpoint can't crash the process (#1531).
78const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
79
80/// The Gmail OAuth2 scope granted at login.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum GmailScope {
83    /// List/read messages, threads, labels, and history. Default; never
84    /// requests send.
85    #[default]
86    ReadOnly,
87    /// Everything [`ReadOnly`](Self::ReadOnly) grants, plus label add/remove
88    /// (`batchModify`).
89    Modify,
90}
91
92impl GmailScope {
93    /// Returns the wire scope string Google expects.
94    #[must_use]
95    pub fn as_str(self) -> &'static str {
96        match self {
97            Self::ReadOnly => SCOPE_READONLY,
98            Self::Modify => SCOPE_MODIFY,
99        }
100    }
101
102    /// Parses Google's space-separated granted-scope response, treating the
103    /// presence of the modify scope anywhere in it as [`Modify`](Self::Modify)
104    /// and the readonly scope (with no modify) as [`ReadOnly`](Self::ReadOnly).
105    ///
106    /// Returns `None` when neither Gmail scope is present — e.g. the user
107    /// left the Gmail permission unticked on Google's consent screen — so
108    /// callers can reject the grant instead of silently defaulting to
109    /// [`ReadOnly`](Self::ReadOnly).
110    #[must_use]
111    pub fn from_granted(granted: &str) -> Option<Self> {
112        let tokens: Vec<&str> = granted.split_whitespace().collect();
113        if tokens.contains(&SCOPE_MODIFY) {
114            Some(Self::Modify)
115        } else if tokens.contains(&SCOPE_READONLY) {
116            Some(Self::ReadOnly)
117        } else {
118            None
119        }
120    }
121
122    /// Whether this scope allows label-mutating calls (`batchModify`).
123    #[must_use]
124    pub fn allows_modify(self) -> bool {
125        matches!(self, Self::Modify)
126    }
127}
128
129/// Gmail OAuth2 credentials.
130#[derive(Debug, Clone)]
131pub struct GmailCredentials {
132    /// OAuth2 client id (not secret — visible in the browser's own network
133    /// traffic during login regardless).
134    pub client_id: String,
135    /// OAuth2 client secret (redacted in `Debug` output).
136    pub client_secret: Secret,
137    /// The stored refresh token (redacted in `Debug` output).
138    pub refresh_token: Secret,
139    /// The scope granted at the login that produced this refresh token.
140    pub scope: GmailScope,
141}
142
143/// Secret-free presence/scope report, safe to serialise (e.g. over MCP).
144#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
145pub struct GmailAuthStatus {
146    /// Whether [`GMAIL_CLIENT_ID`] is present.
147    pub has_client_id: bool,
148    /// Whether [`GMAIL_CLIENT_SECRET`] is present.
149    pub has_client_secret: bool,
150    /// Whether [`GMAIL_REFRESH_TOKEN`] is present.
151    pub has_refresh_token: bool,
152    /// The granted scope, if recorded. `None` when unset.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub scope: Option<String>,
155}
156
157/// Resolves the active Gmail account for this call (issue #1500), folding an
158/// explicit per-call override together with the ambient
159/// `--account`/[`account::GMAIL_ACCOUNT_ENV`] value. The one seam every
160/// credential CRUD entry point in this module and [`crate::gmail::import`]
161/// routes through.
162pub(crate) fn resolve(gmail: &GmailSettings, explicit: Option<&str>) -> Result<ResolvedAccount> {
163    let explicit = fold_explicit(explicit);
164    account::resolve_account(&SystemEnv, gmail, explicit.as_deref())
165}
166
167/// Like [`resolve`], but for account-creating writes (`gmail auth login`,
168/// `gmail auth import`) — see
169/// [`account::resolve_account_for_write`] for why an explicit target need
170/// not already exist.
171pub(crate) fn resolve_for_write(
172    gmail: &GmailSettings,
173    explicit: Option<&str>,
174) -> Result<ResolvedAccount> {
175    let explicit = fold_explicit(explicit);
176    account::resolve_account_for_write(&SystemEnv, gmail, explicit.as_deref())
177}
178
179/// Folds an explicit per-call account override together with the ambient
180/// `--account`/[`account::GMAIL_ACCOUNT_ENV`] value — shared by [`resolve`]
181/// and [`resolve_for_write`].
182fn fold_explicit(explicit: Option<&str>) -> Option<String> {
183    explicit
184        .map(str::to_string)
185        .or_else(|| account::active_gmail_account_from(&SystemEnv))
186}
187
188/// Resolves the [`BrowserConfig`] `gmail auth login` should open the
189/// authorization URL with, honoring a named account's manual
190/// `browser_command` override and opt-in automatic Chrome-profile
191/// resolution (issue #1505). `explicit` is folded exactly like
192/// [`resolve_for_write`]'s. A [`ResolvedAccount::Legacy`] account (no named
193/// accounts configured, or a literal credential env set) always yields
194/// today's [`BrowserLaunch::Auto`] — zero migration.
195pub(crate) fn resolve_browser_config_for(
196    gmail: &GmailSettings,
197    explicit: Option<&str>,
198) -> Result<BrowserConfig> {
199    match resolve_for_write(gmail, explicit)? {
200        ResolvedAccount::Legacy => Ok(BrowserConfig::default()),
201        ResolvedAccount::Named(name) => build_browser_config(
202            gmail.accounts.get(&name),
203            chrome_profile::resolve_launch_command,
204        ),
205    }
206}
207
208/// The pure/injectable core of [`resolve_browser_config_for`] —
209/// `resolve_chrome_profile` is [`chrome_profile::resolve_launch_command`] in
210/// production, a stub in tests, so this stays testable without touching a
211/// real Chrome install.
212///
213/// Precedence:
214/// 1. `account.browser_command` set (non-blank) → used verbatim; a
215///    malformed command is a hard error.
216/// 2. `account.chrome_profile_from_email` set *and* `account.email_address`
217///    set → automatic resolution; any resolution failure (per
218///    `resolve_chrome_profile`'s fail-open contract) falls back to `Auto`.
219/// 3. Otherwise → `Auto`.
220fn build_browser_config(
221    account: Option<&GmailAccountSettings>,
222    resolve_chrome_profile: impl FnOnce(&str) -> Option<Vec<String>>,
223) -> Result<BrowserConfig> {
224    let Some(account) = account else {
225        return Ok(BrowserConfig::default());
226    };
227
228    if let Some(command) = account
229        .browser_command
230        .as_deref()
231        .map(str::trim)
232        .filter(|command| !command.is_empty())
233    {
234        return Ok(BrowserConfig {
235            launch: BrowserLaunch::Command(split_browser_command("browser_command", command)?),
236            ..BrowserConfig::default()
237        });
238    }
239
240    if account.chrome_profile_from_email {
241        if let Some(email) = account.email_address.as_deref() {
242            if let Some(args) = resolve_chrome_profile(email) {
243                return Ok(BrowserConfig {
244                    launch: BrowserLaunch::Command(args),
245                    ..BrowserConfig::default()
246                });
247            }
248        } else {
249            tracing::info!(
250                "chrome_profile_from_email is set but email_address is not; \
251                 falling back to the default browser"
252            );
253        }
254    }
255
256    Ok(BrowserConfig::default())
257}
258
259/// Loads Gmail credentials from environment variables or settings.json.
260///
261/// Environment variables take precedence over the settings file.
262pub fn load_credentials() -> Result<GmailCredentials> {
263    load_credentials_with(&crate::utils::settings::SettingsEnv::load())
264}
265
266/// [`load_credentials`], but honoring the named-account resolution added by
267/// issue #1500. `explicit` is the already-resolved `--account`/
268/// [`account::GMAIL_ACCOUNT_ENV`] override, if any (`None` still resolves
269/// the ambient env var — see [`resolve`]). Falls through to
270/// [`load_credentials_with`]'s exact legacy behavior when no named account
271/// applies — the zero-migration guarantee.
272pub(crate) fn load_credentials_for(explicit: Option<&str>) -> Result<GmailCredentials> {
273    let settings = Settings::load_or_warn_default();
274    match resolve(&settings.gmail, explicit)? {
275        ResolvedAccount::Legacy => {
276            let profile = active_profile_from(&SystemEnv);
277            load_credentials_with(&crate::utils::settings::SettingsEnv::from_settings(
278                settings,
279                profile.as_deref(),
280            ))
281        }
282        ResolvedAccount::Named(name) => load_named_credentials(&settings.gmail, &name),
283    }
284}
285
286/// Reads `gmail.accounts.<name>` into [`GmailCredentials`], wrapping
287/// `client_secret`/`refresh_token` (or their `_file` companions, #2008)
288/// into [`Secret`] immediately, mirroring [`load_credentials_with`].
289fn load_named_credentials(gmail: &GmailSettings, name: &str) -> Result<GmailCredentials> {
290    let account = gmail
291        .accounts
292        .get(name)
293        .ok_or(GmailError::CredentialsNotFound)?;
294    let client_id = account
295        .client_id
296        .clone()
297        .ok_or(GmailError::CredentialsNotFound)?;
298    let client_secret = account_secret(
299        "gmail",
300        name,
301        "client_secret",
302        account.client_secret.as_deref(),
303        account.client_secret_file.as_deref(),
304    )?
305    .ok_or(GmailError::CredentialsNotFound)?;
306    reject_client_secret_json(name, account, &client_secret)?;
307    let refresh_token = account_secret(
308        "gmail",
309        name,
310        "refresh_token",
311        account.refresh_token.as_deref(),
312        account.refresh_token_file.as_deref(),
313    )?
314    .ok_or(GmailError::CredentialsNotFound)?;
315    let scope = account
316        .scope
317        .as_deref()
318        .and_then(GmailScope::from_granted)
319        .unwrap_or_default();
320
321    Ok(GmailCredentials {
322        client_id,
323        client_secret,
324        refresh_token,
325        scope,
326    })
327}
328
329/// Refuses a `client_secret_file` that holds a whole `client_secret.json`
330/// rather than the raw secret (#2008). The flat `GMAIL_CLIENT_SECRET_FILE`
331/// *does* name such a JSON file (for `gmail auth import`, ADR-0089 §6), so
332/// the per-account field is easy to point at one; an OAuth client secret
333/// never starts with `{`, so this only ever catches that mistake.
334fn reject_client_secret_json(
335    name: &str,
336    account: &GmailAccountSettings,
337    client_secret: &Secret,
338) -> Result<()> {
339    if account.client_secret_file.is_some()
340        && client_secret.expose_secret().trim_start().starts_with('{')
341    {
342        anyhow::bail!(
343            "gmail.accounts.{name}.client_secret_file names a JSON file; it must hold only the \
344             raw client secret, the `client_secret` value inside a downloaded \
345             client_secret.json (`gmail auth import` reads such a JSON file; this field does not)"
346        );
347    }
348    Ok(())
349}
350
351/// Forces the legacy (pre-migration) credential path regardless of any
352/// active named account. Used only by `gmail account import-legacy`, which
353/// must read the *true* legacy env-resolved credentials to migrate, not
354/// whatever named account happens to be ambient.
355pub(crate) fn load_credentials_legacy() -> Result<GmailCredentials> {
356    load_credentials_with(&crate::utils::settings::SettingsEnv::load())
357}
358
359/// [`load_credentials`] over an injected
360/// [`EnvSource`](crate::utils::env::EnvSource).
361///
362/// Tests pass a pure `MapEnv` so credential resolution is exercised without
363/// mutating the process environment (issue #1030 / STYLE-0028).
364pub(crate) fn load_credentials_with(
365    env: &impl crate::utils::env::EnvSource,
366) -> Result<GmailCredentials> {
367    let client_id = env
368        .var(GMAIL_CLIENT_ID)
369        .ok_or(GmailError::CredentialsNotFound)?;
370    let client_secret = env
371        .var(GMAIL_CLIENT_SECRET)
372        .ok_or(GmailError::CredentialsNotFound)?;
373    let refresh_token =
374        secret_var(env, GMAIL_REFRESH_TOKEN)?.ok_or(GmailError::CredentialsNotFound)?;
375    // Unlike login (which rejects an unparseable grant outright), a stored
376    // scope that no longer parses degrades to ReadOnly rather than erroring:
377    // it was already validated when written, and failing closed here is
378    // safe (a stale Modify silently becoming ReadOnly still fails at the
379    // API, not open).
380    let scope = env
381        .var(GMAIL_SCOPE)
382        .and_then(|s| GmailScope::from_granted(&s))
383        .unwrap_or_default();
384
385    Ok(GmailCredentials {
386        client_id,
387        client_secret: client_secret.into(),
388        refresh_token,
389        scope,
390    })
391}
392
393/// Builds a [`GmailAuthStatus`] from the current settings / environment.
394///
395/// Reports credential presence without leaking any secret values. Safe to
396/// call with no credentials configured.
397pub fn status() -> GmailAuthStatus {
398    status_with(&crate::utils::settings::SettingsEnv::load())
399}
400
401/// [`status`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
402pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> GmailAuthStatus {
403    GmailAuthStatus {
404        has_client_id: env.var(GMAIL_CLIENT_ID).is_some(),
405        has_client_secret: env.var(GMAIL_CLIENT_SECRET).is_some(),
406        has_refresh_token: secret_var_is_set(env, GMAIL_REFRESH_TOKEN),
407        scope: env.var(GMAIL_SCOPE),
408    }
409}
410
411/// [`status`], but honoring the named-account resolution added by issue
412/// #1500. `explicit` is the already-resolved `--account`/
413/// [`account::GMAIL_ACCOUNT_ENV`] override, if any. Unlike [`status`], this
414/// can fail — once named accounts exist, resolution itself can (e.g. an
415/// unknown or ambiguous account) — so callers that want [`status`]'s
416/// never-fails presence report keep calling that instead.
417///
418/// Only compiled with the `mcp` feature — the MCP `gmail_auth_status` tool
419/// is its sole consumer; the CLI's `gmail auth status` goes through
420/// [`load_credentials_for`] instead.
421#[cfg(feature = "mcp")]
422pub(crate) fn status_for(explicit: Option<&str>) -> Result<GmailAuthStatus> {
423    let settings = Settings::load_or_warn_default();
424    match resolve(&settings.gmail, explicit)? {
425        ResolvedAccount::Legacy => {
426            let profile = active_profile_from(&SystemEnv);
427            Ok(status_with(
428                &crate::utils::settings::SettingsEnv::from_settings(settings, profile.as_deref()),
429            ))
430        }
431        ResolvedAccount::Named(name) => Ok(status_from_named(&settings.gmail, &name)),
432    }
433}
434
435/// Builds a [`GmailAuthStatus`] from `gmail.accounts.<name>`'s presence
436/// flags — the named-account counterpart of [`status_with`].
437///
438/// Only compiled with the `mcp` feature — see [`status_for`], its sole
439/// caller.
440#[cfg(feature = "mcp")]
441fn status_from_named(gmail: &GmailSettings, name: &str) -> GmailAuthStatus {
442    let account = gmail.accounts.get(name);
443    GmailAuthStatus {
444        has_client_id: account.is_some_and(|a| a.client_id.is_some()),
445        has_client_secret: account.is_some_and(|a| {
446            crate::utils::settings::account_secret_is_set(
447                a.client_secret.as_deref(),
448                a.client_secret_file.as_deref(),
449            )
450        }),
451        has_refresh_token: account.is_some_and(|a| {
452            crate::utils::settings::account_secret_is_set(
453                a.refresh_token.as_deref(),
454                a.refresh_token_file.as_deref(),
455            )
456        }),
457        scope: account.and_then(|a| a.scope.clone()),
458    }
459}
460
461/// Opportunistic `email_address` backfill for `name`, populated by `gmail
462/// auth status --all` after a successful live `users.getProfile` call.
463/// Never used for authentication, never written by `login`/`import`. A
464/// no-op when `name` already has an `email_address` — an explicit or
465/// previously-backfilled value is never overwritten (issue #1505).
466pub(crate) fn record_account_email(name: &str, email: &str) -> Result<()> {
467    let settings = Settings::load_or_warn_default();
468    if settings
469        .gmail
470        .accounts
471        .get(name)
472        .is_some_and(|account| account.email_address.is_some())
473    {
474        return Ok(());
475    }
476    Settings::upsert_gmail_account(
477        &Settings::get_settings_path()?,
478        name,
479        &[(
480            "email_address",
481            serde_json::Value::String(email.to_string()),
482        )],
483    )
484}
485
486/// Saves Gmail credentials to `~/.omni-dev/settings.json`.
487///
488/// Merges the four credential keys into the active profile's `env` map (the
489/// base `env` when no profile is active), preserving all other settings.
490pub fn save_credentials(credentials: &GmailCredentials) -> Result<()> {
491    save_credentials_to(
492        &Settings::get_settings_path()?,
493        active_profile_from(&SystemEnv).as_deref(),
494        credentials,
495    )
496}
497
498/// [`save_credentials`], writing to an explicit settings-file path and env
499/// map (`profiles.<name>.env` when `profile` is `Some`, base `env` otherwise).
500pub(crate) fn save_credentials_to(
501    settings_path: &Path,
502    profile: Option<&str>,
503    credentials: &GmailCredentials,
504) -> Result<()> {
505    Settings::upsert_env_vars_in(
506        settings_path,
507        profile,
508        &[
509            (GMAIL_CLIENT_ID, credentials.client_id.as_str()),
510            (
511                GMAIL_CLIENT_SECRET,
512                credentials.client_secret.expose_secret(),
513            ),
514            (
515                GMAIL_REFRESH_TOKEN,
516                credentials.refresh_token.expose_secret(),
517            ),
518            (GMAIL_SCOPE, credentials.scope.as_str()),
519        ],
520    )
521}
522
523/// The `gmail.accounts.<name>` field names/values for `credentials` — the
524/// named-account counterpart of the flat `GMAIL_*` env keys
525/// [`save_credentials_to`] writes.
526fn named_account_vars(credentials: &GmailCredentials) -> [(&str, serde_json::Value); 4] {
527    [
528        (
529            "client_id",
530            serde_json::Value::String(credentials.client_id.clone()),
531        ),
532        (
533            "client_secret",
534            serde_json::Value::String(credentials.client_secret.expose_secret().to_string()),
535        ),
536        (
537            "refresh_token",
538            serde_json::Value::String(credentials.refresh_token.expose_secret().to_string()),
539        ),
540        (
541            "scope",
542            serde_json::Value::String(credentials.scope.as_str().to_string()),
543        ),
544    ]
545}
546
547/// Removes Gmail credential keys from `~/.omni-dev/settings.json` — this
548/// *is* `gmail auth logout`.
549///
550/// Returns `true` if any Gmail key was present and removed, `false`
551/// otherwise.
552pub fn remove_credentials() -> Result<bool> {
553    remove_credentials_at(
554        &Settings::get_settings_path()?,
555        active_profile_from(&SystemEnv).as_deref(),
556    )
557}
558
559/// [`remove_credentials`], operating on an explicit settings-file path and
560/// env map.
561pub(crate) fn remove_credentials_at(settings_path: &Path, profile: Option<&str>) -> Result<bool> {
562    Settings::remove_env_vars_in(
563        settings_path,
564        profile,
565        &[
566            GMAIL_CLIENT_ID,
567            GMAIL_CLIENT_SECRET,
568            GMAIL_REFRESH_TOKEN,
569            GMAIL_SCOPE,
570        ],
571    )
572}
573
574/// [`remove_credentials`], but honoring the named-account resolution added
575/// by issue #1500. `explicit` is the already-resolved `--account`/
576/// [`account::GMAIL_ACCOUNT_ENV`] override, if any. Removes the whole
577/// `gmail.accounts.<name>` entry — an account is coherent as a unit.
578pub(crate) fn remove_credentials_for(explicit: Option<&str>) -> Result<bool> {
579    let settings = Settings::load_or_warn_default();
580    match resolve(&settings.gmail, explicit)? {
581        ResolvedAccount::Legacy => remove_credentials_at(
582            &Settings::get_settings_path()?,
583            active_profile_from(&SystemEnv).as_deref(),
584        ),
585        ResolvedAccount::Named(name) => {
586            Settings::remove_gmail_account(&Settings::get_settings_path()?, &name)
587        }
588    }
589}
590
591// ── Browser launch ──────────────────────────────────────────────────────
592
593/// How to open the authorization URL during login.
594///
595/// Deliberately duplicated from (not shared with)
596/// [`crate::snowflake::client::config::BrowserLaunch`] — a small, stable
597/// shape with no existing "generic browser launch" module to promote into;
598/// extract only on a third consumer.
599#[derive(Clone, Debug, Default)]
600pub enum BrowserLaunch {
601    /// Open with the OS default handler (`open` / `xdg-open` / `start`).
602    #[default]
603    Auto,
604    /// Run a custom command; `{url}` (or a trailing arg) receives the
605    /// authorization URL. Use this to target a specific Chrome profile,
606    /// e.g. `Google Chrome --profile-directory=Profile 1 --new-window {url}`.
607    Command(Vec<String>),
608    /// Do not open a browser; the authorization URL is logged for manual
609    /// opening.
610    Manual,
611}
612
613/// Loopback OAuth2 callback settings.
614#[derive(Clone, Debug)]
615pub struct BrowserConfig {
616    /// How to open the authorization URL.
617    pub launch: BrowserLaunch,
618    /// Bind address for the loopback callback listener.
619    pub callback_addr: IpAddr,
620    /// Bind port for the callback listener (`0` = OS-assigned ephemeral port).
621    pub callback_port: u16,
622}
623
624impl Default for BrowserConfig {
625    fn default() -> Self {
626        Self {
627            launch: BrowserLaunch::Auto,
628            callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
629            callback_port: 0,
630        }
631    }
632}
633
634/// Opens `url` in the configured browser.
635// `{url}` is a literal command placeholder we substitute, not a format string.
636#[allow(clippy::literal_string_with_formatting_args)]
637fn open_browser(launch: &BrowserLaunch, url: &str) -> Result<()> {
638    match launch {
639        BrowserLaunch::Manual => {
640            tracing::info!("Open this URL in a browser to sign in to Gmail:\n{url}");
641            Ok(())
642        }
643        BrowserLaunch::Command(args) => {
644            let mut parts = args.iter();
645            let program = parts
646                .next()
647                .ok_or_else(|| GmailError::InvalidBrowserCommand("empty browser command".into()))?;
648            let mut command = Command::new(program);
649            let mut placed = false;
650            for arg in parts {
651                if arg.contains("{url}") {
652                    command.arg(arg.replace("{url}", url));
653                    placed = true;
654                } else {
655                    command.arg(arg);
656                }
657            }
658            if !placed {
659                command.arg(url);
660            }
661            spawn_detached(command)
662        }
663        BrowserLaunch::Auto => {
664            let program = if cfg!(target_os = "macos") {
665                "open"
666            } else if cfg!(target_os = "windows") {
667                "explorer"
668            } else {
669                "xdg-open"
670            };
671            let mut command = Command::new(program);
672            command.arg(url);
673            spawn_detached(command)
674        }
675    }
676}
677
678/// Spawns a browser command detached from this process's stdio.
679fn spawn_detached(mut command: Command) -> Result<()> {
680    command
681        .stdin(Stdio::null())
682        .stdout(Stdio::null())
683        .stderr(Stdio::null())
684        .spawn()
685        .map(|_| ())
686        .context("Failed to launch the browser")
687}
688
689// ── PKCE + state ────────────────────────────────────────────────────────
690
691/// A pending login's PKCE verifier and CSRF `state` nonce, generated fresh
692/// per login attempt and never persisted.
693struct PendingLogin {
694    state: String,
695    code_verifier: String,
696}
697
698fn generate_pending_login() -> PendingLogin {
699    PendingLogin {
700        state: crate::browser::auth::generate_token(),
701        code_verifier: crate::browser::auth::generate_token(),
702    }
703}
704
705/// Derives the PKCE `code_challenge` (RFC 7636, `S256` method) from a
706/// `code_verifier`.
707fn code_challenge(code_verifier: &str) -> String {
708    let digest = Sha256::digest(code_verifier.as_bytes());
709    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
710}
711
712fn build_authorization_url(
713    client_id: &str,
714    redirect_uri: &str,
715    scope: GmailScope,
716    state: &str,
717    code_challenge: &str,
718) -> Result<Url> {
719    let mut url =
720        Url::parse(AUTHORIZATION_ENDPOINT).context("Invalid Gmail authorization endpoint")?;
721    url.query_pairs_mut()
722        .append_pair("client_id", client_id)
723        .append_pair("redirect_uri", redirect_uri)
724        .append_pair("response_type", "code")
725        .append_pair("scope", scope.as_str())
726        .append_pair("state", state)
727        .append_pair("code_challenge", code_challenge)
728        .append_pair("code_challenge_method", "S256")
729        .append_pair("access_type", "offline")
730        // Without forcing re-consent, Google may not re-issue a refresh
731        // token on a second login — which would silently break re-auth
732        // after the 7-day testing-mode refresh-token expiry.
733        .append_pair("prompt", "consent");
734    Ok(url)
735}
736
737// ── Loopback callback capture ───────────────────────────────────────────
738
739/// The parsed loopback callback: either `code`+`state`, or an `error`
740/// (optionally with `error_description`).
741#[derive(Debug)]
742pub(crate) struct CallbackResult {
743    code: Option<String>,
744    state: Option<String>,
745    error: Option<String>,
746    error_description: Option<String>,
747}
748
749/// Binds the loopback callback listener, returning it along with the
750/// OS-assigned port so the authorization URL's `redirect_uri` can be built
751/// before the browser is opened.
752pub(crate) async fn bind_callback_listener(browser: &BrowserConfig) -> Result<(TcpListener, u16)> {
753    let listener = TcpListener::bind(SocketAddr::new(
754        browser.callback_addr,
755        browser.callback_port,
756    ))
757    .await
758    .context("Failed to start the local OAuth callback listener")?;
759    let port = listener
760        .local_addr()
761        .context("Failed to read the callback listener's port")?
762        .port();
763    Ok((listener, port))
764}
765
766/// Waits for the browser's callback connection using the default
767/// [`CALLBACK_TIMEOUT`].
768pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
769    wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
770}
771
772/// Accepts one loopback connection and extracts the OAuth callback's query
773/// parameters from the redirected `GET` request line.
774///
775/// Never logs the raw request or query string — only that a callback was
776/// received — so the authorization `code` can never reach the request log
777/// via this path (see ADR-0063's redaction discussion).
778pub(crate) async fn wait_for_callback_with_timeout(
779    listener: TcpListener,
780    timeout: Duration,
781) -> Result<CallbackResult> {
782    let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept())
783        .await
784        .map_err(|_| GmailError::CallbackTimeout(timeout.as_secs()))?
785        .context("Failed to accept the browser's callback connection")?;
786
787    let mut buf = vec![0u8; 8192];
788    let n = stream
789        .read(&mut buf)
790        .await
791        .context("Failed to read the callback request")?;
792    let request = String::from_utf8_lossy(&buf[..n]);
793
794    let result = parse_callback(&request).ok_or(GmailError::MalformedCallback)?;
795    tracing::info!("Gmail OAuth callback received");
796
797    let body = if result.error.is_some() {
798        "<html><body>Sign-in failed. You can close this tab and check the terminal.</body></html>"
799    } else {
800        "<html><body>Gmail sign-in complete. You can close this tab.</body></html>"
801    };
802    let response =
803        format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n{body}");
804    let _ = stream.write_all(response.as_bytes()).await;
805    let _ = stream.flush().await;
806
807    Ok(result)
808}
809
810/// Extracts `code`/`state`/`error`/`error_description` from an HTTP
811/// request's first line only — headers and body are never inspected.
812fn parse_callback(request: &str) -> Option<CallbackResult> {
813    let first_line = request.lines().next()?;
814    let path = first_line.split_whitespace().nth(1)?; // "/?code=…&state=…"
815    let query = path.split_once('?')?.1;
816
817    let mut result = CallbackResult {
818        code: None,
819        state: None,
820        error: None,
821        error_description: None,
822    };
823    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
824        match key.as_ref() {
825            "code" => result.code = Some(value.into_owned()),
826            "state" => result.state = Some(value.into_owned()),
827            "error" => result.error = Some(value.into_owned()),
828            "error_description" => result.error_description = Some(value.into_owned()),
829            _ => {}
830        }
831    }
832    Some(result)
833}
834
835// ── Token exchange / refresh ────────────────────────────────────────────
836
837#[derive(Debug, Deserialize)]
838struct TokenResponse {
839    access_token: String,
840    #[serde(default)]
841    refresh_token: Option<String>,
842    expires_in: i64,
843    #[serde(default)]
844    scope: Option<String>,
845}
846
847#[derive(Debug, Deserialize)]
848struct TokenErrorResponse {
849    error: String,
850    #[serde(default)]
851    error_description: Option<String>,
852}
853
854async fn exchange_code_for_tokens(
855    http: &reqwest::Client,
856    token_endpoint: &str,
857    client_id: &str,
858    client_secret: &str,
859    code: &str,
860    code_verifier: &str,
861    redirect_uri: &str,
862) -> Result<TokenResponse> {
863    let params = [
864        ("grant_type", "authorization_code"),
865        ("code", code),
866        ("client_id", client_id),
867        ("client_secret", client_secret),
868        ("redirect_uri", redirect_uri),
869        ("code_verifier", code_verifier),
870    ];
871    post_token_request(http, token_endpoint, &params, GrantContext::CodeExchange).await
872}
873
874async fn refresh_access_token(
875    http: &reqwest::Client,
876    token_endpoint: &str,
877    client_id: &str,
878    client_secret: &str,
879    refresh_token: &str,
880) -> Result<TokenResponse> {
881    let params = [
882        ("grant_type", "refresh_token"),
883        ("refresh_token", refresh_token),
884        ("client_id", client_id),
885        ("client_secret", client_secret),
886    ];
887    post_token_request(http, token_endpoint, &params, GrantContext::Refresh).await
888}
889
890/// POSTs a token request. All secrets travel in the `.form(...)` body, never
891/// the URL — `token_endpoint` carries no query string, so the request-log's
892/// URL redaction has nothing to redact and nothing to miss either.
893async fn post_token_request(
894    http: &reqwest::Client,
895    token_endpoint: &str,
896    params: &[(&str, &str)],
897    context: GrantContext,
898) -> Result<TokenResponse> {
899    let started = std::time::Instant::now();
900    let result = http.post(token_endpoint).form(params).send().await;
901    request_log::record_http_result("gmail", "POST", token_endpoint, started, &result);
902    let response = result.context("Failed to send token request to Google")?;
903
904    if !response.status().is_success() {
905        let body = response.text().await.unwrap_or_default();
906        if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
907            if err.error == "invalid_grant" {
908                return Err(GmailError::InvalidGrant(context).into());
909            }
910            return Err(anyhow::anyhow!(
911                "Google token endpoint rejected the request: {} ({})",
912                err.error,
913                err.error_description.unwrap_or_default()
914            ));
915        }
916        return Err(anyhow::anyhow!(
917            "Google token endpoint returned an unparsable error body: {body}"
918        ));
919    }
920
921    response
922        .json::<TokenResponse>()
923        .await
924        .context("Failed to parse Google's token response")
925}
926
927// ── Session (in-memory access-token lifecycle) ──────────────────────────
928
929/// The mutable access-token state, refreshed by [`GmailSession::refresh_locked`].
930struct TokenState {
931    access_token: Secret,
932    expires_at: DateTime<Utc>,
933}
934
935/// A live Gmail OAuth2 session: holds the refresh token and the current
936/// in-memory access token, refreshing on demand.
937///
938/// Uses [`tokio::sync::Mutex`] (not `std::sync::Mutex`) held *across* the
939/// refresh network call — unlike
940/// [`SnowflakeSession::renew`](crate::snowflake::client::SnowflakeSession::renew),
941/// which releases its lock before the network call and accepts concurrent
942/// refreshes racing each other. Gmail's design requires single-flight
943/// refresh (issue #1465's explicit "concurrent callers don't stampede"
944/// requirement): a second concurrent caller blocks on this mutex and, once
945/// unblocked, observes the already-refreshed token instead of issuing a
946/// second POST.
947pub struct GmailSession {
948    http: reqwest::Client,
949    client_id: String,
950    client_secret: Secret,
951    refresh_token: Secret,
952    token_endpoint: String,
953    state: tokio::sync::Mutex<TokenState>,
954}
955
956impl GmailSession {
957    /// Creates a session against Google's real token endpoint.
958    pub(crate) fn new(http: reqwest::Client, credentials: &GmailCredentials) -> Self {
959        Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
960    }
961
962    /// [`new`](Self::new) against an explicit token endpoint — the test seam
963    /// for pointing at a wiremock server.
964    pub(crate) fn new_with_token_endpoint(
965        http: reqwest::Client,
966        credentials: &GmailCredentials,
967        token_endpoint: &str,
968    ) -> Self {
969        Self {
970            http,
971            client_id: credentials.client_id.clone(),
972            client_secret: credentials.client_secret.clone(),
973            refresh_token: credentials.refresh_token.clone(),
974            token_endpoint: token_endpoint.to_string(),
975            state: tokio::sync::Mutex::new(TokenState {
976                access_token: Secret::new(""),
977                // No access token is ever persisted (ADR-0063 Decision 2), so
978                // every fresh session starts expired and refreshes on its
979                // very first call.
980                expires_at: DateTime::<Utc>::MIN_UTC,
981            }),
982        }
983    }
984
985    /// Returns a valid access token, refreshing proactively when the
986    /// tracked expiry is within [`REFRESH_SKEW`].
987    pub(crate) async fn access_token(&self) -> Result<Secret> {
988        let mut state = self.state.lock().await;
989        if Utc::now() + REFRESH_SKEW >= state.expires_at {
990            self.refresh_locked(&mut state).await?;
991        }
992        Ok(state.access_token.clone())
993    }
994
995    /// Forces a refresh, but only if `observed` is still the current token —
996    /// i.e. no other caller already refreshed while this caller was waiting
997    /// on the lock. Used as the reactive safety net after an HTTP 401 (clock
998    /// skew, or server-side revocation the proactive check can't see).
999    pub(crate) async fn force_refresh(&self, observed: &Secret) -> Result<Secret> {
1000        let mut state = self.state.lock().await;
1001        if state.access_token != *observed {
1002            return Ok(state.access_token.clone());
1003        }
1004        self.refresh_locked(&mut state).await?;
1005        Ok(state.access_token.clone())
1006    }
1007
1008    async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
1009        let response = refresh_access_token(
1010            &self.http,
1011            &self.token_endpoint,
1012            &self.client_id,
1013            self.client_secret.expose_secret(),
1014            self.refresh_token.expose_secret(),
1015        )
1016        .await?;
1017        state.access_token = response.access_token.into();
1018        let expires_in = response.expires_in.clamp(0, MAX_EXPIRES_IN_SECONDS);
1019        state.expires_at = Utc::now() + TimeDelta::seconds(expires_in);
1020        Ok(())
1021    }
1022}
1023
1024// ── Login orchestration ─────────────────────────────────────────────────
1025
1026/// Runs the OAuth2 authorization-code + PKCE login flow, persisting the
1027/// resulting refresh token to `~/.omni-dev/settings.json`.
1028pub async fn login(
1029    client_id: &str,
1030    client_secret: &Secret,
1031    scope: GmailScope,
1032    browser: &BrowserConfig,
1033) -> Result<GmailAuthStatus> {
1034    login_to(
1035        &Settings::get_settings_path()?,
1036        active_profile_from(&SystemEnv).as_deref(),
1037        client_id,
1038        client_secret,
1039        scope,
1040        browser,
1041        TOKEN_ENDPOINT,
1042    )
1043    .await
1044}
1045
1046/// [`login`], writing to an explicit settings-file path/profile and against
1047/// an explicit token endpoint — the test seam for a wiremock server.
1048pub(crate) async fn login_to(
1049    settings_path: &Path,
1050    profile: Option<&str>,
1051    client_id: &str,
1052    client_secret: &Secret,
1053    scope: GmailScope,
1054    browser: &BrowserConfig,
1055    token_endpoint: &str,
1056) -> Result<GmailAuthStatus> {
1057    let credentials =
1058        run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1059    save_credentials_to(settings_path, profile, &credentials)?;
1060    Ok(status_from_credentials(&credentials))
1061}
1062
1063/// [`login`], but honoring the named-account resolution added by issue
1064/// #1500: runs the same OAuth2 flow, then persists to
1065/// `gmail.accounts.<name>` when a named account is active instead of the
1066/// legacy `env`/profile map. `explicit` is the already-resolved
1067/// `--account`/[`account::GMAIL_ACCOUNT_ENV`] override, if any — resolved
1068/// via [`resolve_for_write`], so an explicit name need not already be
1069/// configured (this is how a new account is created).
1070pub(crate) async fn login_for(
1071    explicit: Option<&str>,
1072    client_id: &str,
1073    client_secret: &Secret,
1074    scope: GmailScope,
1075    browser: &BrowserConfig,
1076) -> Result<GmailAuthStatus> {
1077    let settings = Settings::load_or_warn_default();
1078    match resolve_for_write(&settings.gmail, explicit)? {
1079        ResolvedAccount::Legacy => {
1080            login_to(
1081                &Settings::get_settings_path()?,
1082                active_profile_from(&SystemEnv).as_deref(),
1083                client_id,
1084                client_secret,
1085                scope,
1086                browser,
1087                TOKEN_ENDPOINT,
1088            )
1089            .await
1090        }
1091        ResolvedAccount::Named(name) => {
1092            let credentials =
1093                run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1094            Settings::upsert_gmail_account(
1095                &Settings::get_settings_path()?,
1096                &name,
1097                &named_account_vars(&credentials),
1098            )?;
1099            Ok(status_from_credentials(&credentials))
1100        }
1101    }
1102}
1103
1104/// Runs the OAuth2 authorization-code + PKCE flow against `token_endpoint`
1105/// and returns the resulting credentials, without persisting them — the
1106/// shared core both [`login_to`] (legacy path) and [`login_for`]'s Named
1107/// branch build on.
1108async fn run_login_flow(
1109    client_id: &str,
1110    client_secret: &Secret,
1111    scope: GmailScope,
1112    browser: &BrowserConfig,
1113    token_endpoint: &str,
1114) -> Result<GmailCredentials> {
1115    let (listener, port) = bind_callback_listener(browser).await?;
1116    let redirect_uri = format!("http://127.0.0.1:{port}");
1117
1118    let pending = generate_pending_login();
1119    let challenge = code_challenge(&pending.code_verifier);
1120    let auth_url =
1121        build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1122    open_browser(&browser.launch, auth_url.as_str())?;
1123
1124    let callback = wait_for_callback(listener).await?;
1125    if let Some(error) = callback.error {
1126        return Err(GmailError::authorization_denied(
1127            &error,
1128            callback.error_description.as_deref(),
1129        )
1130        .into());
1131    }
1132    let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1133        return Err(GmailError::MalformedCallback.into());
1134    };
1135    // Plain equality, not constant-time: `state` is a CSRF nonce carried in
1136    // a browser-visible URL, not a secret — there's nothing for a timing
1137    // side-channel to extract here (unlike `constant_time_eq`'s real use
1138    // guarding a bridge auth token in `src/browser/auth.rs`).
1139    if returned_state != pending.state {
1140        return Err(GmailError::StateMismatch.into());
1141    }
1142
1143    let http = reqwest::Client::builder()
1144        .connect_timeout(crate::utils::http::connect_timeout())
1145        .read_timeout(crate::utils::http::read_timeout())
1146        .build()
1147        .context("Failed to build HTTP client")?;
1148    let tokens = exchange_code_for_tokens(
1149        &http,
1150        token_endpoint,
1151        client_id,
1152        client_secret.expose_secret(),
1153        &code,
1154        &pending.code_verifier,
1155        &redirect_uri,
1156    )
1157    .await?;
1158    let refresh_token = tokens
1159        .refresh_token
1160        .ok_or(GmailError::MalformedTokenResponse("refresh_token"))?;
1161    let granted_raw = tokens.scope.unwrap_or_default();
1162    let granted_scope = GmailScope::from_granted(&granted_raw).ok_or_else(|| {
1163        let received = if granted_raw.trim().is_empty() {
1164            "none".to_string()
1165        } else {
1166            granted_raw
1167                .split_whitespace()
1168                .collect::<Vec<_>>()
1169                .join(", ")
1170        };
1171        GmailError::NoGmailScopeGranted(received)
1172    })?;
1173
1174    Ok(GmailCredentials {
1175        client_id: client_id.to_string(),
1176        client_secret: client_secret.clone(),
1177        refresh_token: refresh_token.into(),
1178        scope: granted_scope,
1179    })
1180}
1181
1182/// Builds the "just authenticated" [`GmailAuthStatus`] from freshly-obtained
1183/// `credentials` (all fields present by construction).
1184fn status_from_credentials(credentials: &GmailCredentials) -> GmailAuthStatus {
1185    GmailAuthStatus {
1186        has_client_id: true,
1187        has_client_secret: true,
1188        has_refresh_token: true,
1189        scope: Some(credentials.scope.as_str().to_string()),
1190    }
1191}
1192
1193#[cfg(test)]
1194#[allow(clippy::unwrap_used, clippy::expect_used)]
1195mod tests {
1196    use std::fs;
1197    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1198    use std::sync::Arc;
1199
1200    use super::*;
1201
1202    // ── Pure helpers ─────────────────────────────────────────────────
1203
1204    #[test]
1205    fn scope_as_str_matches_google_scope_strings() {
1206        assert_eq!(GmailScope::ReadOnly.as_str(), SCOPE_READONLY);
1207        assert_eq!(GmailScope::Modify.as_str(), SCOPE_MODIFY);
1208    }
1209
1210    #[test]
1211    fn scope_from_granted_detects_modify_anywhere_in_the_list() {
1212        assert_eq!(
1213            GmailScope::from_granted(&format!("{SCOPE_READONLY} {SCOPE_MODIFY}")),
1214            Some(GmailScope::Modify)
1215        );
1216    }
1217
1218    #[test]
1219    fn scope_from_granted_detects_readonly_with_no_modify() {
1220        assert_eq!(
1221            GmailScope::from_granted(SCOPE_READONLY),
1222            Some(GmailScope::ReadOnly)
1223        );
1224    }
1225
1226    #[test]
1227    fn scope_from_granted_is_none_when_no_gmail_scope_present() {
1228        assert_eq!(GmailScope::from_granted("openid email profile"), None);
1229    }
1230
1231    #[test]
1232    fn scope_from_granted_is_none_for_empty_string() {
1233        assert_eq!(GmailScope::from_granted(""), None);
1234    }
1235
1236    #[test]
1237    fn scope_allows_modify() {
1238        assert!(!GmailScope::ReadOnly.allows_modify());
1239        assert!(GmailScope::Modify.allows_modify());
1240    }
1241
1242    #[test]
1243    fn code_challenge_matches_rfc_7636_test_vector() {
1244        // RFC 7636 Appendix B.1.
1245        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1246        let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1247        assert_eq!(code_challenge(verifier), expected);
1248    }
1249
1250    #[test]
1251    fn code_challenge_output_is_url_safe_no_padding() {
1252        let challenge = code_challenge("some-verifier-value");
1253        assert!(!challenge.contains('+'));
1254        assert!(!challenge.contains('/'));
1255        assert!(!challenge.contains('='));
1256    }
1257
1258    #[test]
1259    fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1260        let pending = generate_pending_login();
1261        assert_ne!(pending.state, pending.code_verifier);
1262        assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1263        assert!(pending
1264            .code_verifier
1265            .chars()
1266            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1267    }
1268
1269    #[test]
1270    fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1271        let url = build_authorization_url(
1272            "client-123",
1273            "http://127.0.0.1:5555",
1274            GmailScope::ReadOnly,
1275            "state-abc",
1276            "challenge-xyz",
1277        )
1278        .unwrap();
1279        let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1280        assert_eq!(query.get("client_id").unwrap(), "client-123");
1281        assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1282        assert_eq!(query.get("response_type").unwrap(), "code");
1283        assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1284        assert_eq!(query.get("state").unwrap(), "state-abc");
1285        assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1286        assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1287        assert_eq!(query.get("access_type").unwrap(), "offline");
1288        assert_eq!(query.get("prompt").unwrap(), "consent");
1289    }
1290
1291    #[test]
1292    fn build_authorization_url_uses_modify_scope_when_requested() {
1293        let url = build_authorization_url("c", "http://127.0.0.1:1", GmailScope::Modify, "s", "ch")
1294            .unwrap();
1295        let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1296        assert_eq!(query.get("scope").unwrap(), SCOPE_MODIFY);
1297    }
1298
1299    #[test]
1300    fn parse_callback_extracts_code_and_state() {
1301        let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1302        let result = parse_callback(request).unwrap();
1303        assert_eq!(result.code.as_deref(), Some("abc123"));
1304        assert_eq!(result.state.as_deref(), Some("xyz789"));
1305        assert!(result.error.is_none());
1306    }
1307
1308    #[test]
1309    fn parse_callback_extracts_error_and_error_description() {
1310        let request =
1311            "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1312        let result = parse_callback(request).unwrap();
1313        assert_eq!(result.error.as_deref(), Some("access_denied"));
1314        assert_eq!(result.error_description.as_deref(), Some("user declined"));
1315    }
1316
1317    #[test]
1318    fn parse_callback_missing_query_string_is_none() {
1319        assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1320        assert!(parse_callback("garbage").is_none());
1321    }
1322
1323    #[test]
1324    fn parse_callback_ignores_unrecognized_query_keys() {
1325        let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1326        let result = parse_callback(request).unwrap();
1327        assert_eq!(result.code.as_deref(), Some("abc"));
1328        assert_eq!(result.state.as_deref(), Some("xyz"));
1329    }
1330
1331    #[test]
1332    fn open_browser_manual_logs_and_succeeds() {
1333        assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1334    }
1335
1336    #[test]
1337    fn open_browser_command_substitutes_url_placeholder() {
1338        let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1339        assert!(open_browser(&launch, "https://example/auth").is_ok());
1340    }
1341
1342    #[test]
1343    fn open_browser_command_appends_url_when_no_placeholder() {
1344        let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1345        assert!(open_browser(&launch, "https://example/auth").is_ok());
1346    }
1347
1348    #[test]
1349    fn open_browser_command_passes_through_args_without_the_placeholder() {
1350        // A trailing flag with no `{url}` substring (e.g. `--verbose`) is
1351        // passed to the command unmodified, and the URL is still appended
1352        // since no arg claimed the placeholder.
1353        let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1354        assert!(open_browser(&launch, "https://example/auth").is_ok());
1355    }
1356
1357    #[test]
1358    fn open_browser_command_rejects_empty_args() {
1359        let launch = BrowserLaunch::Command(vec![]);
1360        let err = open_browser(&launch, "u").unwrap_err();
1361        assert!(err.to_string().contains("empty browser command"));
1362    }
1363
1364    // ── build_browser_config (issue #1505) ──────────────────────────────
1365
1366    fn assert_is_auto(config: BrowserConfig) {
1367        assert!(matches!(config.launch, BrowserLaunch::Auto));
1368    }
1369
1370    #[test]
1371    fn build_browser_config_defaults_to_auto_with_no_account() {
1372        assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1373    }
1374
1375    #[test]
1376    fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1377        let account = GmailAccountSettings {
1378            email_address: Some("alice@example.com".to_string()),
1379            ..GmailAccountSettings::default()
1380        };
1381        // chrome_profile_from_email is false, so the resolver must never run
1382        // even though email_address is set.
1383        assert_is_auto(
1384            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1385        );
1386    }
1387
1388    #[test]
1389    fn build_browser_config_uses_browser_command_verbatim() {
1390        let account = GmailAccountSettings {
1391            browser_command: Some("chrome --new-window {url}".to_string()),
1392            ..GmailAccountSettings::default()
1393        };
1394        let config =
1395            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1396        assert!(matches!(
1397            config.launch,
1398            BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1399        ));
1400    }
1401
1402    #[test]
1403    fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1404        let account = GmailAccountSettings {
1405            browser_command: Some("chrome {url}".to_string()),
1406            chrome_profile_from_email: true,
1407            email_address: Some("alice@example.com".to_string()),
1408            ..GmailAccountSettings::default()
1409        };
1410        let config =
1411            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1412        assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1413    }
1414
1415    #[test]
1416    fn build_browser_config_rejects_a_malformed_browser_command() {
1417        let account = GmailAccountSettings {
1418            browser_command: Some("chrome \"--flag".to_string()),
1419            ..GmailAccountSettings::default()
1420        };
1421        let err =
1422            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1423        assert!(err.to_string().contains("browser_command"));
1424    }
1425
1426    #[test]
1427    fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1428        let account = GmailAccountSettings {
1429            chrome_profile_from_email: true,
1430            email_address: Some("alice@example.com".to_string()),
1431            ..GmailAccountSettings::default()
1432        };
1433        let config = build_browser_config(Some(&account), |email| {
1434            assert_eq!(email, "alice@example.com");
1435            Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1436        })
1437        .unwrap();
1438        assert!(matches!(
1439            config.launch,
1440            BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1441        ));
1442    }
1443
1444    #[test]
1445    fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1446        let account = GmailAccountSettings {
1447            chrome_profile_from_email: true,
1448            email_address: Some("alice@example.com".to_string()),
1449            ..GmailAccountSettings::default()
1450        };
1451        assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1452    }
1453
1454    #[test]
1455    fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1456        let account = GmailAccountSettings {
1457            chrome_profile_from_email: true,
1458            ..GmailAccountSettings::default()
1459        };
1460        assert_is_auto(
1461            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1462        );
1463    }
1464
1465    // ── resolve_browser_config_for (issue #1505) ────────────────────────
1466
1467    #[test]
1468    fn resolve_browser_config_for_legacy_account_defaults_to_auto() {
1469        let guard = crate::gmail::test_support::EnvGuard::take();
1470        let _dir = guard.clear_credentials();
1471        std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
1472        std::env::set_var(GMAIL_CLIENT_SECRET, "legacy-secret");
1473        std::env::set_var(GMAIL_REFRESH_TOKEN, "legacy-refresh");
1474
1475        let gmail = GmailSettings::default();
1476        assert_is_auto(resolve_browser_config_for(&gmail, None).unwrap());
1477    }
1478
1479    #[test]
1480    fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1481        let guard = crate::gmail::test_support::EnvGuard::take();
1482        let _dir = guard.clear_credentials();
1483
1484        let mut gmail = GmailSettings::default();
1485        gmail.accounts.insert(
1486            "work".to_string(),
1487            GmailAccountSettings {
1488                email_address: Some("alice@example.com".to_string()),
1489                ..GmailAccountSettings::default()
1490            },
1491        );
1492
1493        // chrome_profile_from_email is false, so this never touches the
1494        // real chrome_profile::resolve_launch_command resolver.
1495        assert_is_auto(resolve_browser_config_for(&gmail, Some("work")).unwrap());
1496    }
1497
1498    // ── Loopback listener (real sockets, no wiremock) ───────────────────
1499
1500    #[tokio::test]
1501    async fn wait_for_callback_times_out_when_nothing_connects() {
1502        let browser = BrowserConfig::default();
1503        let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1504        let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1505            .await
1506            .unwrap_err();
1507        assert!(matches!(
1508            err.downcast_ref::<GmailError>(),
1509            Some(GmailError::CallbackTimeout(_))
1510        ));
1511    }
1512
1513    #[tokio::test]
1514    async fn wait_for_callback_reads_a_real_connection() {
1515        let browser = BrowserConfig::default();
1516        let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1517
1518        let client = tokio::spawn(async move {
1519            let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1520                .await
1521                .unwrap();
1522            stream
1523                .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1524                .await
1525                .unwrap();
1526        });
1527
1528        let result = wait_for_callback(listener).await.unwrap();
1529        client.await.unwrap();
1530        assert_eq!(result.code.as_deref(), Some("abc"));
1531        assert_eq!(result.state.as_deref(), Some("xyz"));
1532    }
1533
1534    #[tokio::test]
1535    async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1536        let browser = BrowserConfig::default();
1537        let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1538
1539        let client = tokio::spawn(async move {
1540            let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1541                .await
1542                .unwrap();
1543            stream.write_all(b"not an http request").await.unwrap();
1544        });
1545
1546        let err = wait_for_callback(listener).await.unwrap_err();
1547        client.await.unwrap();
1548        assert!(matches!(
1549            err.downcast_ref::<GmailError>(),
1550            Some(GmailError::MalformedCallback)
1551        ));
1552    }
1553
1554    // ── Token exchange / refresh (wiremock) ─────────────────────────────
1555
1556    #[tokio::test]
1557    async fn exchange_code_for_tokens_posts_expected_form_body() {
1558        let server = wiremock::MockServer::start().await;
1559        wiremock::Mock::given(wiremock::matchers::method("POST"))
1560            .and(wiremock::matchers::path("/token"))
1561            .and(wiremock::matchers::body_string_contains(
1562                "grant_type=authorization_code",
1563            ))
1564            .and(wiremock::matchers::body_string_contains(
1565                "code_verifier=verifier-1",
1566            ))
1567            .and(wiremock::matchers::body_string_contains(
1568                "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1569            ))
1570            .respond_with(
1571                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1572                    "access_token": "at-1",
1573                    "refresh_token": "rt-1",
1574                    "expires_in": 3600,
1575                    "scope": SCOPE_READONLY,
1576                })),
1577            )
1578            .expect(1)
1579            .mount(&server)
1580            .await;
1581
1582        let http = reqwest::Client::new();
1583        let token_endpoint = format!("{}/token", server.uri());
1584        let response = exchange_code_for_tokens(
1585            &http,
1586            &token_endpoint,
1587            "client-1",
1588            "secret-1",
1589            "code-1",
1590            "verifier-1",
1591            "http://127.0.0.1:9999",
1592        )
1593        .await
1594        .unwrap();
1595        assert_eq!(response.access_token, "at-1");
1596        assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1597    }
1598
1599    #[tokio::test]
1600    async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1601        let server = wiremock::MockServer::start().await;
1602        wiremock::Mock::given(wiremock::matchers::method("POST"))
1603            .respond_with(
1604                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1605                    "error": "invalid_grant",
1606                    "error_description": "Bad Request",
1607                })),
1608            )
1609            .mount(&server)
1610            .await;
1611
1612        let http = reqwest::Client::new();
1613        let err = exchange_code_for_tokens(
1614            &http,
1615            &server.uri(),
1616            "c",
1617            "s",
1618            "code",
1619            "verifier",
1620            "http://127.0.0.1:1",
1621        )
1622        .await
1623        .unwrap_err();
1624        assert!(err.to_string().contains("PKCE"));
1625    }
1626
1627    // ── login_to (end-to-end: state mismatch / access_denied) ───────────
1628    //
1629    // These exercise `login_to` itself rather than its sub-components in
1630    // isolation, so a regression in how it wires the loopback callback into
1631    // the state-mismatch/access_denied branches would actually be caught.
1632    // The callback port is picked by binding-then-dropping a std listener
1633    // (a well-known "reserve a free port" trick) so the test's connector
1634    // task can dial it directly — `login_to` binds the real listener before
1635    // opening the browser, so the connector retries briefly to cover the
1636    // small window before that bind completes.
1637    //
1638    // That reserve-then-drop is itself a TOCTOU race against the rest of the
1639    // parallel test suite: another test can grab the same ephemeral port
1640    // before `login_to`'s own bind runs, which fails outright rather than
1641    // retrying (see issue #1489). `run_with_port_retry` bounds a retry of
1642    // the whole reserve/connect/bind attempt on exactly that failure, so a
1643    // lost race just tries again with a fresh port instead of flaking.
1644
1645    async fn connect_and_send(port: u16, request_line: &[u8]) {
1646        let mut stream = loop {
1647            match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1648                Ok(stream) => break stream,
1649                Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1650            }
1651        };
1652        stream.write_all(request_line).await.unwrap();
1653    }
1654
1655    fn reserve_free_port() -> u16 {
1656        std::net::TcpListener::bind("127.0.0.1:0")
1657            .unwrap()
1658            .local_addr()
1659            .unwrap()
1660            .port()
1661    }
1662
1663    /// True if `err` is `bind_callback_listener`'s wrapped `AddrInUse` —
1664    /// i.e. some other process/test won the race for the port reserved by
1665    /// [`reserve_free_port`] before `login_to` could rebind it.
1666    fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1667        err.to_string()
1668            .contains("Failed to start the local OAuth callback listener")
1669    }
1670
1671    const PORT_RETRY_ATTEMPTS: u32 = 5;
1672
1673    /// Runs `attempt`, which reserves its own port via [`reserve_free_port`]
1674    /// and returns `login_to`'s result, retrying up to
1675    /// [`PORT_RETRY_ATTEMPTS`] times when the attempt loses the ephemeral
1676    /// port race (see the module comment above `connect_and_send`).
1677    async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<GmailAuthStatus>
1678    where
1679        F: FnMut(u16) -> Fut,
1680        Fut: std::future::Future<Output = Result<GmailAuthStatus>>,
1681    {
1682        for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1683            let result = attempt(reserve_free_port()).await;
1684            let is_retryable_conflict =
1685                matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1686            if !is_retryable_conflict {
1687                return result;
1688            }
1689        }
1690        unreachable!("loop always returns on its last iteration")
1691    }
1692
1693    /// Awaits `connector` normally, unless `result` shows `login_to` lost
1694    /// the callback-port race — in which case the connector, which will
1695    /// never see a connection on the now-taken port, is aborted instead of
1696    /// hung.
1697    async fn finish_connector(
1698        connector: tokio::task::JoinHandle<()>,
1699        result: &Result<GmailAuthStatus>,
1700    ) {
1701        match result {
1702            Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1703            _ => connector.await.unwrap(),
1704        }
1705    }
1706
1707    /// Polls `path` until it holds non-empty content, then returns it —
1708    /// used to read back the authorization URL that `open_browser`'s
1709    /// captured shell command writes asynchronously.
1710    async fn wait_for_captured_url(path: &Path) -> String {
1711        loop {
1712            if let Ok(contents) = std::fs::read_to_string(path) {
1713                if !contents.is_empty() {
1714                    return contents;
1715                }
1716            }
1717            tokio::time::sleep(Duration::from_millis(2)).await;
1718        }
1719    }
1720
1721    /// Shared body for the three `login_to_*` tests that drive a single
1722    /// fixed callback request line through `login_to` and expect it to
1723    /// error: reserves a port, spawns the connector, calls `login_to`, and
1724    /// retries the whole attempt (via [`run_with_port_retry`]) if it loses
1725    /// the ephemeral-port race. Asserts no settings file was written and
1726    /// returns the resulting error for the caller to inspect.
1727    async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
1728        std::fs::create_dir_all("tmp").ok();
1729        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1730        let settings_path = temp_dir.path().join("settings.json");
1731
1732        let result = run_with_port_retry(|port| {
1733            let settings_path = settings_path.clone();
1734            async move {
1735                let browser = BrowserConfig {
1736                    launch: BrowserLaunch::Manual,
1737                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1738                    callback_port: port,
1739                };
1740                let connector = tokio::spawn(connect_and_send(port, request_line));
1741
1742                let result = login_to(
1743                    &settings_path,
1744                    None,
1745                    "client-id",
1746                    &Secret::new("client-secret"),
1747                    GmailScope::ReadOnly,
1748                    &browser,
1749                    "http://127.0.0.1:1/token", // never reached — fails before exchange
1750                )
1751                .await;
1752
1753                finish_connector(connector, &result).await;
1754                result
1755            }
1756        })
1757        .await;
1758
1759        let err = result.unwrap_err();
1760        assert!(!settings_path.exists());
1761        err
1762    }
1763
1764    #[tokio::test]
1765    async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
1766        let attempts = AtomicU32::new(0);
1767
1768        let status = run_with_port_retry(|_port| {
1769            let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
1770            async move {
1771                if attempt_no == 0 {
1772                    Err(anyhow::anyhow!(
1773                        "Failed to start the local OAuth callback listener: address in use"
1774                    ))
1775                } else {
1776                    Ok(GmailAuthStatus {
1777                        has_client_id: true,
1778                        has_client_secret: true,
1779                        has_refresh_token: true,
1780                        scope: None,
1781                    })
1782                }
1783            }
1784        })
1785        .await
1786        .unwrap();
1787
1788        assert_eq!(attempts.load(Ordering::SeqCst), 2);
1789        assert!(status.has_client_id);
1790    }
1791
1792    #[tokio::test]
1793    async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
1794        let attempts = AtomicU32::new(0);
1795
1796        let result = run_with_port_retry(|_port| {
1797            attempts.fetch_add(1, Ordering::SeqCst);
1798            async move { Err(anyhow::anyhow!("some other failure")) }
1799        })
1800        .await;
1801
1802        assert_eq!(attempts.load(Ordering::SeqCst), 1);
1803        assert!(result.is_err());
1804    }
1805
1806    #[tokio::test]
1807    async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
1808        let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
1809        let connector = tokio::spawn(async move {
1810            tokio::time::sleep(Duration::from_secs(3600)).await;
1811            let _ = tx.send(());
1812        });
1813        let result: Result<GmailAuthStatus> = Err(anyhow::anyhow!(
1814            "Failed to start the local OAuth callback listener: address in use"
1815        ));
1816
1817        tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
1818            .await
1819            .expect("finish_connector must not wait for an aborted connector");
1820
1821        assert!(
1822            rx.try_recv().is_err(),
1823            "connector must have been aborted, not run to completion"
1824        );
1825    }
1826
1827    #[tokio::test]
1828    async fn finish_connector_awaits_connector_when_login_to_succeeds() {
1829        let ran = Arc::new(AtomicBool::new(false));
1830        let ran_clone = ran.clone();
1831        let connector = tokio::spawn(async move {
1832            ran_clone.store(true, Ordering::SeqCst);
1833        });
1834        let result = Ok(GmailAuthStatus {
1835            has_client_id: true,
1836            has_client_secret: true,
1837            has_refresh_token: true,
1838            scope: None,
1839        });
1840
1841        finish_connector(connector, &result).await;
1842
1843        assert!(ran.load(Ordering::SeqCst));
1844    }
1845
1846    #[tokio::test]
1847    async fn wait_for_captured_url_polls_until_content_is_written() {
1848        std::fs::create_dir_all("tmp").ok();
1849        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1850        let path = temp_dir.path().join("captured-url.txt");
1851
1852        let write_path = path.clone();
1853        tokio::spawn(async move {
1854            tokio::time::sleep(Duration::from_millis(10)).await;
1855            std::fs::write(&write_path, "").unwrap();
1856            tokio::time::sleep(Duration::from_millis(10)).await;
1857            std::fs::write(&write_path, "https://example.com/authorize").unwrap();
1858        });
1859
1860        let contents = wait_for_captured_url(&path).await;
1861        assert_eq!(contents, "https://example.com/authorize");
1862    }
1863
1864    #[tokio::test]
1865    async fn login_to_rejects_a_callback_with_mismatched_state() {
1866        let err =
1867            run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
1868
1869        assert!(matches!(
1870            err.downcast_ref::<GmailError>(),
1871            Some(GmailError::StateMismatch)
1872        ));
1873    }
1874
1875    #[tokio::test]
1876    async fn login_to_surfaces_access_denied_from_the_callback() {
1877        let err = run_login_to_expect_err(
1878            b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
1879        )
1880        .await;
1881
1882        match err.downcast_ref::<GmailError>() {
1883            Some(GmailError::AuthorizationDenied(message)) => {
1884                assert!(message.contains("access_denied"));
1885                assert!(message.contains("user declined"));
1886            }
1887            other => panic!("expected AuthorizationDenied, got {other:?}"),
1888        }
1889    }
1890
1891    #[tokio::test]
1892    async fn login_to_rejects_a_callback_missing_code_and_state() {
1893        let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
1894
1895        assert!(matches!(
1896            err.downcast_ref::<GmailError>(),
1897            Some(GmailError::MalformedCallback)
1898        ));
1899    }
1900
1901    #[tokio::test]
1902    async fn login_to_completes_full_success_flow_and_persists_credentials() {
1903        // Captures the real authorization URL `login_to` generates (with its
1904        // randomly-generated CSRF `state`) by pointing the browser launch at
1905        // a shell command instead of an actual browser: `open_browser`
1906        // substitutes `{url}` into the command's args and spawns it, so a
1907        // tiny `/bin/sh` one-liner writes the URL to a file we can read back
1908        // — letting this test drive the full success path (state echoed
1909        // correctly, token exchange, credential persistence) without ever
1910        // opening a real browser or needing to predict the CSRF nonce.
1911        std::fs::create_dir_all("tmp").ok();
1912        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1913        let capture_path = temp_dir.path().join("captured-url.txt");
1914        let settings_path = temp_dir.path().join("settings.json");
1915
1916        let server = wiremock::MockServer::start().await;
1917        wiremock::Mock::given(wiremock::matchers::method("POST"))
1918            .and(wiremock::matchers::path("/token"))
1919            .respond_with(
1920                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1921                    "access_token": "at-1",
1922                    "refresh_token": "rt-1",
1923                    "expires_in": 3600,
1924                    "scope": SCOPE_READONLY,
1925                })),
1926            )
1927            .expect(1)
1928            .mount(&server)
1929            .await;
1930
1931        let status = run_with_port_retry(|port| {
1932            let capture_path = capture_path.clone();
1933            let settings_path = settings_path.clone();
1934            let token_endpoint = format!("{}/token", server.uri());
1935            async move {
1936                let browser = BrowserConfig {
1937                    launch: BrowserLaunch::Command(vec![
1938                        "/bin/sh".to_string(),
1939                        "-c".to_string(),
1940                        format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
1941                        "{url}".to_string(),
1942                    ]),
1943                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1944                    callback_port: port,
1945                };
1946
1947                let connector = tokio::spawn(async move {
1948                    let auth_url = wait_for_captured_url(&capture_path).await;
1949                    let parsed = Url::parse(&auth_url).unwrap();
1950                    let state = parsed
1951                        .query_pairs()
1952                        .find(|(k, _)| k == "state")
1953                        .map(|(_, v)| v.into_owned())
1954                        .expect("authorization URL must carry a state param");
1955                    connect_and_send(
1956                        port,
1957                        format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
1958                    )
1959                    .await;
1960                });
1961
1962                let result = login_to(
1963                    &settings_path,
1964                    None,
1965                    "client-id",
1966                    &Secret::new("client-secret"),
1967                    GmailScope::ReadOnly,
1968                    &browser,
1969                    &token_endpoint,
1970                )
1971                .await;
1972
1973                finish_connector(connector, &result).await;
1974                result
1975            }
1976        })
1977        .await
1978        .unwrap();
1979
1980        assert!(status.has_client_id);
1981        assert!(status.has_client_secret);
1982        assert!(status.has_refresh_token);
1983        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
1984
1985        let saved = std::fs::read_to_string(&settings_path).unwrap();
1986        assert!(saved.contains("rt-1"));
1987        assert!(saved.contains("client-id"));
1988    }
1989
1990    /// Full mocked login round trip (real state nonce echoed back via the
1991    /// captured-authorization-URL trick, like
1992    /// `login_to_completes_full_success_flow_and_persists_credentials`
1993    /// above), with an injectable token-response body — the seam the
1994    /// scope-validation tests below use to simulate Google granting no
1995    /// Gmail scope.
1996    async fn run_login_to_with_token_response(
1997        token_response_body: serde_json::Value,
1998    ) -> (Result<GmailAuthStatus>, std::path::PathBuf) {
1999        std::fs::create_dir_all("tmp").ok();
2000        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2001        let capture_path = temp_dir.path().join("captured-url.txt");
2002        let settings_path = temp_dir.path().join("settings.json");
2003
2004        let server = wiremock::MockServer::start().await;
2005        wiremock::Mock::given(wiremock::matchers::method("POST"))
2006            .and(wiremock::matchers::path("/token"))
2007            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
2008            .expect(1)
2009            .mount(&server)
2010            .await;
2011
2012        let result = run_with_port_retry(|port| {
2013            let capture_path = capture_path.clone();
2014            let settings_path = settings_path.clone();
2015            let token_endpoint = format!("{}/token", server.uri());
2016            async move {
2017                let browser = BrowserConfig {
2018                    launch: BrowserLaunch::Command(vec![
2019                        "/bin/sh".to_string(),
2020                        "-c".to_string(),
2021                        format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2022                        "{url}".to_string(),
2023                    ]),
2024                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2025                    callback_port: port,
2026                };
2027
2028                let connector = tokio::spawn(async move {
2029                    let auth_url = wait_for_captured_url(&capture_path).await;
2030                    let parsed = Url::parse(&auth_url).unwrap();
2031                    let state = parsed
2032                        .query_pairs()
2033                        .find(|(k, _)| k == "state")
2034                        .map(|(_, v)| v.into_owned())
2035                        .expect("authorization URL must carry a state param");
2036                    connect_and_send(
2037                        port,
2038                        format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2039                    )
2040                    .await;
2041                });
2042
2043                let result = login_to(
2044                    &settings_path,
2045                    None,
2046                    "client-id",
2047                    &Secret::new("client-secret"),
2048                    GmailScope::ReadOnly,
2049                    &browser,
2050                    &token_endpoint,
2051                )
2052                .await;
2053
2054                finish_connector(connector, &result).await;
2055                result
2056            }
2057        })
2058        .await;
2059
2060        (result, settings_path)
2061    }
2062
2063    #[tokio::test]
2064    async fn login_to_rejects_a_grant_with_no_gmail_scope() {
2065        let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2066            "access_token": "at-1",
2067            "refresh_token": "rt-1",
2068            "expires_in": 3600,
2069            "scope": "openid email profile",
2070        }))
2071        .await;
2072
2073        let err = result.unwrap_err();
2074        assert!(matches!(
2075            err.downcast_ref::<GmailError>(),
2076            Some(GmailError::NoGmailScopeGranted(received)) if received == "openid, email, profile"
2077        ));
2078        assert!(!settings_path.exists());
2079    }
2080
2081    #[tokio::test]
2082    async fn login_to_rejects_a_grant_with_missing_scope_field() {
2083        let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2084            "access_token": "at-1",
2085            "refresh_token": "rt-1",
2086            "expires_in": 3600,
2087        }))
2088        .await;
2089
2090        let err = result.unwrap_err();
2091        assert!(matches!(
2092            err.downcast_ref::<GmailError>(),
2093            Some(GmailError::NoGmailScopeGranted(received)) if received == "none"
2094        ));
2095        assert!(!settings_path.exists());
2096    }
2097
2098    #[tokio::test]
2099    async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2100        let server = wiremock::MockServer::start().await;
2101        wiremock::Mock::given(wiremock::matchers::method("POST"))
2102            .and(wiremock::matchers::body_string_contains(
2103                "grant_type=refresh_token",
2104            ))
2105            .respond_with(
2106                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2107                    "access_token": "at-2",
2108                    "expires_in": 1800,
2109                })),
2110            )
2111            .expect(1)
2112            .mount(&server)
2113            .await;
2114
2115        let http = reqwest::Client::new();
2116        let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2117            .await
2118            .unwrap();
2119        assert_eq!(response.access_token, "at-2");
2120        assert_eq!(response.expires_in, 1800);
2121    }
2122
2123    #[tokio::test]
2124    async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2125        let server = wiremock::MockServer::start().await;
2126        wiremock::Mock::given(wiremock::matchers::method("POST"))
2127            .respond_with(
2128                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2129                    "error": "invalid_grant",
2130                })),
2131            )
2132            .mount(&server)
2133            .await;
2134
2135        let http = reqwest::Client::new();
2136        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2137            .await
2138            .unwrap_err();
2139        let msg = err.to_string();
2140        assert!(msg.contains("7 days"));
2141        assert!(msg.contains("Testing"));
2142    }
2143
2144    #[tokio::test]
2145    async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2146        let server = wiremock::MockServer::start().await;
2147        wiremock::Mock::given(wiremock::matchers::method("POST"))
2148            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2149            .mount(&server)
2150            .await;
2151
2152        let http = reqwest::Client::new();
2153        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2154            .await
2155            .unwrap_err();
2156        let msg = err.to_string();
2157        assert!(msg.contains("unparsable error body"));
2158        assert!(msg.contains("not json"));
2159    }
2160
2161    #[tokio::test]
2162    async fn token_request_propagates_network_errors() {
2163        let http = reqwest::Client::new();
2164        let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2165            .await
2166            .unwrap_err();
2167        assert!(err.to_string().contains("Failed to send token request"));
2168    }
2169
2170    #[tokio::test]
2171    async fn token_request_errors_on_unparsable_response_body() {
2172        let server = wiremock::MockServer::start().await;
2173        wiremock::Mock::given(wiremock::matchers::method("POST"))
2174            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2175            .mount(&server)
2176            .await;
2177
2178        let http = reqwest::Client::new();
2179        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2180            .await
2181            .unwrap_err();
2182        assert!(err.to_string().contains("Failed to parse"));
2183    }
2184
2185    // ── GmailSession ─────────────────────────────────────────────────
2186
2187    fn test_credentials() -> GmailCredentials {
2188        GmailCredentials {
2189            client_id: "client-1".to_string(),
2190            client_secret: "secret-1".into(),
2191            refresh_token: "refresh-1".into(),
2192            scope: GmailScope::ReadOnly,
2193        }
2194    }
2195
2196    #[test]
2197    fn named_account_vars_maps_credentials_to_json_string_values() {
2198        assert_eq!(
2199            named_account_vars(&test_credentials()),
2200            [
2201                (
2202                    "client_id",
2203                    serde_json::Value::String("client-1".to_string())
2204                ),
2205                (
2206                    "client_secret",
2207                    serde_json::Value::String("secret-1".to_string())
2208                ),
2209                (
2210                    "refresh_token",
2211                    serde_json::Value::String("refresh-1".to_string())
2212                ),
2213                (
2214                    "scope",
2215                    serde_json::Value::String(SCOPE_READONLY.to_string())
2216                ),
2217            ]
2218        );
2219    }
2220
2221    #[tokio::test]
2222    async fn access_token_refreshes_on_first_call() {
2223        let server = wiremock::MockServer::start().await;
2224        wiremock::Mock::given(wiremock::matchers::method("POST"))
2225            .respond_with(
2226                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2227                    "access_token": "at-first",
2228                    "expires_in": 3600,
2229                })),
2230            )
2231            .expect(1)
2232            .mount(&server)
2233            .await;
2234
2235        let session = GmailSession::new_with_token_endpoint(
2236            reqwest::Client::new(),
2237            &test_credentials(),
2238            &server.uri(),
2239        );
2240        let token = session.access_token().await.unwrap();
2241        assert_eq!(token.expose_secret(), "at-first");
2242    }
2243
2244    #[tokio::test]
2245    async fn access_token_reuses_cached_token_within_skew_window() {
2246        let server = wiremock::MockServer::start().await;
2247        wiremock::Mock::given(wiremock::matchers::method("POST"))
2248            .respond_with(
2249                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2250                    "access_token": "at-cached",
2251                    "expires_in": 3600,
2252                })),
2253            )
2254            .expect(1)
2255            .mount(&server)
2256            .await;
2257
2258        let session = GmailSession::new_with_token_endpoint(
2259            reqwest::Client::new(),
2260            &test_credentials(),
2261            &server.uri(),
2262        );
2263        let first = session.access_token().await.unwrap();
2264        let second = session.access_token().await.unwrap();
2265        assert_eq!(first, second);
2266    }
2267
2268    #[tokio::test]
2269    async fn access_token_proactively_refreshes_when_within_skew_window() {
2270        let server = wiremock::MockServer::start().await;
2271        wiremock::Mock::given(wiremock::matchers::method("POST"))
2272            .respond_with(
2273                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2274                    "access_token": "at-short",
2275                    // Less than REFRESH_SKEW (60s), so the second call also refreshes.
2276                    "expires_in": 30,
2277                })),
2278            )
2279            .expect(2)
2280            .mount(&server)
2281            .await;
2282
2283        let session = GmailSession::new_with_token_endpoint(
2284            reqwest::Client::new(),
2285            &test_credentials(),
2286            &server.uri(),
2287        );
2288        session.access_token().await.unwrap();
2289        session.access_token().await.unwrap();
2290    }
2291
2292    #[tokio::test]
2293    async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2294        let server = wiremock::MockServer::start().await;
2295        wiremock::Mock::given(wiremock::matchers::method("POST"))
2296            .respond_with(
2297                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2298                    "access_token": "at-overflow",
2299                    // Unvalidated, this overflows both TimeDelta::seconds and
2300                    // the subsequent DateTime<Utc> addition (#1531).
2301                    "expires_in": i64::MAX,
2302                })),
2303            )
2304            .expect(1)
2305            .mount(&server)
2306            .await;
2307
2308        let session = GmailSession::new_with_token_endpoint(
2309            reqwest::Client::new(),
2310            &test_credentials(),
2311            &server.uri(),
2312        );
2313        let token = session.access_token().await.unwrap();
2314        assert_eq!(token.expose_secret(), "at-overflow");
2315    }
2316
2317    #[tokio::test]
2318    async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2319        let server = wiremock::MockServer::start().await;
2320        wiremock::Mock::given(wiremock::matchers::method("POST"))
2321            .respond_with(
2322                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2323                    "access_token": "at-negative",
2324                    "expires_in": -3600,
2325                })),
2326            )
2327            // Negative expires_in clamps to 0, so the token is already
2328            // stale and the second call refreshes again.
2329            .expect(2)
2330            .mount(&server)
2331            .await;
2332
2333        let session = GmailSession::new_with_token_endpoint(
2334            reqwest::Client::new(),
2335            &test_credentials(),
2336            &server.uri(),
2337        );
2338        session.access_token().await.unwrap();
2339        session.access_token().await.unwrap();
2340    }
2341
2342    #[tokio::test]
2343    async fn force_refresh_concurrent_callers_do_not_stampede() {
2344        let server = wiremock::MockServer::start().await;
2345        wiremock::Mock::given(wiremock::matchers::method("POST"))
2346            .respond_with(
2347                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2348                    "access_token": "at-bootstrap",
2349                    "expires_in": 3600,
2350                })),
2351            )
2352            .up_to_n_times(1)
2353            .with_priority(1)
2354            .mount(&server)
2355            .await;
2356        wiremock::Mock::given(wiremock::matchers::method("POST"))
2357            .respond_with(
2358                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2359                    "access_token": "at-refreshed",
2360                    "expires_in": 3600,
2361                })),
2362            )
2363            .expect(1)
2364            .with_priority(2)
2365            .mount(&server)
2366            .await;
2367
2368        let session = GmailSession::new_with_token_endpoint(
2369            reqwest::Client::new(),
2370            &test_credentials(),
2371            &server.uri(),
2372        );
2373        let bootstrapped = session.access_token().await.unwrap();
2374        assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2375
2376        let (a, b) = tokio::join!(
2377            session.force_refresh(&bootstrapped),
2378            session.force_refresh(&bootstrapped)
2379        );
2380        let a = a.unwrap();
2381        let b = b.unwrap();
2382        assert_eq!(a, b);
2383        assert_eq!(a.expose_secret(), "at-refreshed");
2384    }
2385
2386    #[tokio::test]
2387    async fn force_refresh_skips_network_call_when_token_already_rotated() {
2388        let server = wiremock::MockServer::start().await;
2389        wiremock::Mock::given(wiremock::matchers::method("POST"))
2390            .respond_with(
2391                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2392                    "access_token": "at-a",
2393                    "expires_in": 3600,
2394                })),
2395            )
2396            .up_to_n_times(1)
2397            .with_priority(1)
2398            .mount(&server)
2399            .await;
2400        wiremock::Mock::given(wiremock::matchers::method("POST"))
2401            .respond_with(
2402                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2403                    "access_token": "at-b",
2404                    "expires_in": 3600,
2405                })),
2406            )
2407            .expect(1)
2408            .with_priority(2)
2409            .mount(&server)
2410            .await;
2411
2412        let session = GmailSession::new_with_token_endpoint(
2413            reqwest::Client::new(),
2414            &test_credentials(),
2415            &server.uri(),
2416        );
2417        let stale = Secret::new("at-never-issued");
2418        let bootstrapped = session.access_token().await.unwrap();
2419        assert_eq!(bootstrapped.expose_secret(), "at-a");
2420
2421        // `stale` was never the live token, so this should reuse the current
2422        // one without an extra network call.
2423        let result = session.force_refresh(&stale).await.unwrap();
2424        assert_eq!(result, bootstrapped);
2425
2426        // A real force_refresh against the actual current token does POST.
2427        let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2428        assert_eq!(refreshed.expose_secret(), "at-b");
2429    }
2430
2431    // ── Secret non-leakage ───────────────────────────────────────────
2432
2433    #[test]
2434    fn gmail_credentials_debug_redacts_client_secret_and_refresh_token() {
2435        let creds = GmailCredentials {
2436            client_id: "client-visible".to_string(),
2437            client_secret: "sekret-client-secret".into(),
2438            refresh_token: "sekret-refresh-token".into(),
2439            scope: GmailScope::ReadOnly,
2440        };
2441        let debug = format!("{creds:?}");
2442        assert!(debug.contains("GmailCredentials"));
2443        assert!(debug.contains("client-visible"));
2444        assert!(!debug.contains("sekret-client-secret"));
2445        assert!(!debug.contains("sekret-refresh-token"));
2446        assert!(debug.contains("client_secret: <redacted>"));
2447        assert!(debug.contains("refresh_token: <redacted>"));
2448    }
2449
2450    #[test]
2451    fn gmail_auth_status_yaml_serialization_contains_no_secret_values() {
2452        let env = crate::test_support::env::MapEnv::new()
2453            .with(GMAIL_CLIENT_ID, "client-id-value")
2454            .with(GMAIL_CLIENT_SECRET, "sekret-do-not-leak")
2455            .with(GMAIL_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2456            .with(GMAIL_SCOPE, SCOPE_READONLY);
2457        let status = status_with(&env);
2458        let yaml = serde_yaml::to_string(&status).unwrap();
2459        assert!(!yaml.contains("sekret-do-not-leak"));
2460        assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2461    }
2462
2463    // ── Env-DI boundary tests ────────────────────────────────────────
2464
2465    use crate::test_support::env::MapEnv;
2466
2467    #[test]
2468    fn refresh_token_can_come_from_a_file() {
2469        let (_dir, path) = crate::test_support::env::secret_file("rt-from-file\n");
2470        let env = MapEnv::new()
2471            .with(GMAIL_CLIENT_ID, "id")
2472            .with(GMAIL_CLIENT_SECRET, "secret")
2473            .with("GMAIL_REFRESH_TOKEN_FILE", &path);
2474        let creds = load_credentials_with(&env).unwrap();
2475        assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
2476        assert!(status_with(&env).has_refresh_token);
2477    }
2478
2479    #[test]
2480    fn status_reports_all_false_when_nothing_configured() {
2481        let status = status_with(&MapEnv::new());
2482        assert!(!status.has_client_id);
2483        assert!(!status.has_client_secret);
2484        assert!(!status.has_refresh_token);
2485        assert_eq!(status.scope, None);
2486    }
2487
2488    #[test]
2489    fn status_reports_scope_when_present() {
2490        let env = MapEnv::new().with(GMAIL_SCOPE, SCOPE_MODIFY);
2491        let status = status_with(&env);
2492        assert_eq!(status.scope.as_deref(), Some(SCOPE_MODIFY));
2493    }
2494
2495    #[test]
2496    fn load_credentials_errors_when_client_id_missing() {
2497        let env = MapEnv::new()
2498            .with(GMAIL_CLIENT_SECRET, "s")
2499            .with(GMAIL_REFRESH_TOKEN, "r");
2500        let err = load_credentials_with(&env).unwrap_err();
2501        assert!(err.to_string().contains("not configured"));
2502    }
2503
2504    #[test]
2505    fn load_credentials_errors_when_client_secret_missing() {
2506        let env = MapEnv::new()
2507            .with(GMAIL_CLIENT_ID, "c")
2508            .with(GMAIL_REFRESH_TOKEN, "r");
2509        assert!(load_credentials_with(&env).is_err());
2510    }
2511
2512    #[test]
2513    fn load_credentials_errors_when_refresh_token_missing() {
2514        let env = MapEnv::new()
2515            .with(GMAIL_CLIENT_ID, "c")
2516            .with(GMAIL_CLIENT_SECRET, "s");
2517        assert!(load_credentials_with(&env).is_err());
2518    }
2519
2520    #[test]
2521    fn load_credentials_succeeds_with_all_three_present() {
2522        let env = MapEnv::new()
2523            .with(GMAIL_CLIENT_ID, "c")
2524            .with(GMAIL_CLIENT_SECRET, "s")
2525            .with(GMAIL_REFRESH_TOKEN, "r");
2526        let creds = load_credentials_with(&env).unwrap();
2527        assert_eq!(creds.client_id, "c");
2528        assert_eq!(creds.scope, GmailScope::ReadOnly);
2529    }
2530
2531    /// Save + remove round-trip against injected settings-file paths — no
2532    /// `HOME` mutation, so the test needs no lock.
2533    #[test]
2534    fn save_then_remove_round_trip() {
2535        // ── Part 1: creates file from scratch ──────────────────────
2536        {
2537            let temp_dir = {
2538                std::fs::create_dir_all("tmp").ok();
2539                tempfile::TempDir::new_in("tmp").unwrap()
2540            };
2541            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2542
2543            let creds = GmailCredentials {
2544                client_id: "client-1".to_string(),
2545                client_secret: "secret-1".into(),
2546                refresh_token: "refresh-1".into(),
2547                scope: GmailScope::ReadOnly,
2548            };
2549            save_credentials_to(&settings_path, None, &creds).unwrap();
2550
2551            assert!(settings_path.exists());
2552            let val: serde_json::Value =
2553                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2554            assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "client-1");
2555            assert_eq!(val["env"]["GMAIL_CLIENT_SECRET"], "secret-1");
2556            assert_eq!(val["env"]["GMAIL_REFRESH_TOKEN"], "refresh-1");
2557            assert_eq!(val["env"]["GMAIL_SCOPE"], SCOPE_READONLY);
2558
2559            #[cfg(unix)]
2560            {
2561                use std::os::unix::fs::PermissionsExt;
2562                let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2563                assert_eq!(mode & 0o777, 0o600);
2564            }
2565        }
2566
2567        // ── Part 2: merges into existing settings ──────────────────
2568        {
2569            let temp_dir = {
2570                std::fs::create_dir_all("tmp").ok();
2571                tempfile::TempDir::new_in("tmp").unwrap()
2572            };
2573            let omni_dir = temp_dir.path().join(".omni-dev");
2574            fs::create_dir_all(&omni_dir).unwrap();
2575            let settings_path = omni_dir.join("settings.json");
2576            fs::write(
2577                &settings_path,
2578                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2579            )
2580            .unwrap();
2581
2582            let creds = GmailCredentials {
2583                client_id: "client-2".to_string(),
2584                client_secret: "secret-2".into(),
2585                refresh_token: "refresh-2".into(),
2586                scope: GmailScope::Modify,
2587            };
2588            save_credentials_to(&settings_path, None, &creds).unwrap();
2589
2590            let val: serde_json::Value =
2591                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2592            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2593            assert_eq!(val["extra"], true);
2594            assert_eq!(val["env"]["GMAIL_SCOPE"], SCOPE_MODIFY);
2595        }
2596
2597        // ── Part 3: remove clears the four keys, preserves others ──
2598        {
2599            let temp_dir = {
2600                std::fs::create_dir_all("tmp").ok();
2601                tempfile::TempDir::new_in("tmp").unwrap()
2602            };
2603            let omni_dir = temp_dir.path().join(".omni-dev");
2604            fs::create_dir_all(&omni_dir).unwrap();
2605            let settings_path = omni_dir.join("settings.json");
2606            fs::write(
2607                &settings_path,
2608                r#"{"env": {
2609                    "GMAIL_CLIENT_ID": "a",
2610                    "GMAIL_CLIENT_SECRET": "b",
2611                    "GMAIL_REFRESH_TOKEN": "c",
2612                    "GMAIL_SCOPE": "d",
2613                    "OTHER_KEY": "keep"
2614                }}"#,
2615            )
2616            .unwrap();
2617
2618            let removed = remove_credentials_at(&settings_path, None).unwrap();
2619            assert!(removed);
2620
2621            let val: serde_json::Value =
2622                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2623            assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2624            assert!(val["env"].get("GMAIL_CLIENT_SECRET").is_none());
2625            assert!(val["env"].get("GMAIL_REFRESH_TOKEN").is_none());
2626            assert!(val["env"].get("GMAIL_SCOPE").is_none());
2627            assert_eq!(val["env"]["OTHER_KEY"], "keep");
2628        }
2629
2630        // ── Part 4: remove returns false when nothing to remove ────
2631        {
2632            let temp_dir = {
2633                std::fs::create_dir_all("tmp").ok();
2634                tempfile::TempDir::new_in("tmp").unwrap()
2635            };
2636            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2637            let removed = remove_credentials_at(&settings_path, None).unwrap();
2638            assert!(!removed);
2639        }
2640    }
2641
2642    /// Save + remove round-trip against a profile-targeted env map.
2643    #[test]
2644    fn save_then_remove_round_trip_in_profile() {
2645        let temp_dir = {
2646            std::fs::create_dir_all("tmp").ok();
2647            tempfile::TempDir::new_in("tmp").unwrap()
2648        };
2649        let omni_dir = temp_dir.path().join(".omni-dev");
2650        fs::create_dir_all(&omni_dir).unwrap();
2651        let settings_path = omni_dir.join("settings.json");
2652        fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
2653
2654        let creds = GmailCredentials {
2655            client_id: "client-p".to_string(),
2656            client_secret: "secret-p".into(),
2657            refresh_token: "refresh-p".into(),
2658            scope: GmailScope::ReadOnly,
2659        };
2660        save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
2661
2662        let val: serde_json::Value =
2663            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2664        assert_eq!(
2665            val["profiles"]["work"]["env"]["GMAIL_CLIENT_ID"],
2666            "client-p"
2667        );
2668        assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2669        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2670
2671        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2672        assert!(removed);
2673        let val: serde_json::Value =
2674            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2675        assert!(val["profiles"]["work"]["env"]
2676            .get("GMAIL_CLIENT_ID")
2677            .is_none());
2678
2679        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2680        assert!(!removed);
2681    }
2682
2683    /// The production wrappers resolve `~/.omni-dev/settings.json` from
2684    /// `HOME` and the active profile from `OMNI_DEV_PROFILE`, so this one
2685    /// test must redirect both via [`crate::gmail::test_support::EnvGuard`].
2686    #[test]
2687    fn save_and_remove_credentials_resolve_default_settings_path() {
2688        let guard = crate::gmail::test_support::EnvGuard::take();
2689        let dir = guard.clear_credentials();
2690
2691        let creds = GmailCredentials {
2692            client_id: "wrapper-client".to_string(),
2693            client_secret: "wrapper-secret".into(),
2694            refresh_token: "wrapper-refresh".into(),
2695            scope: GmailScope::ReadOnly,
2696        };
2697        save_credentials(&creds).unwrap();
2698
2699        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2700        let val: serde_json::Value =
2701            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2702        assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "wrapper-client");
2703
2704        assert!(remove_credentials().unwrap());
2705        let val: serde_json::Value =
2706            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2707        assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2708    }
2709
2710    // ── named-account dispatch (issue #1500) ───────────────────────────
2711    //
2712    // These exercise the production `*_for` wrappers, so — like
2713    // `save_and_remove_credentials_resolve_default_settings_path` above —
2714    // they must redirect `HOME` via `EnvGuard`.
2715
2716    // ── named-account `_file` secrets (#2008) ────────────────────────────
2717
2718    /// Writes `contents` to `name` under `dir`, owner-only, and returns its
2719    /// absolute path as a settings value.
2720    fn owner_only_file(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
2721        let path = dir.path().join(name);
2722        std::fs::write(&path, contents).unwrap();
2723        #[cfg(unix)]
2724        {
2725            use std::os::unix::fs::PermissionsExt;
2726            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2727        }
2728        path.to_str().unwrap().to_string()
2729    }
2730
2731    fn named_settings(account: GmailAccountSettings) -> GmailSettings {
2732        GmailSettings {
2733            accounts: std::iter::once(("work".to_string(), account)).collect(),
2734            ..GmailSettings::default()
2735        }
2736    }
2737
2738    #[test]
2739    fn load_named_credentials_reads_secrets_from_their_files() {
2740        let dir = tempfile::tempdir().unwrap();
2741        let account = GmailAccountSettings {
2742            client_id: Some("id".to_string()),
2743            client_secret_file: Some(owner_only_file(&dir, "cs", "cs-from-file\n")),
2744            refresh_token_file: Some(owner_only_file(&dir, "rt", "rt-from-file\n")),
2745            ..GmailAccountSettings::default()
2746        };
2747        let creds = load_named_credentials(&named_settings(account), "work").unwrap();
2748        assert_eq!(creds.client_secret.expose_secret(), "cs-from-file");
2749        assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
2750    }
2751
2752    #[test]
2753    fn load_named_credentials_rejects_a_field_and_its_file_together() {
2754        let dir = tempfile::tempdir().unwrap();
2755        let account = GmailAccountSettings {
2756            client_id: Some("id".to_string()),
2757            client_secret: Some("cs".to_string()),
2758            refresh_token: Some("rt".to_string()),
2759            refresh_token_file: Some(owner_only_file(&dir, "rt", "rt\n")),
2760            ..GmailAccountSettings::default()
2761        };
2762        let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
2763        assert_eq!(
2764            err.to_string(),
2765            "both gmail.accounts.work.refresh_token and gmail.accounts.work.refresh_token_file \
2766             are set; set only one of them"
2767        );
2768    }
2769
2770    #[test]
2771    fn load_named_credentials_rejects_a_relative_secret_file() {
2772        let account = GmailAccountSettings {
2773            client_id: Some("id".to_string()),
2774            client_secret_file: Some("secrets/cs".to_string()),
2775            refresh_token: Some("rt".to_string()),
2776            ..GmailAccountSettings::default()
2777        };
2778        let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
2779        assert!(
2780            err.to_string()
2781                .contains("gmail.accounts.work.client_secret_file must be an absolute path"),
2782            "{err}"
2783        );
2784    }
2785
2786    #[test]
2787    fn load_named_credentials_rejects_a_client_secret_json_file() {
2788        let dir = tempfile::tempdir().unwrap();
2789        let json = owner_only_file(&dir, "client_secret.json", "{\"installed\": {}}\n");
2790        let account = GmailAccountSettings {
2791            client_id: Some("id".to_string()),
2792            client_secret_file: Some(json),
2793            refresh_token: Some("rt".to_string()),
2794            ..GmailAccountSettings::default()
2795        };
2796        let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
2797        let message = err.to_string();
2798        assert!(
2799            message.contains("gmail.accounts.work.client_secret_file"),
2800            "{message}"
2801        );
2802        assert!(message.contains("gmail auth import"), "{message}");
2803        assert!(!message.contains("installed"), "{message}");
2804    }
2805
2806    #[cfg(feature = "mcp")]
2807    #[test]
2808    fn status_from_named_counts_a_secret_file_as_present() {
2809        let account = GmailAccountSettings {
2810            client_id: Some("id".to_string()),
2811            client_secret_file: Some("/never/read".to_string()),
2812            refresh_token_file: Some("/never/read".to_string()),
2813            ..GmailAccountSettings::default()
2814        };
2815        let status = status_from_named(&named_settings(account), "work");
2816        assert!(status.has_client_secret);
2817        assert!(status.has_refresh_token);
2818    }
2819
2820    #[test]
2821    fn load_credentials_for_named_reads_from_gmail_accounts() {
2822        let guard = crate::gmail::test_support::EnvGuard::take();
2823        let dir = guard.clear_credentials();
2824        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2825        Settings::upsert_gmail_account(
2826            &settings_path,
2827            "work",
2828            &[
2829                (
2830                    "client_id",
2831                    serde_json::Value::String("work-id".to_string()),
2832                ),
2833                (
2834                    "client_secret",
2835                    serde_json::Value::String("work-secret".to_string()),
2836                ),
2837                (
2838                    "refresh_token",
2839                    serde_json::Value::String("work-refresh".to_string()),
2840                ),
2841                ("scope", serde_json::Value::String(SCOPE_MODIFY.to_string())),
2842            ],
2843        )
2844        .unwrap();
2845
2846        let creds = load_credentials_for(Some("work")).unwrap();
2847        assert_eq!(creds.client_id, "work-id");
2848        assert_eq!(creds.client_secret.expose_secret(), "work-secret");
2849        assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
2850        assert_eq!(creds.scope, GmailScope::Modify);
2851    }
2852
2853    #[test]
2854    fn load_credentials_for_unknown_named_account_errors() {
2855        let guard = crate::gmail::test_support::EnvGuard::take();
2856        let dir = guard.clear_credentials();
2857        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2858        Settings::upsert_gmail_account(
2859            &settings_path,
2860            "work",
2861            &[(
2862                "client_id",
2863                serde_json::Value::String("work-id".to_string()),
2864            )],
2865        )
2866        .unwrap();
2867
2868        let err = load_credentials_for(Some("bogus")).unwrap_err();
2869        assert!(err.to_string().contains("unknown Gmail account 'bogus'"));
2870    }
2871
2872    #[test]
2873    fn load_credentials_for_falls_back_to_legacy_when_accounts_empty() {
2874        let guard = crate::gmail::test_support::EnvGuard::take();
2875        let _dir = guard.clear_credentials();
2876        std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
2877        std::env::set_var(GMAIL_CLIENT_SECRET, "legacy-secret");
2878        std::env::set_var(GMAIL_REFRESH_TOKEN, "legacy-refresh");
2879
2880        let creds = load_credentials_for(None).unwrap();
2881        assert_eq!(creds.client_id, "legacy-id");
2882    }
2883
2884    #[test]
2885    fn load_credentials_for_none_honors_ambient_account_env_var() {
2886        let guard = crate::gmail::test_support::EnvGuard::take();
2887        let dir = guard.clear_credentials();
2888        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2889        Settings::upsert_gmail_account(
2890            &settings_path,
2891            "work",
2892            &[
2893                (
2894                    "client_id",
2895                    serde_json::Value::String("work-id".to_string()),
2896                ),
2897                (
2898                    "client_secret",
2899                    serde_json::Value::String("work-secret".to_string()),
2900                ),
2901                (
2902                    "refresh_token",
2903                    serde_json::Value::String("work-refresh".to_string()),
2904                ),
2905            ],
2906        )
2907        .unwrap();
2908        std::env::set_var(account::GMAIL_ACCOUNT_ENV, "work");
2909
2910        let creds = load_credentials_for(None).unwrap();
2911        assert_eq!(creds.client_id, "work-id");
2912    }
2913
2914    #[test]
2915    fn remove_credentials_for_named_removes_whole_account() {
2916        let guard = crate::gmail::test_support::EnvGuard::take();
2917        let dir = guard.clear_credentials();
2918        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2919        Settings::upsert_gmail_account(
2920            &settings_path,
2921            "work",
2922            &[("client_id", serde_json::Value::String("id".to_string()))],
2923        )
2924        .unwrap();
2925
2926        assert!(remove_credentials_for(Some("work")).unwrap());
2927        let val: serde_json::Value =
2928            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2929        assert!(val["gmail"]["accounts"].get("work").is_none());
2930    }
2931
2932    #[cfg(feature = "mcp")]
2933    #[test]
2934    fn status_for_named_reports_presence_from_account() {
2935        let guard = crate::gmail::test_support::EnvGuard::take();
2936        let dir = guard.clear_credentials();
2937        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2938        Settings::upsert_gmail_account(
2939            &settings_path,
2940            "work",
2941            &[
2942                ("client_id", serde_json::Value::String("id".to_string())),
2943                (
2944                    "scope",
2945                    serde_json::Value::String(SCOPE_READONLY.to_string()),
2946                ),
2947            ],
2948        )
2949        .unwrap();
2950
2951        let status = status_for(Some("work")).unwrap();
2952        assert!(status.has_client_id);
2953        assert!(!status.has_client_secret);
2954        assert!(!status.has_refresh_token);
2955        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2956    }
2957
2958    #[cfg(feature = "mcp")]
2959    #[test]
2960    fn status_for_legacy_matches_status_with_when_accounts_empty() {
2961        let guard = crate::gmail::test_support::EnvGuard::take();
2962        let _dir = guard.clear_credentials();
2963        std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
2964
2965        let status = status_for(None).unwrap();
2966        assert!(status.has_client_id);
2967        assert!(!status.has_refresh_token);
2968    }
2969
2970    #[test]
2971    fn load_credentials_legacy_ignores_active_named_account() {
2972        let guard = crate::gmail::test_support::EnvGuard::take();
2973        let dir = guard.clear_credentials();
2974        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2975
2976        // A named default account is configured...
2977        Settings::set_gmail_default_account(&settings_path, Some("work")).unwrap();
2978        Settings::upsert_gmail_account(
2979            &settings_path,
2980            "work",
2981            &[
2982                (
2983                    "client_id",
2984                    serde_json::Value::String("named-id".to_string()),
2985                ),
2986                (
2987                    "client_secret",
2988                    serde_json::Value::String("named-secret".to_string()),
2989                ),
2990                (
2991                    "refresh_token",
2992                    serde_json::Value::String("named-refresh".to_string()),
2993                ),
2994            ],
2995        )
2996        .unwrap();
2997        // ...and legacy credentials also exist in the base `env` map (not
2998        // process env, so rule 1's literal-env bypass does not apply).
2999        Settings::upsert_env_vars(
3000            &settings_path,
3001            &[
3002                (GMAIL_CLIENT_ID, "legacy-id"),
3003                (GMAIL_CLIENT_SECRET, "legacy-secret"),
3004                (GMAIL_REFRESH_TOKEN, "legacy-refresh"),
3005            ],
3006        )
3007        .unwrap();
3008
3009        // The account-aware wrapper resolves to the named default...
3010        let via_resolution = load_credentials_for(None).unwrap();
3011        assert_eq!(via_resolution.client_id, "named-id");
3012
3013        // ...but load_credentials_legacy forces the legacy path regardless.
3014        let forced_legacy = load_credentials_legacy().unwrap();
3015        assert_eq!(forced_legacy.client_id, "legacy-id");
3016    }
3017
3018    #[test]
3019    fn record_account_email_writes_email_address_only() {
3020        let guard = crate::gmail::test_support::EnvGuard::take();
3021        let dir = guard.clear_credentials();
3022        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3023        Settings::upsert_gmail_account(
3024            &settings_path,
3025            "work",
3026            &[("client_id", serde_json::Value::String("id".to_string()))],
3027        )
3028        .unwrap();
3029
3030        record_account_email("work", "alice@work.com").unwrap();
3031
3032        let val: serde_json::Value =
3033            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3034        assert_eq!(
3035            val["gmail"]["accounts"]["work"]["email_address"],
3036            "alice@work.com"
3037        );
3038        assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
3039    }
3040
3041    #[test]
3042    fn record_account_email_does_not_overwrite_an_existing_value() {
3043        let guard = crate::gmail::test_support::EnvGuard::take();
3044        let dir = guard.clear_credentials();
3045        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3046        Settings::upsert_gmail_account(
3047            &settings_path,
3048            "work",
3049            &[(
3050                "email_address",
3051                serde_json::Value::String("manually-set@work.com".to_string()),
3052            )],
3053        )
3054        .unwrap();
3055
3056        record_account_email("work", "alice@work.com").unwrap();
3057
3058        let val: serde_json::Value =
3059            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3060        assert_eq!(
3061            val["gmail"]["accounts"]["work"]["email_address"],
3062            "manually-set@work.com"
3063        );
3064    }
3065
3066    /// The zero-migration guarantee (issue #1500): with `gmail.accounts`
3067    /// empty, the account-aware `_for(None, ...)` wrappers must behave
3068    /// byte-identically to the pre-#1500 wrappers they sit beside. Both
3069    /// sandboxes are seeded via the same unchanged [`save_credentials`] (no
3070    /// account-aware save wrapper exists — `login_for`/
3071    /// `import_client_credentials_for` persist directly, since a
3072    /// resolve-then-save round trip would reject the very name being
3073    /// created), so this isolates `load`/`remove`.
3074    #[test]
3075    fn zero_migration_load_remove_byte_identical_when_accounts_empty() {
3076        let guard = crate::gmail::test_support::EnvGuard::take();
3077        let creds = GmailCredentials {
3078            client_id: "id".to_string(),
3079            client_secret: "secret".into(),
3080            refresh_token: "refresh".into(),
3081            scope: GmailScope::Modify,
3082        };
3083
3084        let dir_legacy = guard.clear_credentials();
3085        save_credentials(&creds).unwrap();
3086        let legacy_written =
3087            fs::read_to_string(dir_legacy.path().join(".omni-dev").join("settings.json")).unwrap();
3088        let legacy_loaded = load_credentials().unwrap();
3089        let legacy_removed = remove_credentials().unwrap();
3090
3091        let dir_for = guard.clear_credentials();
3092        save_credentials(&creds).unwrap();
3093        let for_written =
3094            fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
3095        let for_loaded = load_credentials_for(None).unwrap();
3096        let for_removed = remove_credentials_for(None).unwrap();
3097
3098        assert_eq!(legacy_written, for_written);
3099        assert_eq!(legacy_loaded.client_id, for_loaded.client_id);
3100        assert_eq!(
3101            legacy_loaded.client_secret.expose_secret(),
3102            for_loaded.client_secret.expose_secret()
3103        );
3104        assert_eq!(legacy_loaded.scope, for_loaded.scope);
3105        assert_eq!(legacy_removed, for_removed);
3106    }
3107}