pidge-client 0.4.3

Microsoft 365 / Graph client and OAuth flows for the pidge CLI
Documentation
//! OAuth browser-based sign-in (auth-code + PKCE), token refresh, and
//! credential storage.

pub mod browser_flow;
pub mod config;
pub mod device_code;
mod file_store;
mod jwt;
pub mod refresh;
mod store;
mod token_store;
mod tokens;

pub use browser_flow::AuthSuccess;
pub use file_store::FileStore;
pub use jwt::extract_tenant_id;
pub use store::KeychainStore;
pub use token_store::TokenStore;
pub use tokens::TokenSet;

use pidge_core::TokenStorage;

use crate::error::ClientError;

/// High-level auth client. Holds a shared `reqwest::Client` and the resolved
/// `client_id`; provides device-code sign-in and access-token retrieval (with
/// transparent refresh).
pub struct AuthClient {
    http: reqwest::Client,
    client_id: String,
    authority_base: String,
    scope: String,
}

impl AuthClient {
    /// Construct an AuthClient from compile-time/env configuration.
    ///
    /// Errors with `ClientError::NotProvisioned` if no client_id is available.
    pub fn from_env() -> Result<Self, ClientError> {
        let client_id = config::client_id().ok_or(ClientError::NotProvisioned)?;
        Ok(Self {
            http: reqwest::Client::builder()
                .user_agent(format!("pidge/{}", env!("CARGO_PKG_VERSION")))
                .build()?,
            client_id,
            authority_base: config::AUTHORITY.to_string(),
            scope: config::scope_string(),
        })
    }

    /// Construct an AuthClient against a specific authority — for tests with wiremock.
    pub fn for_test(client_id: impl Into<String>, authority_base: impl Into<String>) -> Self {
        Self {
            http: reqwest::Client::new(),
            client_id: client_id.into(),
            authority_base: authority_base.into(),
            scope: config::scope_string(),
        }
    }

    /// Run the OAuth 2.0 authorization-code + PKCE sign-in flow with a
    /// one-shot localhost HTTP server for the redirect callback.
    ///
    /// `on_authorize_url_ready` receives the constructed `/authorize` URL
    /// once the local listener is bound and the URL is built — the caller
    /// is responsible for printing it to the user and (best-effort) opening
    /// the browser.
    ///
    /// Works for both work/school (M365) and personal (live.com /
    /// outlook.com / hotmail.com) Microsoft accounts. Device-code is kept
    /// in tree for potential future headless use but no longer the
    /// default sign-in path.
    pub async fn run_browser_flow<F>(
        &self,
        on_authorize_url_ready: F,
    ) -> Result<AuthSuccess, ClientError>
    where
        F: FnOnce(&str),
    {
        browser_flow::run(
            &self.http,
            &self.authority_base,
            &self.client_id,
            &self.scope,
            on_authorize_url_ready,
        )
        .await
    }

    /// Get a valid (un-expired) access token for an email, refreshing if necessary.
    /// Returns `ClientError::SessionExpired` if the refresh fails — caller should
    /// prompt the user to `pidge auth login` again for that account.
    ///
    /// The token storage backend is resolved from the account's config entry; if
    /// the email has no entry in `config.yaml` yet (e.g. mid-login) we fall back
    /// to the OS keychain.
    pub async fn get_valid_token(&self, email: &str) -> Result<String, ClientError> {
        let storage = storage_for(email);
        let tokens =
            TokenStore::load(email, storage)?.ok_or_else(|| ClientError::SessionExpired {
                email: email.to_string(),
            })?;

        let access_token = if tokens.needs_refresh() {
            let new_tokens = refresh::refresh(
                &self.http,
                &self.authority_base,
                &self.client_id,
                &tokens,
                &self.scope,
                email,
            )
            .await?;
            TokenStore::save(email, &new_tokens, storage)?;
            new_tokens.access_token
        } else {
            tokens.access_token
        };

        // Opportunistic backfill: accounts added before pidge requested the
        // `openid` scope have an empty tenant_id in config. Microsoft Graph
        // access tokens are JWTs that carry the `tid` claim, so we can fix
        // this once per such account on the next Graph call without any
        // user action.
        backfill_tenant_id(email, &access_token);

        Ok(access_token)
    }
}

/// If the cached Account for `email` has an empty `tenant_id`, decode the
/// `tid` claim from the JWT access token and persist it to config. Silent on
/// any failure — this is a best-effort cosmetic backfill, not a correctness
/// requirement.
fn backfill_tenant_id(email: &str, access_token: &str) {
    let Ok(mut config) = pidge_core::Config::load() else {
        return;
    };
    let Some(existing) = config.find(email).cloned() else {
        return;
    };
    if !existing.tenant_id.is_empty() {
        return;
    }
    let Some(tid) = jwt::extract_tenant_id(access_token) else {
        return;
    };
    if tid.is_empty() {
        return;
    }
    let mut updated = existing;
    updated.tenant_id = tid;
    config.add_account(updated);
    let _ = config.save();
}

/// Resolve the token storage backend for an email by consulting `config.yaml`.
/// Falls back to [`TokenStorage::Keychain`] (the default) if the config can't
/// be read or the account isn't listed yet.
fn storage_for(email: &str) -> TokenStorage {
    pidge_core::Config::load()
        .ok()
        .and_then(|c| c.find(email).map(|a| a.storage))
        .unwrap_or_default()
}