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}