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}