stygian_graph/ports/auth.rs
1//! `AuthPort` — runtime token loading, expiry checking, and refresh.
2//!
3//! Implement this trait to inject live credentials into pipeline execution
4//! without pre-loading a static token. Designed to integrate with
5//! `stygian-browser`'s OAuth2 PKCE token store.
6
7use std::future::Future;
8use std::time::{Duration, SystemTime};
9
10use async_trait::async_trait;
11
12use crate::domain::error::{ServiceError, StygianError};
13
14// ─────────────────────────────────────────────────────────────────────────────
15// Token
16// ─────────────────────────────────────────────────────────────────────────────
17
18/// A resolved `OAuth2` / API bearer token with optional expiry metadata.
19///
20/// `TokenSet` deliberately does **not** implement `Display` — only `Debug` —
21/// to prevent accidental log or format-string leakage of access tokens.
22///
23/// # Example
24///
25/// ```rust
26/// use stygian_graph::ports::auth::TokenSet;
27/// use std::time::{SystemTime, Duration};
28///
29/// let ts = TokenSet {
30/// access_token: "tok_abc123".to_string(),
31/// refresh_token: Some("ref_xyz".to_string()),
32/// expires_at: SystemTime::now().checked_add(Duration::from_secs(3600)),
33/// scopes: vec!["read:user".to_string()],
34/// };
35/// assert!(!ts.is_expired());
36/// ```
37#[derive(Debug, Clone)]
38pub struct TokenSet {
39 /// Bearer token to inject into requests
40 pub access_token: String,
41 /// Refresh token (may be absent for non-OAuth2 API keys)
42 pub refresh_token: Option<String>,
43 /// Absolute expiry time; `None` means the token does not expire
44 pub expires_at: Option<SystemTime>,
45 /// `OAuth2` scopes granted to this token
46 pub scopes: Vec<String>,
47}
48
49impl TokenSet {
50 /// Returns `true` if the token has expired (with a 60-second safety margin).
51 ///
52 /// A token without an `expires_at` is considered perpetually valid.
53 ///
54 /// # Example
55 ///
56 /// ```rust
57 /// use stygian_graph::ports::auth::TokenSet;
58 /// use std::time::{SystemTime, Duration};
59 ///
60 /// let expired = TokenSet {
61 /// access_token: "tok".to_string(),
62 /// refresh_token: None,
63 /// expires_at: SystemTime::now().checked_sub(Duration::from_secs(300)),
64 /// scopes: vec![],
65 /// };
66 /// assert!(expired.is_expired());
67 /// ```
68 #[must_use]
69 pub fn is_expired(&self) -> bool {
70 let Some(exp) = self.expires_at else {
71 return false;
72 };
73 let threshold = SystemTime::now()
74 .checked_add(Duration::from_secs(60))
75 .unwrap_or(SystemTime::UNIX_EPOCH);
76 exp <= threshold
77 }
78}
79
80// ─────────────────────────────────────────────────────────────────────────────
81// Error
82// ─────────────────────────────────────────────────────────────────────────────
83
84/// Errors produced by [`AuthPort`] implementations.
85#[derive(Debug, thiserror::Error)]
86pub enum AuthError {
87 /// No token is stored; the user must complete the auth flow first.
88 #[error("no token found — please run the auth flow")]
89 TokenNotFound,
90
91 /// The stored token has expired and could not be refreshed.
92 #[error("token expired")]
93 TokenExpired,
94
95 /// The refresh request failed.
96 #[error("token refresh failed: {0}")]
97 RefreshFailed(String),
98
99 /// The token store could not be read or written.
100 #[error("token storage failed: {0}")]
101 StorageFailed(String),
102
103 /// The PKCE / interactive auth flow failed.
104 #[error("auth flow failed: {0}")]
105 AuthFlowFailed(String),
106
107 /// The token was present but malformed.
108 #[error("invalid token: {0}")]
109 InvalidToken(String),
110}
111
112impl From<AuthError> for StygianError {
113 fn from(e: AuthError) -> Self {
114 Self::Service(ServiceError::AuthenticationFailed(e.to_string()))
115 }
116}
117
118// ─────────────────────────────────────────────────────────────────────────────
119// Trait
120// ─────────────────────────────────────────────────────────────────────────────
121
122/// Port for runtime credential management.
123///
124/// Implement this trait to supply live tokens to pipeline execution.
125/// `stygian-browser`'s encrypted disk token store is the primary reference
126/// implementation, but in-memory and environment-variable backed variants
127/// are also common.
128///
129/// The trait uses native `async fn` in traits (Rust 2024 edition) so it is
130/// *not* object-safe. Use `Arc<impl AuthPort>` or generics rather than
131/// `Arc<dyn AuthPort>`.
132///
133/// # Example implementation (in-memory)
134///
135/// ```rust
136/// use stygian_graph::ports::auth::{AuthPort, AuthError, TokenSet};
137///
138/// struct StaticTokenAuth { token: String }
139///
140/// impl AuthPort for StaticTokenAuth {
141/// async fn load_token(&self) -> std::result::Result<Option<TokenSet>, AuthError> {
142/// Ok(Some(TokenSet {
143/// access_token: self.token.clone(),
144/// refresh_token: None,
145/// expires_at: None,
146/// scopes: vec![],
147/// }))
148/// }
149/// async fn refresh_token(&self) -> std::result::Result<TokenSet, AuthError> {
150/// Err(AuthError::TokenNotFound)
151/// }
152/// }
153/// ```
154pub trait AuthPort: Send + Sync {
155 /// Load the current token from the backing store.
156 ///
157 /// Returns `Ok(None)` if no token has been stored yet.
158 ///
159 /// # Errors
160 ///
161 /// Returns [`AuthError::StorageFailed`] if the backing store is unavailable.
162 fn load_token(
163 &self,
164 ) -> impl Future<Output = std::result::Result<Option<TokenSet>, AuthError>> + Send;
165
166 /// Obtain a fresh token by exchanging the stored refresh token with the
167 /// authorization server, then persist it.
168 ///
169 /// Implementations should persist the refreshed token before returning so
170 /// that concurrent callers get a consistent view.
171 ///
172 /// # Errors
173 ///
174 /// Returns [`AuthError::RefreshFailed`] when the token endpoint rejects the
175 /// request, or [`AuthError::TokenNotFound`] when no refresh token is
176 /// available.
177 fn refresh_token(
178 &self,
179 ) -> impl Future<Output = std::result::Result<TokenSet, AuthError>> + Send;
180}
181
182// ─────────────────────────────────────────────────────────────────────────────
183// Helper
184// ─────────────────────────────────────────────────────────────────────────────
185
186/// Resolve a live access-token string from an `AuthPort`.
187///
188/// 1. Calls `load_token()`.
189/// 2. If the token is expired, calls `refresh_token()`.
190/// 3. Returns the raw access token string, ready to be injected into a request.
191///
192/// # Errors
193///
194/// Returns `Err` if no token exists, storage is unavailable, or refresh fails.
195///
196/// # Example
197///
198/// ```rust
199/// # use stygian_graph::ports::auth::{AuthPort, AuthError, TokenSet, resolve_token};
200/// # struct Env;
201/// # impl AuthPort for Env {
202/// # async fn load_token(&self) -> std::result::Result<Option<TokenSet>, AuthError> {
203/// # Ok(Some(TokenSet { access_token: "abc".to_string(), refresh_token: None, expires_at: None, scopes: vec![] }))
204/// # }
205/// # async fn refresh_token(&self) -> std::result::Result<TokenSet, AuthError> { Err(AuthError::TokenNotFound) }
206/// # }
207/// # async fn run() -> std::result::Result<String, AuthError> {
208/// let auth = Env;
209/// let token = resolve_token(&auth).await?;
210/// println!("Bearer {token}");
211/// # Ok(token)
212/// # }
213/// ```
214pub async fn resolve_token(port: &impl AuthPort) -> std::result::Result<String, AuthError> {
215 let ts = port.load_token().await?.ok_or(AuthError::TokenNotFound)?;
216
217 if ts.is_expired() {
218 let refreshed = port.refresh_token().await?;
219 return Ok(refreshed.access_token);
220 }
221
222 Ok(ts.access_token)
223}
224
225// ─────────────────────────────────────────────────────────────────────────────
226// ErasedAuthPort — object-safe wrapper for use with Arc<dyn ...>
227// ─────────────────────────────────────────────────────────────────────────────
228
229/// Object-safe version of [`AuthPort`] for runtime dispatch.
230///
231/// [`AuthPort`] uses native `async fn in trait` (Rust 2024) and is NOT
232/// object-safe. `ErasedAuthPort` wraps the same logic via `async_trait`,
233/// producing `Pin<Box<dyn Future>>` return types that `Arc<dyn ...>` requires.
234///
235/// A blanket `impl<T: AuthPort> ErasedAuthPort for T` is provided — you never
236/// need to implement this trait directly.
237///
238/// # Example
239///
240/// ```rust
241/// use std::sync::Arc;
242/// use stygian_graph::ports::auth::{ErasedAuthPort, EnvAuthPort};
243///
244/// let port: Arc<dyn ErasedAuthPort> = Arc::new(EnvAuthPort::new("GITHUB_TOKEN"));
245/// // Pass `port` to GraphQlService::with_auth_port(port)
246/// ```
247#[async_trait]
248pub trait ErasedAuthPort: Send + Sync {
249 /// Resolve a live access-token string — load, check expiry, refresh if needed.
250 ///
251 /// # Errors
252 ///
253 /// Returns `Err` if no token exists, storage is unavailable, or refresh fails.
254 async fn erased_resolve_token(&self) -> std::result::Result<String, AuthError>;
255}
256
257#[async_trait]
258impl<T: AuthPort> ErasedAuthPort for T {
259 async fn erased_resolve_token(&self) -> std::result::Result<String, AuthError> {
260 resolve_token(self).await
261 }
262}
263
264// ─────────────────────────────────────────────────────────────────────────────
265// EnvAuthPort — convenience impl backed by an environment variable
266// ─────────────────────────────────────────────────────────────────────────────
267
268/// An [`AuthPort`] that reads a static token from an environment variable.
269///
270/// Tokens from environment variables never expire; `refresh_token` always
271/// returns [`AuthError::TokenNotFound`].
272///
273/// # Example
274///
275/// ```rust
276/// use stygian_graph::ports::auth::EnvAuthPort;
277///
278/// let auth = EnvAuthPort::new("GITHUB_TOKEN");
279/// // At pipeline execution time, `load_token()` will read $GITHUB_TOKEN.
280/// ```
281pub struct EnvAuthPort {
282 var_name: String,
283}
284
285impl EnvAuthPort {
286 /// Create an `EnvAuthPort` that will read `var_name` from the environment
287 /// at token-load time.
288 ///
289 /// # Example
290 ///
291 /// ```rust
292 /// use stygian_graph::ports::auth::EnvAuthPort;
293 ///
294 /// let auth = EnvAuthPort::new("GITHUB_TOKEN");
295 /// ```
296 #[must_use]
297 pub fn new(var_name: impl Into<String>) -> Self {
298 Self {
299 var_name: var_name.into(),
300 }
301 }
302}
303
304impl AuthPort for EnvAuthPort {
305 async fn load_token(&self) -> std::result::Result<Option<TokenSet>, AuthError> {
306 match std::env::var(&self.var_name) {
307 Ok(token) if !token.is_empty() => Ok(Some(TokenSet {
308 access_token: token,
309 refresh_token: None,
310 expires_at: None,
311 scopes: vec![],
312 })),
313 Ok(_) | Err(_) => Ok(None),
314 }
315 }
316
317 async fn refresh_token(&self) -> std::result::Result<TokenSet, AuthError> {
318 // Static env-var tokens don't support refresh.
319 Err(AuthError::TokenNotFound)
320 }
321}
322
323// ─────────────────────────────────────────────────────────────────────────────
324// Tests
325// ─────────────────────────────────────────────────────────────────────────────
326
327#[cfg(test)]
328mod tests {
329 #![allow(clippy::unwrap_used, unsafe_code)] // env::set_var / remove_var are unsafe in Rust ≥1.79
330 use super::*;
331
332 struct FixedToken(String);
333
334 impl AuthPort for FixedToken {
335 async fn load_token(&self) -> std::result::Result<Option<TokenSet>, AuthError> {
336 Ok(Some(TokenSet {
337 access_token: self.0.clone(),
338 refresh_token: None,
339 expires_at: None,
340 scopes: vec![],
341 }))
342 }
343
344 async fn refresh_token(&self) -> std::result::Result<TokenSet, AuthError> {
345 Err(AuthError::RefreshFailed("no refresh token".to_string()))
346 }
347 }
348
349 struct NoToken;
350
351 impl AuthPort for NoToken {
352 async fn load_token(&self) -> std::result::Result<Option<TokenSet>, AuthError> {
353 Ok(None)
354 }
355
356 async fn refresh_token(&self) -> std::result::Result<TokenSet, AuthError> {
357 Err(AuthError::TokenNotFound)
358 }
359 }
360
361 struct ExpiredToken {
362 new_token: String,
363 }
364
365 impl AuthPort for ExpiredToken {
366 async fn load_token(&self) -> std::result::Result<Option<TokenSet>, AuthError> {
367 Ok(Some(TokenSet {
368 access_token: "old_token".to_string(),
369 refresh_token: Some("ref".to_string()),
370 expires_at: SystemTime::now().checked_sub(Duration::from_secs(3600)),
371 scopes: vec![],
372 }))
373 }
374
375 async fn refresh_token(&self) -> std::result::Result<TokenSet, AuthError> {
376 Ok(TokenSet {
377 access_token: self.new_token.clone(),
378 refresh_token: None,
379 expires_at: None,
380 scopes: vec![],
381 })
382 }
383 }
384
385 #[test]
386 fn not_expired_when_no_expiry() {
387 let ts = TokenSet {
388 access_token: "tok".to_string(),
389 refresh_token: None,
390 expires_at: None,
391 scopes: vec![],
392 };
393 assert!(!ts.is_expired());
394 }
395
396 #[test]
397 fn expired_when_past_expiry() {
398 let ts = TokenSet {
399 access_token: "tok".to_string(),
400 refresh_token: None,
401 expires_at: SystemTime::now().checked_sub(Duration::from_secs(300)),
402 scopes: vec![],
403 };
404 assert!(ts.is_expired());
405 }
406
407 #[test]
408 fn not_expired_within_60s_margin() {
409 // Expires in 30s — within the 60s safety margin, so treated as expired.
410 let ts = TokenSet {
411 access_token: "tok".to_string(),
412 refresh_token: None,
413 expires_at: SystemTime::now().checked_add(Duration::from_secs(30)),
414 scopes: vec![],
415 };
416 assert!(ts.is_expired());
417 }
418
419 #[test]
420 fn not_expired_outside_60s_margin() {
421 let ts = TokenSet {
422 access_token: "tok".to_string(),
423 refresh_token: None,
424 expires_at: SystemTime::now().checked_add(Duration::from_secs(120)),
425 scopes: vec![],
426 };
427 assert!(!ts.is_expired());
428 }
429
430 #[tokio::test]
431 async fn resolve_token_returns_access_token() {
432 let auth = FixedToken("tok_abc".to_string());
433 let token = resolve_token(&auth).await.unwrap();
434 assert_eq!(token, "tok_abc");
435 }
436
437 #[tokio::test]
438 async fn resolve_token_returns_err_when_no_token() {
439 let auth = NoToken;
440 assert!(resolve_token(&auth).await.is_err());
441 }
442
443 #[tokio::test]
444 async fn resolve_token_refreshes_when_expired() {
445 let auth = ExpiredToken {
446 new_token: "fresh_tok".to_string(),
447 };
448 let token = resolve_token(&auth).await.unwrap();
449 assert_eq!(token, "fresh_tok");
450 }
451
452 #[tokio::test]
453 async fn env_auth_port_loads_from_env() {
454 // Safety: test-only env mutation under #[tokio::test]
455 unsafe { std::env::set_var("_STYGIAN_TEST_TOKEN_1", "env_tok_xyz") };
456 let auth = EnvAuthPort::new("_STYGIAN_TEST_TOKEN_1");
457 let token = resolve_token(&auth).await.unwrap();
458 assert_eq!(token, "env_tok_xyz");
459 unsafe { std::env::remove_var("_STYGIAN_TEST_TOKEN_1") };
460 }
461
462 #[tokio::test]
463 async fn env_auth_port_returns_none_when_unset() {
464 let auth = EnvAuthPort::new("_STYGIAN_TEST_MISSING_VAR_9999");
465 let ts = auth.load_token().await.unwrap();
466 assert!(ts.is_none());
467 }
468}