//! OAuth 2.0 authorization-code + PKCE flow use-case. //! //! `OAuthUseCase` orchestrates the standard PKCE-enhanced OAuth flow: //! //! 1. **`start_flow`** — generates a cryptographic PKCE code verifier, //! derives its S256 challenge, creates a CSRF state token, persists //! the verifier + state via `OAuthFlowStore`, and builds an //! authorization URL with all required parameters. //! 2. **`complete_flow`** — validates the returned `state` against the //! stored value (CSRF check), reads the stored verifier, delegates //! the token-code exchange to an injected `TokenExchanger`, and //! persists the resulting `OAuthToken` via `OAuthRepository`. //! 3. **`get_token`** — loads the stored OAuth token (if any). //! //! # Portability //! //! The service is generic over three injected dependencies: //! - `R: OAuthRepository` — token persistence //! - `S: OAuthFlowStore` — ephemeral flow state (verifier + CSRF state) //! - `E: TokenExchanger` — the HTTP token-endpoint exchange //! //! This keeps all I/O and protocol-level concerns abstracted behind //! port traits; the service itself contains only orchestration logic. use std::path::PathBuf; use tracing; use zesdex_domain::auth::{OAuthConfig, OAuthRepository, OAuthToken, ServiceError}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use sha2::{Digest, Sha256}; // --------------------------------------------------------------------------- // Port traits (defined here because they are specific to this use-case) // --------------------------------------------------------------------------- /// Persistence contract for ephemeral OAuth flow state. /// /// Between `start_flow` and `complete_flow` the verifier and CSRF state /// must survive across process boundaries (the user opens a browser, the /// provider redirects back to a loopback listener on the next invocation). /// /// Implementors store key-value pairs to disk or another durable medium /// and clear them after a successful (or failed) flow completion. pub trait OAuthFlowStore: Send + Sync { /// Persist the PKCE code verifier and CSRF state token. fn save_flow_state( &self, verifier: &str, state: &str, ) -> Result<(), ServiceError>; /// Load the stored PKCE code verifier. fn load_verifier(&self) -> Result; /// Load the stored CSRF state token. fn load_state(&self) -> Result; /// Clear stored flow state (verifier + state). fn clear(&self) -> Result<(), ServiceError>; } /// Abstraction for exchanging an authorization code for tokens. /// /// Implementors handle the HTTP POST to the provider's token endpoint /// with the appropriate form-encoded parameters, parse the JSON /// response, and return the extracted `OAuthToken`. pub trait TokenExchanger: Send + Sync { /// Exchange an authorization code for an access token. /// /// ## Parameters /// - `token_url` — the provider's token endpoint URL /// - `client_id` — OAuth client identifier /// - `client_secret` — optional client secret /// - `redirect_uri` — must match the URI used in `start_flow` /// - `code` — the authorization code from the provider's redirect /// - `code_verifier` — the PKCE verifier from `start_flow` fn exchange_code( &self, token_url: &str, client_id: &str, client_secret: Option<&str>, redirect_uri: &str, code: &str, code_verifier: &str, ) -> Result; } // --------------------------------------------------------------------------- // PKCE helpers // --------------------------------------------------------------------------- /// Generate a PKCE code-verifier and its S256 code-challenge. /// /// Uses 32 cryptographically random bytes, base64url-encoded (no padding) /// for the verifier, then SHA-256 hashes the verifier and base64url-encodes /// the digest for the challenge. This satisfies the PKCE `S256` method /// which requires a minimum verifier length of 43 characters. fn generate_pkce_pair() -> (String, String) { // 32 random bytes → 43 base64url chars (well above the 43-char PKCE // minimum). let mut bytes = [0u8; 32]; bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); let verifier = URL_SAFE_NO_PAD.encode(&bytes); let challenge = { let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); URL_SAFE_NO_PAD.encode(hasher.finalize()) }; (verifier, challenge) } /// Generate a random CSRF state token (UUID-based, 36 chars). fn generate_state_token() -> String { uuid::Uuid::new_v4().to_string() } // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- /// Concrete OAuth flow use-case. /// /// Generic over three dependencies: /// - `R` — token persistence (`OAuthRepository`) /// - `S` — flow-state persistence (`OAuthFlowStore`) /// - `E` — token-endpoint HTTP exchange (`TokenExchanger`) pub struct OAuthUseCase { /// Repository for persisting / loading OAuth tokens. pub token_repo: R, /// Store for ephemeral flow state (verifier + CSRF state). pub flow_store: S, /// Token-endpoint HTTP exchanger. pub token_exchanger: E, /// File path for the token JSON file. pub token_path: PathBuf, } impl OAuthUseCase { /// Create a new OAuth use-case. pub fn new( token_repo: R, flow_store: S, token_exchanger: E, token_path: PathBuf, ) -> Self { OAuthUseCase { token_repo, flow_store, token_exchanger, token_path, } } } impl zesdex_domain::auth::OAuthService for OAuthUseCase { fn start_flow( &self, config: &OAuthConfig, redirect_uri: &str, ) -> Result<(String, String), ServiceError> { if config.auth_url.is_empty() { return Err(ServiceError::InvalidConfig( "OAuth auth_url is empty".to_string(), )); } let (verifier, challenge) = generate_pkce_pair(); let state = generate_state_token(); // Persist verifier + state so `complete_flow` can retrieve them. self.flow_store.save_flow_state(&verifier, &state)?; tracing::debug!( auth_url = %config.auth_url, redirect_uri = %redirect_uri, state_len = state.len(), "starting OAuth flow", ); let mut url = url::Url::parse(&config.auth_url) .map_err(|e| { ServiceError::InvalidConfig(format!( "invalid auth_url '{}': {e}", config.auth_url )) })?; url.query_pairs_mut() .append_pair("response_type", "code") .append_pair("client_id", &config.client_id) .append_pair("redirect_uri", redirect_uri) .append_pair("scope", &config.scopes.join(" ")) .append_pair("state", &state) .append_pair("code_challenge_method", "S256") .append_pair("code_challenge", &challenge); Ok((url.to_string(), state)) } fn complete_flow( &self, config: &OAuthConfig, redirect_uri: &str, code: &str, state: &str, ) -> Result { // CSRF check: validate the returned state against the stored value. let expected_state = self.flow_store.load_state()?; if expected_state != state { return Err(ServiceError::StateMismatch); } // Read the PKCE verifier that was saved in `start_flow`. let verifier = self.flow_store.load_verifier()?; tracing::debug!( token_url = %config.token_url, code_len = code.len(), "completing OAuth flow — exchanging code for token", ); // Delegate the HTTP token exchange to the injected exchanger. let token = self.token_exchanger.exchange_code( &config.token_url, &config.client_id, config.client_secret.as_deref(), redirect_uri, code, &verifier, )?; // Persist the token and clean up flow state. self.token_repo.save_token(&self.token_path, &token)?; let _ = self.flow_store.clear(); Ok(token) } fn get_token(&self) -> Result, ServiceError> { self.token_repo .load_token(&self.token_path) .map_err(ServiceError::Repository) } }