Skip to main content

agent_core/core/auth/
token.rs

1use 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
6/// Exchange an authorization code for access + refresh tokens.
7pub 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    // expires_in is seconds; store as epoch millis with 5-minute buffer (matches Pi/Claude Code)
52    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
63/// Refresh an expired OAuth token.
64pub 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
102/// Acquire an exclusive lock on auth.json, check token freshness, refresh if
103/// needed, and persist the result. Returns the current (possibly refreshed)
104/// credentials.
105pub 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    // Ensure parent dir exists (first-run case where we're reading before login)
113    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
179/// Ensure a non-Anthropic OAuth provider has a fresh token.
180pub 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}