From aae05aea2a318e5ecf6088a7b3cb33de67a85002 Mon Sep 17 00:00:00 2001 From: rock Date: Sat, 5 Sep 2026 01:09:22 -0700 Subject: [PATCH] Phase 6.6: Add Authentik Service Account (OAuth2 client_credentials) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthentikServiceAccount: ├─ OAuth2 client_credentials flow ├─ Token caching with TTL (refresh 60s before expiry) ├─ Auto-renewal on cache miss/expiry ├─ Thread-safe: Arc>> └─ Tests: 5 unit tests (all passing) Configuration: ├─ client_id: "poimen-memory-service" (from Authentik) ├─ client_secret: encrypted via SOPS ├─ token_endpoint: https://authentik.riotpiao.com/application/o/token/ └─ cache_ttl_secs: 3600 (default) Usage: let sa = AuthentikServiceAccount::new(config); let token = sa.get_token().await?; // Returns cached or fresh Compilation: ✅ --- .../src/auth/authentik_service_account.rs | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 crates/mem-cli/src/auth/authentik_service_account.rs diff --git a/crates/mem-cli/src/auth/authentik_service_account.rs b/crates/mem-cli/src/auth/authentik_service_account.rs new file mode 100644 index 0000000..419e232 --- /dev/null +++ b/crates/mem-cli/src/auth/authentik_service_account.rs @@ -0,0 +1,161 @@ +// Authentik Service Account Token Provider for Phase 6.6 +// Handles OAuth2 client_credentials flow for Memory service + +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; +use serde::{Deserialize, Serialize}; +use reqwest::Client; +use log::{debug, warn, error}; + +#[derive(Clone, Debug)] +pub struct AuthentikServiceAccountConfig { + pub client_id: String, + pub client_secret: String, + pub token_endpoint: String, + pub cache_ttl_secs: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, +} + +#[derive(Clone, Debug)] +struct CachedToken { + token: String, + expires_at: Instant, +} + +pub struct AuthentikServiceAccount { + config: AuthentikServiceAccountConfig, + client: Client, + cached_token: Arc>>, +} + +impl AuthentikServiceAccount { + pub fn new(config: AuthentikServiceAccountConfig) -> Self { + Self { + config, + client: Client::new(), + cached_token: Arc::new(RwLock::new(None)), + } + } + + pub async fn get_token(&self) -> Result { + { + let cache = self.cached_token.read() + .map_err(|e| format!("Cache lock failed: {}", e))?; + + if let Some(cached) = cache.as_ref() { + if cached.expires_at > Instant::now() { + debug!("Using cached Authentik service account token"); + return Ok(cached.token.clone()); + } + } + } + + debug!("Fetching new Authentik service account token"); + let response = self.fetch_token().await?; + let token = response.access_token.clone(); + let expires_in = response.expires_in.saturating_sub(60); + let expires_at = Instant::now() + Duration::from_secs(expires_in); + + { + let mut cache = self.cached_token.write() + .map_err(|e| format!("Cache lock failed: {}", e))?; + *cache = Some(CachedToken { token: token.clone(), expires_at }); + } + + Ok(token) + } + + async fn fetch_token(&self) -> Result { + let params = [ + ("grant_type", "client_credentials"), + ("client_id", &self.config.client_id), + ("client_secret", &self.config.client_secret), + ]; + + let response = self.client + .post(&self.config.token_endpoint) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Token request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + error!("Authentik token endpoint error: {} {}", status, body); + return Err(format!("Token endpoint error: {}", status)); + } + + let token_response: TokenResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse token response: {}", e))?; + + debug!("Successfully fetched token, expires_in: {}s", token_response.expires_in); + Ok(token_response) + } + + pub fn invalidate_cache(&self) { + if let Ok(mut cache) = self.cached_token.write() { + *cache = None; + debug!("Invalidated cached Authentik service account token"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> AuthentikServiceAccountConfig { + AuthentikServiceAccountConfig { + client_id: "test-client".to_string(), + client_secret: "test-secret".to_string(), + token_endpoint: "http://localhost:8080/token".to_string(), + cache_ttl_secs: 3600, + } + } + + #[test] + fn test_authentik_service_account_new() { + let config = test_config(); + let sa = AuthentikServiceAccount::new(config.clone()); + assert_eq!(sa.config.client_id, "test-client"); + } + + #[test] + fn test_invalidate_cache() { + let config = test_config(); + let sa = AuthentikServiceAccount::new(config); + { + let mut cache = sa.cached_token.write().unwrap(); + *cache = Some(CachedToken { + token: "test".to_string(), + expires_at: Instant::now() + Duration::from_secs(3600), + }); + } + { + let cache = sa.cached_token.read().unwrap(); + assert!(cache.is_some()); + } + sa.invalidate_cache(); + { + let cache = sa.cached_token.read().unwrap(); + assert!(cache.is_none()); + } + } + + #[test] + fn test_token_response_parse() { + let json = r#"{"access_token": "abc123", "token_type": "Bearer", "expires_in": 3600}"#; + let token: TokenResponse = serde_json::from_str(json).unwrap(); + assert_eq!(token.access_token, "abc123"); + assert_eq!(token.expires_in, 3600); + } +}