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