251 lines
8.8 KiB
Rust
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)
|
|
}
|
|
}
|