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
4mod backend;
5pub mod browser_flow;
6pub mod config;
7pub mod device_code;
8pub(crate) mod file_store;
9mod jwt;
10pub mod refresh;
11mod store;
12mod token_store;
13mod tokens;
14
15pub use backend::{LocalBackend, TokenBackend};
16pub use browser_flow::AuthSuccess;
17pub use file_store::FileStore;
18pub use jwt::{IdTokenClaims, extract_id_claims, extract_tenant_id};
19pub use store::KeychainStore;
20pub use token_store::TokenStore;
21pub use tokens::TokenSet;
22
23use std::sync::Arc;
24
25use crate::error::ClientError;
26
27/// High-level auth client. Holds a shared `reqwest::Client` and the resolved
28/// `client_id`; provides device-code sign-in and access-token retrieval (with
29/// transparent refresh).
30pub struct AuthClient {
31    http: reqwest::Client,
32    client_id: String,
33    authority_base: String,
34    scope: String,
35    backend: Arc<dyn TokenBackend>,
36}
37
38impl AuthClient {
39    /// Construct an AuthClient from compile-time/env configuration.
40    ///
41    /// Errors with `ClientError::NotProvisioned` if no client_id is available.
42    pub fn from_env() -> Result<Self, ClientError> {
43        let client_id = config::client_id().ok_or(ClientError::NotProvisioned)?;
44        Ok(Self {
45            http: reqwest::Client::builder()
46                .user_agent(format!("pidge/{}", env!("CARGO_PKG_VERSION")))
47                .build()?,
48            client_id,
49            authority_base: config::AUTHORITY.to_string(),
50            scope: config::scope_string(),
51            backend: Arc::new(LocalBackend),
52        })
53    }
54
55    /// Like [`Self::from_env`], but tokens are loaded from and saved to
56    /// `backend` instead of the CLI's config-resolved keychain/file store.
57    /// This is the constructor for hosted consumers such as the MCP server.
58    pub fn from_env_with_backend(backend: Arc<dyn TokenBackend>) -> Result<Self, ClientError> {
59        let mut client = Self::from_env()?;
60        client.backend = backend;
61        Ok(client)
62    }
63
64    /// Replace the token backend (builder style). Handy for tests that pair
65    /// [`Self::for_test`] with an in-memory store.
66    pub fn with_backend(mut self, backend: Arc<dyn TokenBackend>) -> Self {
67        self.backend = backend;
68        self
69    }
70
71    /// The space-separated Microsoft Graph scope string this client requests.
72    pub fn scope(&self) -> &str {
73        &self.scope
74    }
75
76    /// The Entra `client_id` this client authenticates as.
77    pub fn client_id(&self) -> &str {
78        &self.client_id
79    }
80
81    /// Persist a freshly obtained [`TokenSet`] for `email` through the
82    /// configured backend. Hosted sign-in flows call this after
83    /// [`Self::exchange_code`].
84    pub async fn store_tokens(&self, email: &str, tokens: &TokenSet) -> Result<(), ClientError> {
85        self.backend.save(email, tokens).await
86    }
87
88    /// Build the Microsoft `/authorize` URL for an auth-code + PKCE sign-in
89    /// whose callback lands on `redirect_uri` (which must be registered on
90    /// the Entra app). The caller owns `state` and the PKCE verifier behind
91    /// `code_challenge`; pair with [`Self::exchange_code`].
92    pub fn authorize_url(&self, redirect_uri: &str, code_challenge: &str, state: &str) -> String {
93        self.authorize_url_with_hint(redirect_uri, code_challenge, state, None)
94    }
95
96    /// [`Self::authorize_url`] with a `login_hint`: the address Microsoft
97    /// preselects in its account picker. The picker is still shown
98    /// (`prompt=select_account`), so the user can pick another account; the
99    /// hint only makes the expected one the obvious choice.
100    pub fn authorize_url_with_hint(
101        &self,
102        redirect_uri: &str,
103        code_challenge: &str,
104        state: &str,
105        login_hint: Option<&str>,
106    ) -> String {
107        browser_flow::build_authorize_url_with_hint(
108            &self.authority_base,
109            &self.client_id,
110            redirect_uri,
111            &self.scope,
112            code_challenge,
113            state,
114            login_hint,
115        )
116    }
117
118    /// Redeem an authorization code delivered to `redirect_uri` for tokens.
119    /// Nothing is stored; call [`Self::store_tokens`] once the caller has
120    /// decided which account the tokens belong to.
121    pub async fn exchange_code(
122        &self,
123        code: &str,
124        code_verifier: &str,
125        redirect_uri: &str,
126    ) -> Result<AuthSuccess, ClientError> {
127        browser_flow::exchange_code_to_success(
128            &self.http,
129            &self.authority_base,
130            &self.client_id,
131            code,
132            code_verifier,
133            redirect_uri,
134        )
135        .await
136    }
137
138    /// Construct an AuthClient against a specific authority, for tests with wiremock.
139    pub fn for_test(client_id: impl Into<String>, authority_base: impl Into<String>) -> Self {
140        Self {
141            http: reqwest::Client::new(),
142            client_id: client_id.into(),
143            authority_base: authority_base.into(),
144            scope: config::scope_string(),
145            backend: Arc::new(LocalBackend),
146        }
147    }
148
149    /// Run the OAuth 2.0 authorization-code + PKCE sign-in flow with a
150    /// one-shot localhost HTTP server for the redirect callback.
151    ///
152    /// `on_authorize_url_ready` receives the constructed `/authorize` URL
153    /// once the local listener is bound and the URL is built. The caller
154    /// is responsible for printing it to the user and (best-effort) opening
155    /// the browser.
156    ///
157    /// Works for both work/school (M365) and personal (live.com /
158    /// outlook.com / hotmail.com) Microsoft accounts. Device-code is kept
159    /// in tree for potential future headless use but no longer the
160    /// default sign-in path.
161    pub async fn run_browser_flow<F>(
162        &self,
163        on_authorize_url_ready: F,
164    ) -> Result<AuthSuccess, ClientError>
165    where
166        F: FnOnce(&str),
167    {
168        browser_flow::run(
169            &self.http,
170            &self.authority_base,
171            &self.client_id,
172            &self.scope,
173            on_authorize_url_ready,
174        )
175        .await
176    }
177
178    /// Get a valid (un-expired) access token for an email, refreshing if necessary.
179    /// Returns `ClientError::SessionExpired` if the refresh fails; the caller should
180    /// prompt the user to `pidge auth login` again for that account.
181    ///
182    /// Tokens come from the configured [`TokenBackend`]. The default,
183    /// [`LocalBackend`], resolves the storage backend from the account's
184    /// config entry and falls back to the OS keychain if the email has no
185    /// entry in `config.yaml` yet (e.g. mid-login).
186    pub async fn get_valid_token(&self, email: &str) -> Result<String, ClientError> {
187        let tokens =
188            self.backend
189                .load(email)
190                .await?
191                .ok_or_else(|| ClientError::SessionExpired {
192                    email: email.to_string(),
193                })?;
194
195        let access_token = if tokens.needs_refresh() {
196            let new_tokens = refresh::refresh(
197                &self.http,
198                &self.authority_base,
199                &self.client_id,
200                &tokens,
201                &self.scope,
202                email,
203            )
204            .await?;
205            self.backend.save(email, &new_tokens).await?;
206            new_tokens.access_token
207        } else {
208            tokens.access_token
209        };
210
211        self.backend.on_access_token(email, &access_token);
212
213        Ok(access_token)
214    }
215}