Skip to main content

codineer_runtime/credentials/
oauth_resolver.rs

1use std::sync::Arc;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use crate::config::OAuthConfig;
5use crate::oauth::{clear_oauth_credentials, load_oauth_credentials, save_oauth_credentials};
6
7use super::{CredentialError, CredentialResolver, ResolvedCredential};
8
9/// Callback type for refreshing an expired OAuth token.
10/// The CLI layer provides this since it requires HTTP (api crate).
11pub type RefreshFn = Arc<
12    dyn Fn(
13            &OAuthConfig,
14            crate::OAuthTokenSet,
15        ) -> Result<crate::OAuthTokenSet, Box<dyn std::error::Error + Send + Sync>>
16        + Send
17        + Sync,
18>;
19
20/// Callback type for running an interactive login flow.
21/// The CLI layer provides this since it requires browser + HTTP.
22pub type LoginFn = Arc<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
23
24/// Resolves credentials from Codineer's saved OAuth tokens.
25///
26/// Loads tokens from the OS keyring or `~/.codineer/credentials.json`.
27/// If the token is expired and a refresh callback is available, attempts refresh.
28pub struct CodineerOAuthResolver {
29    oauth_config: Option<OAuthConfig>,
30    refresh_fn: Option<RefreshFn>,
31    login_fn: Option<LoginFn>,
32}
33
34impl std::fmt::Debug for CodineerOAuthResolver {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("CodineerOAuthResolver")
37            .field("has_oauth_config", &self.oauth_config.is_some())
38            .field("has_refresh_fn", &self.refresh_fn.is_some())
39            .field("has_login_fn", &self.login_fn.is_some())
40            .finish()
41    }
42}
43
44impl CodineerOAuthResolver {
45    #[must_use]
46    pub fn new(oauth_config: Option<OAuthConfig>) -> Self {
47        Self {
48            oauth_config,
49            refresh_fn: None,
50            login_fn: None,
51        }
52    }
53
54    /// Set the callback used to refresh expired tokens.
55    #[must_use]
56    pub fn with_refresh_fn(mut self, f: RefreshFn) -> Self {
57        self.refresh_fn = Some(f);
58        self
59    }
60
61    /// Set the callback used for interactive login.
62    #[must_use]
63    pub fn with_login_fn(mut self, f: LoginFn) -> Self {
64        self.login_fn = Some(f);
65        self
66    }
67}
68
69fn now_unix() -> u64 {
70    SystemTime::now()
71        .duration_since(UNIX_EPOCH)
72        .map_or(0, |d| d.as_secs())
73}
74
75fn is_expired(token_set: &crate::OAuthTokenSet) -> bool {
76    token_set
77        .expires_at
78        .is_some_and(|expires_at| expires_at <= now_unix())
79}
80
81impl CredentialResolver for CodineerOAuthResolver {
82    fn id(&self) -> &str {
83        "codineer-oauth"
84    }
85
86    fn display_name(&self) -> &str {
87        "Codineer OAuth"
88    }
89
90    fn priority(&self) -> u16 {
91        200
92    }
93
94    fn resolve(&self) -> Result<Option<ResolvedCredential>, CredentialError> {
95        let token_set = load_oauth_credentials().map_err(|e| CredentialError::ResolverFailed {
96            resolver_id: self.id().to_string(),
97            source: Box::new(e),
98        })?;
99
100        let Some(token_set) = token_set else {
101            return Ok(None);
102        };
103
104        if !is_expired(&token_set) {
105            return Ok(Some(ResolvedCredential::BearerToken(
106                token_set.access_token,
107            )));
108        }
109
110        // Token is expired — try to refresh if we have both a config and a refresh callback
111        if token_set.refresh_token.is_some() {
112            if let (Some(config), Some(refresh)) = (&self.oauth_config, &self.refresh_fn) {
113                match refresh(config, token_set) {
114                    Ok(refreshed) => {
115                        let _ = save_oauth_credentials(&refreshed);
116                        return Ok(Some(ResolvedCredential::BearerToken(
117                            refreshed.access_token,
118                        )));
119                    }
120                    Err(e) => {
121                        return Err(CredentialError::ResolverFailed {
122                            resolver_id: self.id().to_string(),
123                            source: e,
124                        });
125                    }
126                }
127            }
128        }
129
130        // Expired and can't refresh — not available
131        Ok(None)
132    }
133
134    fn supports_login(&self) -> bool {
135        self.login_fn.is_some()
136    }
137
138    fn login(&self) -> Result<(), Box<dyn std::error::Error>> {
139        match &self.login_fn {
140            Some(f) => f(),
141            None => Err("OAuth login requires the CLI login flow; run `codineer login`".into()),
142        }
143    }
144
145    fn logout(&self) -> Result<(), Box<dyn std::error::Error>> {
146        clear_oauth_credentials().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::oauth::save_oauth_credentials;
154    use std::time::{SystemTime, UNIX_EPOCH};
155
156    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
157        crate::test_env_lock()
158    }
159
160    fn temp_config_home() -> std::path::PathBuf {
161        std::env::temp_dir().join(format!(
162            "oauth-resolver-test-{}-{}",
163            std::process::id(),
164            SystemTime::now()
165                .duration_since(UNIX_EPOCH)
166                .expect("time")
167                .as_nanos()
168        ))
169    }
170
171    #[test]
172    fn returns_none_when_no_saved_credentials() {
173        let _guard = env_lock();
174        let config_home = temp_config_home();
175        std::env::set_var("CODINEER_CONFIG_HOME", &config_home);
176
177        let resolver = CodineerOAuthResolver::new(None);
178        assert_eq!(resolver.resolve().unwrap(), None);
179
180        std::env::remove_var("CODINEER_CONFIG_HOME");
181        let _ = std::fs::remove_dir_all(config_home);
182    }
183
184    #[test]
185    fn returns_token_when_not_expired() {
186        let _guard = env_lock();
187        let config_home = temp_config_home();
188        std::env::set_var("CODINEER_CONFIG_HOME", &config_home);
189        std::fs::create_dir_all(&config_home).unwrap();
190
191        let future = now_unix() + 3600;
192        let token_set = crate::OAuthTokenSet {
193            access_token: "valid-token".into(),
194            refresh_token: None,
195            expires_at: Some(future),
196            scopes: vec![],
197        };
198        save_oauth_credentials(&token_set).unwrap();
199
200        let resolver = CodineerOAuthResolver::new(None);
201        let cred = resolver.resolve().unwrap();
202        assert_eq!(
203            cred,
204            Some(ResolvedCredential::BearerToken("valid-token".into()))
205        );
206
207        clear_oauth_credentials().unwrap();
208        std::env::remove_var("CODINEER_CONFIG_HOME");
209        let _ = std::fs::remove_dir_all(config_home);
210    }
211
212    #[test]
213    fn returns_none_when_expired_without_refresh() {
214        let _guard = env_lock();
215        let config_home = temp_config_home();
216        std::env::set_var("CODINEER_CONFIG_HOME", &config_home);
217        std::fs::create_dir_all(&config_home).unwrap();
218
219        let token_set = crate::OAuthTokenSet {
220            access_token: "expired".into(),
221            refresh_token: None,
222            expires_at: Some(1), // long expired
223            scopes: vec![],
224        };
225        save_oauth_credentials(&token_set).unwrap();
226
227        let resolver = CodineerOAuthResolver::new(None);
228        assert_eq!(resolver.resolve().unwrap(), None);
229
230        clear_oauth_credentials().unwrap();
231        std::env::remove_var("CODINEER_CONFIG_HOME");
232        let _ = std::fs::remove_dir_all(config_home);
233    }
234
235    #[test]
236    fn logout_clears_credentials() {
237        let _guard = env_lock();
238        let config_home = temp_config_home();
239        std::env::set_var("CODINEER_CONFIG_HOME", &config_home);
240        std::fs::create_dir_all(&config_home).unwrap();
241
242        let future = now_unix() + 3600;
243        let token_set = crate::OAuthTokenSet {
244            access_token: "tok".into(),
245            refresh_token: None,
246            expires_at: Some(future),
247            scopes: vec![],
248        };
249        save_oauth_credentials(&token_set).unwrap();
250
251        let resolver = CodineerOAuthResolver::new(None);
252        assert!(resolver.resolve().unwrap().is_some());
253        resolver.logout().unwrap();
254        assert_eq!(resolver.resolve().unwrap(), None);
255
256        std::env::remove_var("CODINEER_CONFIG_HOME");
257        let _ = std::fs::remove_dir_all(config_home);
258    }
259
260    #[test]
261    fn supports_login_reflects_login_fn() {
262        let resolver = CodineerOAuthResolver::new(None);
263        assert!(!resolver.supports_login());
264        let resolver_with_fn = CodineerOAuthResolver::new(None).with_login_fn(Arc::new(|| Ok(())));
265        assert!(resolver_with_fn.supports_login());
266    }
267
268    #[test]
269    fn login_without_handler_returns_error() {
270        let resolver = CodineerOAuthResolver::new(None);
271        assert!(resolver.login().is_err());
272    }
273
274    #[test]
275    fn metadata() {
276        let resolver = CodineerOAuthResolver::new(None);
277        assert_eq!(resolver.id(), "codineer-oauth");
278        assert_eq!(resolver.display_name(), "Codineer OAuth");
279        assert_eq!(resolver.priority(), 200);
280    }
281}