//! OAuth 2.0 flows implemented as Engram graph operations. //! //! Implements PKCE (RFC 7636) and Authorization Code flow without external //! OAuth crates. Token exchange uses `reqwest` (already in the workspace). //! All tokens are hashed before graph storage — the raw token never persists. //! //! Flow: //! 1. `begin_auth_flow()` → PKCE verifier + challenge, redirect URL //! 2. Provider redirects back with `code` //! 3. `exchange_code()` → calls provider token endpoint, stores OAuthToken node //! 4. `refresh_token()` → find OAuthToken node, call refresh endpoint, update node use crate::{ engram::EngramClient, error::{IdentityError, IdentityResult}, nodes::{OAuthToken, User, EDGE_AUTHENTICATED_VIA, NODE_OAUTH_TOKEN}, provider::OAuthProvider, session::SessionManager, }; use chrono::{Duration, Utc}; use sha2::{Digest, Sha256}; use std::sync::Arc; // ── PKCE helpers ────────────────────────────────────────────────────────────── /// A PKCE verifier/challenge pair. #[derive(Debug, Clone)] pub struct PkceChallenge { /// The raw verifier — sent to the token endpoint. pub verifier: String, /// The challenge (BASE64URL(SHA256(verifier))) — sent in the auth request. pub challenge: String, /// Always "S256". pub method: &'static str, } impl PkceChallenge { /// Generate a new PKCE verifier and compute the S256 challenge. /// /// The verifier is a 43-character URL-safe random string derived from /// entropy collected from the system clock and a UUID. pub fn generate() -> Self { let verifier = generate_pkce_verifier(); let challenge = pkce_s256_challenge(&verifier); Self { verifier, challenge, method: "S256", } } /// Verify that a verifier matches this challenge (used in tests and server-side). pub fn verify(&self, verifier: &str) -> bool { pkce_s256_challenge(verifier) == self.challenge } } fn generate_pkce_verifier() -> String { // 32 random bytes → base64url (43 chars, no padding) // We derive entropy from UUID (random in v4) + timestamp nanos. let id = uuid::Uuid::new_v4(); let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.subsec_nanos()) .unwrap_or(0); let mut raw = [0u8; 32]; let id_bytes = id.as_bytes(); for i in 0..16 { raw[i] = id_bytes[i]; } let ts_bytes = ts.to_le_bytes(); for i in 0..4 { raw[16 + i] = ts_bytes[i]; } // Fill remaining with XOR mix for i in 20..32 { raw[i] = id_bytes[i - 16] ^ ts_bytes[i % 4]; } base64url_encode_no_pad(&raw) } fn pkce_s256_challenge(verifier: &str) -> String { let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); base64url_encode_no_pad(&hasher.finalize()) } fn base64url_encode_no_pad(input: &[u8]) -> String { const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; let mut out = String::new(); for chunk in input.chunks(3) { let b0 = chunk[0] as u32; let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; let n = (b0 << 16) | (b1 << 8) | b2; out.push(CHARS[((n >> 18) & 63) as usize] as char); out.push(CHARS[((n >> 12) & 63) as usize] as char); if chunk.len() > 1 { out.push(CHARS[((n >> 6) & 63) as usize] as char); } if chunk.len() > 2 { out.push(CHARS[(n & 63) as usize] as char); } } out } /// Hash a token with SHA-256 for safe graph storage. pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode_lower_sha256(hasher.finalize().as_ref()) } mod hex { pub fn encode_lower_sha256(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() } } // ── Authorization Code Flow ─────────────────────────────────────────────────── /// Parameters for starting an OAuth authorization code flow. #[derive(Debug, Clone)] pub struct AuthFlowParams { /// The PKCE challenge (save the verifier for use at exchange time). pub pkce: PkceChallenge, /// The full redirect URL to send the user to. pub redirect_url: String, /// An opaque state value for CSRF protection. pub state: String, } /// The result of a successful token exchange. #[derive(Debug, Clone)] pub struct TokenSet { pub access_token: String, pub refresh_token: Option, /// Expiry in seconds from now. pub expires_in: u64, pub scopes: Vec, } /// OAuth flow coordinator — executes auth code + PKCE flows and writes /// the resulting tokens to the Engram graph. pub struct OAuthFlow { client: Arc, /// Kept for future use (e.g., session creation during OAuth callback). _session_manager: Arc, } impl OAuthFlow { pub fn new(client: Arc, session_manager: Arc) -> Self { Self { client, _session_manager: session_manager } } /// Step 1: Generate the redirect URL and PKCE parameters. /// /// The caller should: /// 1. Save `params.pkce.verifier` in the user's browser session (cookie/localStorage). /// 2. Redirect the user to `params.redirect_url`. pub fn begin_auth_flow( &self, provider: &dyn OAuthProvider, redirect_uri: &str, extra_scopes: &[&str], ) -> IdentityResult { let pkce = PkceChallenge::generate(); let state = generate_pkce_verifier(); // reuse the verifier generator for state let url = provider.authorization_url( redirect_uri, &pkce.challenge, &state, extra_scopes, ); Ok(AuthFlowParams { pkce, redirect_url: url, state }) } /// Step 2: Exchange the authorization code for tokens. /// /// - `user` — the authenticated user to link the token to /// - `code` — the authorization code from the provider callback /// - `pkce_verifier` — the verifier saved in step 1 /// - `session_id` — the session to attach the OAuthToken to /// /// Returns the session ID that now has an OAuthToken attached via graph edge. pub fn exchange_code( &self, provider: &dyn OAuthProvider, _user: &User, code: &str, pkce_verifier: &str, redirect_uri: &str, session_id: &str, ) -> IdentityResult { // Exchange code with provider (HTTP call inside OAuthProvider::exchange_code) let token_set = provider.exchange_code(code, pkce_verifier, redirect_uri)?; // Hash tokens before storing let access_hash = hash_token(&token_set.access_token); let refresh_hash = token_set.refresh_token.as_deref().map(hash_token); let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64); let oauth_token = OAuthToken::new( provider.name(), access_hash, refresh_hash, expires_at, token_set.scopes, ); // Store OAuthToken node in graph let token_id = self .client .create_node(NODE_OAUTH_TOKEN, oauth_token.to_value()) .map_err(|e| IdentityError::GraphError(e.to_string()))?; // Edge: Session → OAuthToken (authenticated_via) self.client .create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA) .map_err(|e| IdentityError::GraphError(e.to_string()))?; Ok(oauth_token) } /// Step 3: Refresh an expired access token. /// /// Finds the OAuthToken node for the given session, calls the provider's /// refresh endpoint, and updates the node in-place (delete old, create new). pub fn refresh_token( &self, provider: &dyn OAuthProvider, session_id: &str, refresh_token: &str, ) -> IdentityResult { // Exchange refresh token with provider let token_set = provider.refresh_token(refresh_token)?; // Find and delete old OAuthToken nodes attached to this session let old_tokens = self .client .find_connected(session_id, EDGE_AUTHENTICATED_VIA) .map_err(|e| IdentityError::GraphError(e.to_string()))?; for old in &old_tokens { if let Some(id) = old.get("id").and_then(|v| v.as_str()) { let _ = self.client.delete_node(id); } } // Store new token let access_hash = hash_token(&token_set.access_token); let refresh_hash = token_set.refresh_token.as_deref().map(hash_token); let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64); let new_token = OAuthToken::new( provider.name(), access_hash, refresh_hash, expires_at, token_set.scopes, ); let token_id = self .client .create_node(NODE_OAUTH_TOKEN, new_token.to_value()) .map_err(|e| IdentityError::GraphError(e.to_string()))?; self.client .create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA) .map_err(|e| IdentityError::GraphError(e.to_string()))?; Ok(new_token) } }