agent_core/core/auth/
token.rs1use reqwest::Client;
2
3use super::{is_token_expired, now_millis, AuthFile, OAuthCredentials, TokenResponse, CLIENT_ID, TOKEN_URL};
4use super::storage::{auth_file_path, load_provider_auth, save_provider_auth};
5
6pub async fn exchange_code_for_tokens(
8 code: &str,
9 state: &str,
10 verifier: &str,
11 port: u16,
12) -> std::result::Result<OAuthCredentials, String> {
13 let redirect_uri = format!("http://localhost:{}/callback", port);
14
15 let body = serde_json::json!({
16 "grant_type": "authorization_code",
17 "client_id": CLIENT_ID,
18 "code": code,
19 "state": state,
20 "redirect_uri": redirect_uri,
21 "code_verifier": verifier,
22 });
23
24 let client = Client::builder()
25 .tls_built_in_webpki_certs(true)
26 .tls_built_in_native_certs(true)
27 .connect_timeout(std::time::Duration::from_secs(10))
28 .timeout(std::time::Duration::from_secs(30))
29 .build()
30 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
31 let resp = client
32 .post(TOKEN_URL)
33 .header("Content-Type", "application/json")
34 .header("Accept", "application/json")
35 .json(&body)
36 .send()
37 .await
38 .map_err(|e| format!("Token exchange request failed: {}", e))?;
39
40 if !resp.status().is_success() {
41 let status = resp.status();
42 let text = resp.text().await.unwrap_or_default();
43 return Err(format!("Token exchange failed ({}): {}", status, text));
44 }
45
46 let token_resp: TokenResponse = resp
47 .json()
48 .await
49 .map_err(|e| format!("Failed to parse token response: {}", e))?;
50
51 let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
53
54 Ok(OAuthCredentials {
55 auth_type: "oauth".to_string(),
56 refresh: token_resp.refresh_token,
57 access: token_resp.access_token,
58 expires,
59 account_id: None,
60 })
61}
62
63pub async fn refresh_token(client: &Client, refresh: &str) -> std::result::Result<OAuthCredentials, String> {
65 let body = serde_json::json!({
66 "grant_type": "refresh_token",
67 "client_id": CLIENT_ID,
68 "refresh_token": refresh,
69 });
70
71 let resp = client
72 .post(TOKEN_URL)
73 .header("Content-Type", "application/json")
74 .header("Accept", "application/json")
75 .json(&body)
76 .send()
77 .await
78 .map_err(|e| format!("Token refresh request failed: {}", e))?;
79
80 if !resp.status().is_success() {
81 let status = resp.status();
82 let text = resp.text().await.unwrap_or_default();
83 return Err(format!("Token refresh failed ({}): {}", status, text));
84 }
85
86 let token_resp: TokenResponse = resp
87 .json()
88 .await
89 .map_err(|e| format!("Failed to parse refresh response: {}", e))?;
90
91 let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
92
93 Ok(OAuthCredentials {
94 auth_type: "oauth".to_string(),
95 refresh: token_resp.refresh_token,
96 access: token_resp.access_token,
97 expires,
98 account_id: None,
99 })
100}
101
102pub async fn ensure_fresh_token(client: &Client) -> std::result::Result<OAuthCredentials, String> {
106 use fs4::fs_std::FileExt;
107 use std::fs::OpenOptions;
108 use std::io::{Read, Seek, SeekFrom, Write};
109
110 let path = auth_file_path();
111
112 if let Some(parent) = path.parent() {
114 std::fs::create_dir_all(parent)
115 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
116 }
117
118 if !path.exists() {
119 return Err(format!(
120 "No credentials at {}. Run `login` to authenticate.",
121 path.display()
122 ));
123 }
124
125 let file = OpenOptions::new()
126 .read(true)
127 .write(true)
128 .open(&path)
129 .map_err(|e| format!("Failed to open {}: {}", path.display(), e))?;
130
131 let mut file = tokio::task::spawn_blocking(move || -> std::result::Result<std::fs::File, String> {
132 FileExt::lock_exclusive(&file)
133 .map_err(|e| format!("Failed to lock auth.json: {}", e))?;
134 Ok(file)
135 })
136 .await
137 .map_err(|e| format!("Lock task failed: {}", e))??;
138
139 file.seek(SeekFrom::Start(0))
140 .map_err(|e| format!("Failed to seek auth.json: {}", e))?;
141 let mut content = String::new();
142 file.read_to_string(&mut content)
143 .map_err(|e| format!("Failed to read auth.json: {}", e))?;
144
145 let auth: AuthFile = serde_json::from_str(&content)
146 .map_err(|e| format!("Failed to parse auth.json: {}", e))?;
147
148 if !is_token_expired(&auth.anthropic) {
149 return Ok(auth.anthropic);
150 }
151
152 let new_creds = refresh_token(client, &auth.anthropic.refresh).await?;
153
154 let new_auth = AuthFile {
155 anthropic: new_creds.clone(),
156 openai_codex: auth.openai_codex,
157 };
158 let new_json = serde_json::to_string_pretty(&new_auth)
159 .map_err(|e| format!("Failed to serialize auth: {}", e))?;
160
161 file.seek(SeekFrom::Start(0))
162 .map_err(|e| format!("Failed to seek for write: {}", e))?;
163 file.set_len(0)
164 .map_err(|e| format!("Failed to truncate auth.json: {}", e))?;
165 file.write_all(new_json.as_bytes())
166 .map_err(|e| format!("Failed to write auth.json: {}", e))?;
167 file.sync_all()
168 .map_err(|e| format!("Failed to fsync auth.json: {}", e))?;
169
170 #[cfg(unix)]
171 {
172 use std::os::unix::fs::PermissionsExt;
173 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
174 }
175
176 Ok(new_creds)
177}
178
179pub async fn ensure_fresh_provider_token(
181 client: &Client,
182 provider: &str,
183) -> std::result::Result<OAuthCredentials, String> {
184 let Some(creds) = load_provider_auth(provider)? else {
185 return Err(format!("No credentials for {}. Run `synaps login`.", provider));
186 };
187
188 if !is_token_expired(&creds) {
189 return Ok(creds);
190 }
191
192 let fresh = match provider {
193 "openai-codex" => super::openai_codex::refresh_token(client, &creds.refresh).await?,
194 other => return Err(format!("No refresh handler for OAuth provider {}", other)),
195 };
196 save_provider_auth(provider, &fresh)?;
197 Ok(fresh)
198}