Skip to main content

synaps_cli/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        .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    // expires_in is seconds; store as epoch millis with 5-minute buffer (matches Pi/Claude Code)
50    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
61/// Refresh an expired OAuth token.
62pub 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
100/// Acquire an exclusive lock on auth.json, check token freshness, refresh if
101/// needed, and persist the result. Returns the current (possibly refreshed)
102/// credentials.
103pub 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    // Ensure parent dir exists (first-run case where we're reading before login)
111    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
177/// Ensure a non-Anthropic OAuth provider has a fresh token.
178pub 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}