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}