codineer_runtime/credentials/
oauth_resolver.rs1use 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
9pub 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
20pub type LoginFn = Arc<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
23
24pub 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 #[must_use]
56 pub fn with_refresh_fn(mut self, f: RefreshFn) -> Self {
57 self.refresh_fn = Some(f);
58 self
59 }
60
61 #[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 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 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), 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}