Skip to main content

pidge_client/auth/
mod.rs

1//! OAuth device-code flow, token refresh, and credential storage.
2
3pub mod config;
4pub mod device_code;
5mod file_store;
6mod jwt;
7pub mod refresh;
8mod store;
9mod token_store;
10mod tokens;
11
12pub use file_store::FileStore;
13pub use jwt::extract_tenant_id;
14pub use store::KeychainStore;
15pub use token_store::TokenStore;
16pub use tokens::TokenSet;
17
18use pidge_core::TokenStorage;
19
20use crate::error::ClientError;
21
22/// High-level auth client. Holds a shared `reqwest::Client` and the resolved
23/// `client_id`; provides device-code sign-in and access-token retrieval (with
24/// transparent refresh).
25pub struct AuthClient {
26    http: reqwest::Client,
27    client_id: String,
28    authority_base: String,
29    scope: String,
30}
31
32impl AuthClient {
33    /// Construct an AuthClient from compile-time/env configuration.
34    ///
35    /// Errors with `ClientError::NotProvisioned` if no client_id is available.
36    pub fn from_env() -> Result<Self, ClientError> {
37        let client_id = config::client_id().ok_or(ClientError::NotProvisioned)?;
38        Ok(Self {
39            http: reqwest::Client::builder()
40                .user_agent(format!("pidge/{}", env!("CARGO_PKG_VERSION")))
41                .build()?,
42            client_id,
43            authority_base: config::AUTHORITY.to_string(),
44            scope: config::scope_string(),
45        })
46    }
47
48    /// Construct an AuthClient against a specific authority — for tests with wiremock.
49    pub fn for_test(client_id: impl Into<String>, authority_base: impl Into<String>) -> Self {
50        Self {
51            http: reqwest::Client::new(),
52            client_id: client_id.into(),
53            authority_base: authority_base.into(),
54            scope: config::scope_string(),
55        }
56    }
57
58    /// Run the device code flow. Returns the device code response immediately;
59    /// caller is expected to display the user code and call `poll_for_tokens`.
60    pub async fn start_device_code(&self) -> Result<device_code::DeviceCodeResponse, ClientError> {
61        device_code::start(
62            &self.http,
63            &self.authority_base,
64            &self.client_id,
65            &self.scope,
66        )
67        .await
68    }
69
70    /// Poll for tokens after `start_device_code`. Blocks until success or terminal error.
71    pub async fn poll_for_tokens(
72        &self,
73        device_code_resp: &device_code::DeviceCodeResponse,
74    ) -> Result<device_code::PollSuccess, ClientError> {
75        device_code::poll(
76            &self.http,
77            &self.authority_base,
78            &self.client_id,
79            &device_code_resp.device_code,
80            device_code_resp.interval,
81            device_code_resp.expires_in,
82            device_code::real_sleep,
83        )
84        .await
85    }
86
87    /// Get a valid (un-expired) access token for an email, refreshing if necessary.
88    /// Returns `ClientError::SessionExpired` if the refresh fails — caller should
89    /// prompt the user to `pidge auth login` again for that account.
90    ///
91    /// The token storage backend is resolved from the account's config entry; if
92    /// the email has no entry in `config.yaml` yet (e.g. mid-login) we fall back
93    /// to the OS keychain.
94    pub async fn get_valid_token(&self, email: &str) -> Result<String, ClientError> {
95        let storage = storage_for(email);
96        let tokens =
97            TokenStore::load(email, storage)?.ok_or_else(|| ClientError::SessionExpired {
98                email: email.to_string(),
99            })?;
100
101        if !tokens.needs_refresh() {
102            return Ok(tokens.access_token);
103        }
104
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        Ok(new_tokens.access_token)
116    }
117}
118
119/// Resolve the token storage backend for an email by consulting `config.yaml`.
120/// Falls back to [`TokenStorage::Keychain`] (the default) if the config can't
121/// be read or the account isn't listed yet.
122fn storage_for(email: &str) -> TokenStorage {
123    pidge_core::Config::load()
124        .ok()
125        .and_then(|c| c.find(email).map(|a| a.storage))
126        .unwrap_or_default()
127}