Skip to main content

pidge_client/auth/
mod.rs

1//! OAuth browser-based sign-in (auth-code + PKCE), token refresh, and
2//! credential storage.
3
4pub mod browser_flow;
5pub mod config;
6pub mod device_code;
7mod file_store;
8mod jwt;
9pub mod refresh;
10mod store;
11mod token_store;
12mod tokens;
13
14pub use browser_flow::AuthSuccess;
15pub use file_store::FileStore;
16pub use jwt::extract_tenant_id;
17pub use store::KeychainStore;
18pub use token_store::TokenStore;
19pub use tokens::TokenSet;
20
21use pidge_core::TokenStorage;
22
23use crate::error::ClientError;
24
25/// High-level auth client. Holds a shared `reqwest::Client` and the resolved
26/// `client_id`; provides device-code sign-in and access-token retrieval (with
27/// transparent refresh).
28pub struct AuthClient {
29    http: reqwest::Client,
30    client_id: String,
31    authority_base: String,
32    scope: String,
33}
34
35impl AuthClient {
36    /// Construct an AuthClient from compile-time/env configuration.
37    ///
38    /// Errors with `ClientError::NotProvisioned` if no client_id is available.
39    pub fn from_env() -> Result<Self, ClientError> {
40        let client_id = config::client_id().ok_or(ClientError::NotProvisioned)?;
41        Ok(Self {
42            http: reqwest::Client::builder()
43                .user_agent(format!("pidge/{}", env!("CARGO_PKG_VERSION")))
44                .build()?,
45            client_id,
46            authority_base: config::AUTHORITY.to_string(),
47            scope: config::scope_string(),
48        })
49    }
50
51    /// Construct an AuthClient against a specific authority — for tests with wiremock.
52    pub fn for_test(client_id: impl Into<String>, authority_base: impl Into<String>) -> Self {
53        Self {
54            http: reqwest::Client::new(),
55            client_id: client_id.into(),
56            authority_base: authority_base.into(),
57            scope: config::scope_string(),
58        }
59    }
60
61    /// Run the OAuth 2.0 authorization-code + PKCE sign-in flow with a
62    /// one-shot localhost HTTP server for the redirect callback.
63    ///
64    /// `on_authorize_url_ready` receives the constructed `/authorize` URL
65    /// once the local listener is bound and the URL is built — the caller
66    /// is responsible for printing it to the user and (best-effort) opening
67    /// the browser.
68    ///
69    /// Works for both work/school (M365) and personal (live.com /
70    /// outlook.com / hotmail.com) Microsoft accounts. Device-code is kept
71    /// in tree for potential future headless use but no longer the
72    /// default sign-in path.
73    pub async fn run_browser_flow<F>(
74        &self,
75        on_authorize_url_ready: F,
76    ) -> Result<AuthSuccess, ClientError>
77    where
78        F: FnOnce(&str),
79    {
80        browser_flow::run(
81            &self.http,
82            &self.authority_base,
83            &self.client_id,
84            &self.scope,
85            on_authorize_url_ready,
86        )
87        .await
88    }
89
90    /// Get a valid (un-expired) access token for an email, refreshing if necessary.
91    /// Returns `ClientError::SessionExpired` if the refresh fails — caller should
92    /// prompt the user to `pidge auth login` again for that account.
93    ///
94    /// The token storage backend is resolved from the account's config entry; if
95    /// the email has no entry in `config.yaml` yet (e.g. mid-login) we fall back
96    /// to the OS keychain.
97    pub async fn get_valid_token(&self, email: &str) -> Result<String, ClientError> {
98        let storage = storage_for(email);
99        let tokens =
100            TokenStore::load(email, storage)?.ok_or_else(|| ClientError::SessionExpired {
101                email: email.to_string(),
102            })?;
103
104        let access_token = if tokens.needs_refresh() {
105            let new_tokens = refresh::refresh(
106                &self.http,
107                &self.authority_base,
108                &self.client_id,
109                &tokens,
110                &self.scope,
111                email,
112            )
113            .await?;
114            TokenStore::save(email, &new_tokens, storage)?;
115            new_tokens.access_token
116        } else {
117            tokens.access_token
118        };
119
120        // Opportunistic backfill: accounts added before pidge requested the
121        // `openid` scope have an empty tenant_id in config. Microsoft Graph
122        // access tokens are JWTs that carry the `tid` claim, so we can fix
123        // this once per such account on the next Graph call without any
124        // user action.
125        backfill_tenant_id(email, &access_token);
126
127        Ok(access_token)
128    }
129}
130
131/// If the cached Account for `email` has an empty `tenant_id`, decode the
132/// `tid` claim from the JWT access token and persist it to config. Silent on
133/// any failure — this is a best-effort cosmetic backfill, not a correctness
134/// requirement.
135fn backfill_tenant_id(email: &str, access_token: &str) {
136    let Ok(mut config) = pidge_core::Config::load() else {
137        return;
138    };
139    let Some(existing) = config.find(email).cloned() else {
140        return;
141    };
142    if !existing.tenant_id.is_empty() {
143        return;
144    }
145    let Some(tid) = jwt::extract_tenant_id(access_token) else {
146        return;
147    };
148    if tid.is_empty() {
149        return;
150    }
151    let mut updated = existing;
152    updated.tenant_id = tid;
153    config.add_account(updated);
154    let _ = config.save();
155}
156
157/// Resolve the token storage backend for an email by consulting `config.yaml`.
158/// Falls back to [`TokenStorage::Keychain`] (the default) if the config can't
159/// be read or the account isn't listed yet.
160fn storage_for(email: &str) -> TokenStorage {
161    pidge_core::Config::load()
162        .ok()
163        .and_then(|c| c.find(email).map(|a| a.storage))
164        .unwrap_or_default()
165}