Files
zesdex/apps/application/src/auth/oauth_service.rs
T

251 lines
8.8 KiB
Rust

//! 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<String, ServiceError>;
/// Load the stored CSRF state token.
fn load_state(&self) -> Result<String, ServiceError>;
/// 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<OAuthToken, ServiceError>;
}
// ---------------------------------------------------------------------------
// 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<R, S, E> {
/// 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<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> {
/// 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<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
zesdex_domain::auth::OAuthService for OAuthUseCase<R, S, E>
{
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<OAuthToken, ServiceError> {
// 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<Option<OAuthToken>, ServiceError> {
self.token_repo
.load_token(&self.token_path)
.map_err(ServiceError::Repository)
}
}