1use std::collections::HashMap;
2use std::io::Write as _;
3
4use anyhow::{anyhow, bail, Context, Result};
5use base64::Engine;
6use rand::RngCore;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use sha2::{Digest, Sha256};
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::net::{TcpListener, TcpStream};
12
13use crate::{
14 fetch_provider_email, import_amp, import_grok, jwt_exp_ms, named_account_id, normalize_email,
15 now_ms, persist_account_email, save_amp_api_key, Account, Vault, ANTHROPIC_CLIENT_ID,
16 ANTHROPIC_TOKEN_URL, OPENAI_CLIENT_ID, OPENAI_TOKEN_URL, XAI_CLIENT_ID, XAI_TOKEN_URL,
17};
18use alex_core::Provider;
19
20pub const PROVIDERS: &[&str] = &["claude", "codex", "grok", "gemini", "amp"];
21
22pub const ANTHROPIC_AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
23pub const ANTHROPIC_REDIRECT_URI: &str = "https://console.anthropic.com/oauth/code/callback";
24pub const ANTHROPIC_SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
25pub const OPENAI_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
26pub const OPENAI_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
27pub const OPENAI_SCOPES: &str = "openid profile email offline_access";
28pub const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
29pub(crate) const OPENAI_CALLBACK_ADDR: &str = "127.0.0.1:1455";
30const OPENAI_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
31pub const OPENAI_DEVICE_USER_CODE_URL: &str =
32 "https://auth.openai.com/api/accounts/deviceauth/usercode";
33pub const OPENAI_DEVICE_TOKEN_URL: &str =
34 "https://auth.openai.com/api/accounts/deviceauth/token";
35pub const OPENAI_DEVICE_VERIFICATION_URL: &str = "https://auth.openai.com/codex/device";
36pub const OPENAI_DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback";
37const OPENAI_JWT_CLAIM: &str = "https://api.openai.com/auth";
38pub const XAI_DEVICE_CODE_URL: &str = "https://auth.x.ai/oauth2/device/code";
39pub const XAI_SCOPES: &str = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write";
40pub const GEMINI_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
41pub const GEMINI_SCOPES: &str = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid";
42pub const GEMINI_CALLBACK_PATH: &str = "/oauth2callback";
43
44pub struct Pkce {
45 pub verifier: String,
46 pub challenge: String,
47}
48
49fn base64url(bytes: &[u8]) -> String {
50 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
51}
52
53pub fn pkce_challenge(verifier: &str) -> String {
54 base64url(&Sha256::digest(verifier.as_bytes()))
55}
56
57pub fn generate_pkce() -> Pkce {
58 let mut bytes = [0u8; 32];
59 rand::thread_rng().fill_bytes(&mut bytes);
60 let verifier = base64url(&bytes);
61 let challenge = pkce_challenge(&verifier);
62 Pkce {
63 verifier,
64 challenge,
65 }
66}
67
68fn random_state() -> String {
69 let mut bytes = [0u8; 16];
70 rand::thread_rng().fill_bytes(&mut bytes);
71 bytes.iter().map(|b| format!("{b:02x}")).collect()
72}
73
74pub fn anthropic_authorize_url(challenge: &str, state: &str) -> String {
75 let mut url = reqwest::Url::parse(ANTHROPIC_AUTHORIZE_URL).unwrap();
76 url.query_pairs_mut()
77 .append_pair("code", "true")
78 .append_pair("client_id", ANTHROPIC_CLIENT_ID)
79 .append_pair("response_type", "code")
80 .append_pair("redirect_uri", ANTHROPIC_REDIRECT_URI)
81 .append_pair("scope", ANTHROPIC_SCOPES)
82 .append_pair("code_challenge", challenge)
83 .append_pair("code_challenge_method", "S256")
84 .append_pair("state", state);
85 url.to_string()
86}
87
88pub fn openai_authorize_url(challenge: &str, state: &str) -> String {
89 let mut url = reqwest::Url::parse(OPENAI_AUTHORIZE_URL).unwrap();
90 url.query_pairs_mut()
91 .append_pair("response_type", "code")
92 .append_pair("client_id", OPENAI_CLIENT_ID)
93 .append_pair("redirect_uri", OPENAI_REDIRECT_URI)
94 .append_pair("scope", OPENAI_SCOPES)
95 .append_pair("code_challenge", challenge)
96 .append_pair("code_challenge_method", "S256")
97 .append_pair("state", state)
98 .append_pair("id_token_add_organizations", "true")
99 .append_pair("codex_cli_simplified_flow", "true")
100 .append_pair("originator", "pi");
101 url.to_string()
102}
103
104pub fn parse_authorization_input(input: &str) -> (Option<String>, Option<String>) {
105 let value = input.trim();
106 if value.is_empty() {
107 return (None, None);
108 }
109 if let Ok(url) = reqwest::Url::parse(value) {
110 let find = |key: &str| {
111 url.query_pairs()
112 .find(|(k, _)| k == key)
113 .map(|(_, v)| v.into_owned())
114 };
115 return (find("code"), find("state"));
116 }
117 if let Some((code, state)) = value.split_once('#') {
118 return (Some(code.to_string()), Some(state.to_string()));
119 }
120 if value.contains("code=") {
121 let mut code = None;
122 let mut state = None;
123 for pair in value.split('&') {
124 match pair.split_once('=') {
125 Some(("code", v)) => code = Some(v.to_string()),
126 Some(("state", v)) => state = Some(v.to_string()),
127 _ => {}
128 }
129 }
130 return (code, state);
131 }
132 (Some(value.to_string()), None)
133}
134
135pub fn jwt_payload(token: &str) -> Option<serde_json::Value> {
136 let payload = token.split('.').nth(1)?;
137 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
138 .decode(payload)
139 .ok()?;
140 serde_json::from_slice(&bytes).ok()
141}
142
143pub fn chatgpt_account_id(token: &str) -> Option<String> {
144 jwt_payload(token)?
145 .get(OPENAI_JWT_CLAIM)?
146 .get("chatgpt_account_id")?
147 .as_str()
148 .map(String::from)
149}
150
151fn jwt_email(token: &str) -> Option<String> {
152 jwt_payload(token)
153 .and_then(|payload| payload.get("email").and_then(Value::as_str).and_then(normalize_email))
154}
155
156fn token_email(id_token: Option<&str>, access_token: &str) -> Option<String> {
157 id_token.and_then(jwt_email).or_else(|| jwt_email(access_token))
158}
159
160#[derive(Debug, Deserialize)]
161struct TokenResponse {
162 access_token: String,
163 #[serde(default)]
164 refresh_token: Option<String>,
165 #[serde(default)]
166 id_token: Option<String>,
167 #[serde(default)]
168 expires_in: Option<i64>,
169 #[serde(default)]
170 scope: Option<String>,
171}
172
173async fn read_token_response(resp: reqwest::Response) -> Result<TokenResponse> {
174 let status = resp.status();
175 let text = resp.text().await?;
176 if !status.is_success() {
177 bail!("token exchange failed ({status}): {text}");
178 }
179 serde_json::from_str(&text).context("bad token exchange response")
180}
181
182pub fn browser_open_command(url: &str) -> Option<(&'static str, Vec<String>)> {
183 if cfg!(target_os = "macos") {
184 Some(("open", vec![url.to_string()]))
185 } else if cfg!(target_os = "windows") {
186 Some((
187 "cmd",
188 vec!["/C".into(), "start".into(), String::new(), url.to_string()],
189 ))
190 } else if cfg!(target_os = "linux") {
191 Some(("xdg-open", vec![url.to_string()]))
192 } else {
193 None
194 }
195}
196
197fn open_browser(url: &str) {
198 if let Some((program, args)) = browser_open_command(url) {
199 let _ = std::process::Command::new(program).args(args).spawn();
200 }
201}
202
203async fn prompt_line(message: &str) -> Result<String> {
204 print!("{message}");
205 std::io::stdout().flush()?;
206 let line = tokio::task::spawn_blocking(|| {
207 let mut line = String::new();
208 std::io::stdin().read_line(&mut line).map(|_| line)
209 })
210 .await??;
211 Ok(line.trim().to_string())
212}
213
214fn request_target(request: &str) -> Option<&str> {
215 request.lines().next()?.split_whitespace().nth(1)
216}
217
218fn callback_path(target: &str) -> String {
219 reqwest::Url::parse(&format!("http://localhost{target}"))
220 .map(|u| u.path().to_string())
221 .unwrap_or_default()
222}
223
224fn callback_query(target: &str) -> HashMap<String, String> {
225 reqwest::Url::parse(&format!("http://localhost{target}"))
226 .map(|url| {
227 url.query_pairs()
228 .map(|(k, v)| (k.into_owned(), v.into_owned()))
229 .collect()
230 })
231 .unwrap_or_default()
232}
233
234async fn respond(stream: &mut TcpStream, status: &str, body: &str) {
235 let resp = format!(
236 "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
237 body.len()
238 );
239 let _ = stream.write_all(resp.as_bytes()).await;
240 let _ = stream.shutdown().await;
241}
242
243pub(crate) async fn wait_for_openai_callback(
244 listener: &TcpListener,
245 expected_state: &str,
246) -> Result<String> {
247 wait_for_loopback_callback(listener, expected_state, OPENAI_CALLBACK_PATH).await
248}
249
250pub(crate) async fn wait_for_loopback_callback(
251 listener: &TcpListener,
252 expected_state: &str,
253 expected_path: &str,
254) -> Result<String> {
255 loop {
256 let (mut stream, _) = listener.accept().await?;
257 let mut buf = vec![0u8; 8192];
258 let n = stream.read(&mut buf).await.unwrap_or(0);
259 let request = String::from_utf8_lossy(&buf[..n]).into_owned();
260 let Some(target) = request_target(&request) else {
261 respond(
262 &mut stream,
263 "400 Bad Request",
264 "<html><body>bad request</body></html>",
265 )
266 .await;
267 continue;
268 };
269 if callback_path(target) != expected_path {
270 respond(
271 &mut stream,
272 "404 Not Found",
273 "<html><body>not found</body></html>",
274 )
275 .await;
276 continue;
277 }
278 let params = callback_query(target);
279 if let Some(err) = params.get("error") {
280 respond(
281 &mut stream,
282 "400 Bad Request",
283 &format!("<html><body>login failed: {err}</body></html>"),
284 )
285 .await;
286 bail!("oauth provider returned error: {err}");
287 }
288 if params.get("state").map(String::as_str) != Some(expected_state) {
289 respond(
290 &mut stream,
291 "400 Bad Request",
292 "<html><body>state mismatch</body></html>",
293 )
294 .await;
295 continue;
296 }
297 let Some(code) = params.get("code") else {
298 respond(
299 &mut stream,
300 "400 Bad Request",
301 "<html><body>missing code</body></html>",
302 )
303 .await;
304 continue;
305 };
306 let code = code.clone();
307 respond(
308 &mut stream,
309 "200 OK",
310 "<html><body>Login complete. You can close this tab.</body></html>",
311 )
312 .await;
313 return Ok(code);
314 }
315}
316
317pub async fn login(vault: &Vault, provider: &str) -> Result<String> {
318 login_named(vault, provider, "default", false).await
319}
320
321pub async fn login_named(vault: &Vault, provider: &str, name: &str, force: bool) -> Result<String> {
322 let p = match provider {
323 "claude" | "anthropic" => Provider::Anthropic,
324 "codex" | "openai" | "chatgpt" => Provider::Openai,
325 "grok" | "xai" => Provider::Xai,
326 "gemini" | "google" => Provider::Gemini,
327 "amp" | "ampcode" => Provider::Amp,
328 other => bail!("unknown provider '{other}' (expected claude|codex|grok|gemini|amp)"),
329 };
330 validate_account_name(name)?;
331 if !force && p != Provider::Amp && vault.has_account_name(p, name).await { bail!("{} account '{name}' already exists (use --force to replace)", p.as_str()); }
333 match provider {
334 "claude" | "anthropic" => login_claude(vault).await,
335 "codex" | "openai" | "chatgpt" => login_codex(vault, name).await,
336 "grok" | "xai" => login_grok(vault).await,
337 "gemini" | "google" => login_gemini(vault).await,
338 "amp" | "ampcode" => login_amp(vault).await,
339 _ => unreachable!(),
340 }
341}
342
343fn validate_account_name(name: &str) -> Result<()> {
344 if name.is_empty()
345 || name.len() > 32
346 || !name
347 .chars()
348 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
349 {
350 bail!("account name must match [a-z0-9_-]{{1,32}}");
351 }
352 Ok(())
353}
354
355async fn save_named_login_account(
356 vault: &Vault,
357 mut account: Account,
358 account_name: &str,
359) -> Result<String> {
360 validate_account_name(account_name)?;
361 account.name = account_name.to_string();
362 account.id = named_account_id(account.provider, &account.kind, account_name);
363 account.path = None;
364 let id = account.id.clone();
365 vault.upsert(account).await?;
366 Ok(id)
367}
368
369async fn login_amp(vault: &Vault) -> Result<String> {
371 let imported = import_amp(vault).await;
372 if !imported.imported.is_empty() {
373 println!(
374 "imported amp credentials from ~/.local/share/amp/secrets.json ({})",
375 imported.imported.join(", ")
376 );
377 return Ok(imported.imported[0].clone());
378 }
379 if let Ok(key) = std::env::var("AMP_API_KEY") {
380 if !key.trim().is_empty() {
381 let id = save_amp_api_key(vault, &key).await?;
382 println!("saved amp API key from AMP_API_KEY env");
383 return Ok(id);
384 }
385 }
386 println!(
387 "Amp login options:\n\
388 1. Run `amp login` in another terminal, then re-run `alex auth login amp`\n\
389 2. Create an access token at https://ampcode.com/settings and paste it below\n\
390 3. Set AMP_API_KEY and re-run\n"
391 );
392 if imported.note.is_some() {
393 println!("(import note: {})", imported.note.unwrap());
394 }
395 print!("amp access token (empty to cancel): ");
396 std::io::stdout().flush()?;
397 let mut line = String::new();
398 std::io::stdin().read_line(&mut line)?;
399 let key = line.trim();
400 if key.is_empty() {
401 bail!("cancelled — no amp credentials saved");
402 }
403 save_amp_api_key(vault, key).await
404}
405
406pub async fn claude_exchange(vault: &Vault, verifier: &str, input: &str) -> Result<String> {
407 let (code, state) = parse_authorization_input(input);
408 let code = code.ok_or_else(|| anyhow!("no authorization code provided"))?;
409 if let Some(s) = &state {
410 if s != verifier {
411 bail!("oauth state mismatch");
412 }
413 }
414 let state = state.unwrap_or_else(|| verifier.to_string());
415 let resp = reqwest::Client::new()
416 .post(ANTHROPIC_TOKEN_URL)
417 .json(&json!({
418 "grant_type": "authorization_code",
419 "client_id": ANTHROPIC_CLIENT_ID,
420 "code": code,
421 "state": state,
422 "redirect_uri": ANTHROPIC_REDIRECT_URI,
423 "code_verifier": verifier,
424 }))
425 .send()
426 .await?;
427 let tokens = read_token_response(resp).await?;
428 let scopes: Vec<String> = tokens
429 .scope
430 .as_deref()
431 .map(|s| s.split_whitespace().map(String::from).collect())
432 .unwrap_or_default();
433 let access_token = tokens.access_token;
434 let email = match fetch_provider_email(
435 &reqwest::Client::new(),
436 Provider::Anthropic,
437 &access_token,
438 )
439 .await
440 {
441 Some(email) => Some(email),
442 None => token_email(tokens.id_token.as_deref(), &access_token),
443 };
444 let mut account = Account {
445 id: named_account_id(Provider::Anthropic, "oauth", "default"),
446 provider: Provider::Anthropic,
447 kind: "oauth".into(),
448 name: "default".into(),
449 description: email.clone(),
450 paused: false,
451 label: Some(email.as_ref().map(|email| format!("claude ({email})")).unwrap_or_else(|| "claude (oauth login)".into())),
452 access_token: Some(access_token),
453 refresh_token: tokens.refresh_token,
454 id_token: tokens.id_token,
455 api_key: None,
456 expires_at_ms: tokens.expires_in.map(|s| now_ms() + s * 1000),
457 last_refresh_ms: Some(now_ms()),
458 account_meta: json!({"scopes": scopes}),
459 cooldown_until_ms: None,
460 status: "active".into(),
461 path: None,
462 };
463 if let Some(email) = email {
464 persist_account_email(&mut account, &email);
465 }
466 vault.upsert(account).await?;
467 Ok("anthropic-oauth".into())
468}
469
470async fn login_claude(vault: &Vault) -> Result<String> {
471 let pkce = generate_pkce();
472 let url = anthropic_authorize_url(&pkce.challenge, &pkce.verifier);
473 println!("open this url to authorize:\n\n {url}\n");
474 open_browser(&url);
475 let input = prompt_line("paste the authorization code (format: code#state): ").await?;
476 claude_exchange(vault, &pkce.verifier, &input).await
477}
478
479pub async fn codex_exchange(vault: &Vault, verifier: &str, code: &str) -> Result<String> {
480 codex_exchange_named(vault, verifier, code, "default").await
481}
482
483pub async fn codex_exchange_named(
484 vault: &Vault,
485 verifier: &str,
486 code: &str,
487 account_name: &str,
488) -> Result<String> {
489 let tokens = exchange_codex_tokens(verifier, code, OPENAI_REDIRECT_URI).await?;
490 let account = codex_account_from_tokens(tokens).await;
491 save_named_login_account(vault, account, account_name).await
492}
493
494pub async fn codex_exchange_auto(vault: &Vault, verifier: &str, code: &str) -> Result<String> {
495 let tokens = exchange_codex_tokens(verifier, code, OPENAI_REDIRECT_URI).await?;
496 let account = codex_account_from_tokens(tokens).await;
497 save_auto_codex_account(vault, account).await
498}
499
500async fn exchange_codex_tokens(
501 verifier: &str,
502 code: &str,
503 redirect_uri: &str,
504) -> Result<TokenResponse> {
505 let resp = reqwest::Client::new()
506 .post(OPENAI_TOKEN_URL)
507 .form(&[
508 ("grant_type", "authorization_code"),
509 ("client_id", OPENAI_CLIENT_ID),
510 ("code", code),
511 ("code_verifier", verifier),
512 ("redirect_uri", redirect_uri),
513 ])
514 .send()
515 .await?;
516 read_token_response(resp).await
517}
518
519#[derive(Debug, Clone)]
520pub struct CodexDeviceStart {
521 pub device_auth_id: String,
522 pub user_code: String,
523 pub interval_s: u64,
524}
525
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub enum CodexDevicePoll {
528 Pending,
529 Done {
530 authorization_code: String,
531 code_verifier: String,
532 },
533 Failed(String),
534}
535
536pub async fn codex_device_start(client: &reqwest::Client) -> Result<CodexDeviceStart> {
537 let response = client
538 .post(OPENAI_DEVICE_USER_CODE_URL)
539 .json(&json!({"client_id": OPENAI_CLIENT_ID}))
540 .send()
541 .await?;
542 let status = response.status();
543 let raw: Value = response.json().await?;
544 if !status.is_success() {
545 bail!("Codex device login could not start ({status})");
546 }
547 let device_auth_id = raw
548 .get("device_auth_id")
549 .and_then(Value::as_str)
550 .context("Codex device login response omitted device_auth_id")?
551 .to_string();
552 let user_code = raw
553 .get("user_code")
554 .and_then(Value::as_str)
555 .context("Codex device login response omitted user_code")?
556 .to_string();
557 let interval_s = raw
558 .get("interval")
559 .and_then(|value| {
560 value
561 .as_u64()
562 .or_else(|| value.as_str().and_then(|text| text.parse().ok()))
563 })
564 .unwrap_or(5)
565 .clamp(1, 30);
566 Ok(CodexDeviceStart {
567 device_auth_id,
568 user_code,
569 interval_s,
570 })
571}
572
573pub async fn codex_device_poll_once(
574 client: &reqwest::Client,
575 start: &CodexDeviceStart,
576) -> CodexDevicePoll {
577 let response = match client
578 .post(OPENAI_DEVICE_TOKEN_URL)
579 .json(&json!({
580 "device_auth_id": start.device_auth_id,
581 "user_code": start.user_code,
582 }))
583 .send()
584 .await
585 {
586 Ok(response) => response,
587 Err(error) => return CodexDevicePoll::Failed(error.to_string()),
588 };
589 let status = response.status();
590 let body = match response.text().await {
591 Ok(body) => body,
592 Err(error) => return CodexDevicePoll::Failed(error.to_string()),
593 };
594 parse_codex_device_poll(status.as_u16(), &body)
595}
596
597pub fn parse_codex_device_poll(status: u16, body: &str) -> CodexDevicePoll {
598 if status == 403 || status == 404 {
599 return CodexDevicePoll::Pending;
600 }
601 let raw: Value = match serde_json::from_str(body) {
602 Ok(raw) => raw,
603 Err(error) => return CodexDevicePoll::Failed(error.to_string()),
604 };
605 if !(200..300).contains(&status) {
606 return CodexDevicePoll::Failed(format!("Codex device login failed ({status})"));
607 }
608 let Some(authorization_code) = raw.get("authorization_code").and_then(Value::as_str) else {
609 return CodexDevicePoll::Failed("device login omitted authorization_code".into());
610 };
611 let Some(code_verifier) = raw.get("code_verifier").and_then(Value::as_str) else {
612 return CodexDevicePoll::Failed("device login omitted code_verifier".into());
613 };
614 if let Some(expected_challenge) = raw.get("code_challenge").and_then(Value::as_str) {
615 if pkce_challenge(code_verifier) != expected_challenge {
616 return CodexDevicePoll::Failed("device login returned an invalid PKCE verifier".into());
617 }
618 }
619 CodexDevicePoll::Done {
620 authorization_code: authorization_code.to_string(),
621 code_verifier: code_verifier.to_string(),
622 }
623}
624
625pub async fn codex_device_exchange_auto(
626 vault: &Vault,
627 authorization_code: &str,
628 code_verifier: &str,
629) -> Result<String> {
630 let tokens = exchange_codex_tokens(
631 code_verifier,
632 authorization_code,
633 OPENAI_DEVICE_REDIRECT_URI,
634 )
635 .await?;
636 let account = codex_account_from_tokens(tokens).await;
637 save_auto_codex_account(vault, account).await
638}
639
640async fn codex_account_from_tokens(tokens: TokenResponse) -> Account {
641 let account_id = tokens
642 .id_token
643 .as_deref()
644 .and_then(chatgpt_account_id)
645 .or_else(|| chatgpt_account_id(&tokens.access_token));
646 let identity_payload = tokens
647 .id_token
648 .as_deref()
649 .and_then(jwt_payload)
650 .or_else(|| jwt_payload(&tokens.access_token));
651 let profile = identity_payload
652 .as_ref()
653 .and_then(|payload| payload.get("https://api.openai.com/profile"));
654 let email = identity_payload
655 .as_ref()
656 .and_then(|payload| payload.get("email").and_then(Value::as_str))
657 .or_else(|| profile.and_then(|value| value.get("email").and_then(Value::as_str)))
658 .map(|value| value.trim().to_ascii_lowercase())
659 .filter(|value| !value.is_empty());
660 let auth_claim = identity_payload
661 .as_ref()
662 .and_then(|payload| payload.get(OPENAI_JWT_CLAIM));
663 let plan = auth_claim
664 .and_then(|value| value.get("chatgpt_plan_type").and_then(Value::as_str))
665 .or_else(|| {
666 identity_payload
667 .as_ref()
668 .and_then(|payload| payload.get("chatgpt_plan_type").and_then(Value::as_str))
669 })
670 .map(String::from);
671 let mut account_meta = json!({
672 "account_id": account_id,
673 "email": email,
674 "plan": plan,
675 });
676 if let Ok(snapshot) = fetch_codex_usage(
677 &tokens.access_token,
678 account_meta.get("account_id").and_then(Value::as_str),
679 )
680 .await
681 {
682 account_meta["codex_limits"] = snapshot;
683 account_meta["verified_at_ms"] = json!(now_ms());
684 }
685 Account {
686 id: named_account_id(Provider::Openai, "oauth", "default"),
687 provider: Provider::Openai,
688 kind: "oauth".into(),
689 name: "default".into(),
690 description: None,
691 paused: false,
692 label: email
693 .as_ref()
694 .map(|value| format!("codex ({value})"))
695 .or_else(|| Some("codex (chatgpt)".into())),
696 access_token: Some(tokens.access_token.clone()),
697 refresh_token: tokens.refresh_token,
698 id_token: tokens.id_token,
699 api_key: None,
700 expires_at_ms: tokens
701 .expires_in
702 .map(|s| now_ms() + s * 1000)
703 .or_else(|| jwt_exp_ms(&tokens.access_token)),
704 last_refresh_ms: Some(now_ms()),
705 account_meta,
706 cooldown_until_ms: None,
707 status: "active".into(),
708 path: None,
709 }
710}
711
712fn codex_usage_snapshot(raw: &Value) -> Option<Value> {
713 let mut windows = Vec::new();
714 for key in ["primary_window", "secondary_window"] {
715 let Some(window) = raw.get("rate_limit").and_then(|limits| limits.get(key)) else {
716 continue;
717 };
718 let Some(used_pct) = window.get("used_percent").and_then(Value::as_f64) else {
719 continue;
720 };
721 let seconds = window
722 .get("limit_window_seconds")
723 .and_then(Value::as_i64);
724 let label = match seconds {
725 Some(18_000) => "5h".to_string(),
726 Some(86_400) => "1d".to_string(),
727 Some(604_800) => "7d".to_string(),
728 Some(value) if value > 0 && value % 3_600 == 0 => format!("{}h", value / 3_600),
729 Some(value) if value > 0 => format!("{}m", value / 60),
730 _ => key.trim_end_matches("_window").to_string(),
731 };
732 windows.push(json!({
733 "window": label,
734 "used_pct": used_pct,
735 "resets_at_s": window.get("reset_at").and_then(Value::as_i64),
736 }));
737 }
738 if windows.is_empty() {
739 return None;
740 }
741 Some(json!({
742 "source": "Codex usage API",
743 "observed_at_ms": now_ms(),
744 "plan": raw.get("plan_type").cloned().unwrap_or(Value::Null),
745 "windows": windows,
746 "credits": raw.get("credits").cloned().unwrap_or(Value::Null),
747 }))
748}
749
750async fn fetch_codex_usage(access_token: &str, account_id: Option<&str>) -> Result<Value> {
751 let client = reqwest::Client::builder()
752 .timeout(std::time::Duration::from_secs(10))
753 .build()?;
754 let mut request = client
755 .get(OPENAI_USAGE_URL)
756 .bearer_auth(access_token)
757 .header("accept", "application/json")
758 .header("user-agent", "Alexandria");
759 if let Some(account_id) = account_id.filter(|value| !value.is_empty()) {
760 request = request.header("chatgpt-account-id", account_id);
761 }
762 let response = request.send().await?;
763 let status = response.status();
764 let raw: Value = response.json().await?;
765 if !status.is_success() {
766 bail!("Codex usage verification failed ({status})");
767 }
768 codex_usage_snapshot(&raw).context("Codex usage response did not contain rate-limit windows")
769}
770
771async fn save_auto_codex_account(vault: &Vault, mut account: Account) -> Result<String> {
772 let provider_account_id = account
773 .account_meta
774 .get("account_id")
775 .and_then(Value::as_str)
776 .map(String::from);
777 let email = account
778 .account_meta
779 .get("email")
780 .and_then(Value::as_str)
781 .map(|value| value.trim().to_ascii_lowercase())
782 .filter(|value| !value.is_empty());
783 let identity = provider_account_id
784 .as_deref()
785 .or(email.as_deref())
786 .context("Codex login succeeded but no account identity or email was returned")?;
787 let existing = vault.list().await.into_iter().find(|candidate| {
788 if candidate.provider != Provider::Openai || candidate.kind != "oauth" {
789 return false;
790 }
791 let existing_account_id = candidate
792 .account_meta
793 .get("account_id")
794 .and_then(Value::as_str);
795 if let (Some(expected), Some(actual)) =
796 (provider_account_id.as_deref(), existing_account_id)
797 {
798 return expected == actual;
799 }
800 provider_account_id.is_none()
801 && email.as_deref()
802 == candidate
803 .account_meta
804 .get("email")
805 .and_then(Value::as_str)
806 .map(str::trim)
807 });
808 if let Some(existing) = existing {
809 if let (Some(old), Some(new)) = (
810 existing.account_meta.as_object(),
811 account.account_meta.as_object_mut(),
812 ) {
813 for (key, value) in old {
814 new.entry(key.clone()).or_insert_with(|| value.clone());
815 }
816 }
817 account.id = existing.id;
818 account.name = existing.name;
819 account.description = existing.description.or(email.clone());
820 account.paused = existing.paused;
821 account.status = existing.status;
822 account.cooldown_until_ms = None;
823 account.path = existing.path;
824 } else {
825 let digest = Sha256::digest(identity.as_bytes());
826 let suffix: String = digest[..8].iter().map(|byte| format!("{byte:02x}")).collect();
827 account.name = format!("acct-{suffix}");
828 account.id = named_account_id(Provider::Openai, &account.kind, &account.name);
829 account.description = email;
830 account.path = None;
831 }
832 let id = account.id.clone();
833 vault.upsert(account).await?;
834 Ok(id)
835}
836
837async fn login_codex(vault: &Vault, account_name: &str) -> Result<String> {
838 let listener = TcpListener::bind(OPENAI_CALLBACK_ADDR)
839 .await
840 .with_context(|| format!("binding {OPENAI_CALLBACK_ADDR} for the oauth callback"))?;
841 let pkce = generate_pkce();
842 let state = random_state();
843 let url = openai_authorize_url(&pkce.challenge, &state);
844 println!("open this url to authorize:\n\n {url}\n");
845 println!("waiting for the browser callback on {OPENAI_REDIRECT_URI} ...");
846 open_browser(&url);
847 let code = wait_for_openai_callback(&listener, &state).await?;
848 codex_exchange_named(vault, &pkce.verifier, &code, account_name).await
849}
850
851pub fn gemini_authorize_url(challenge: &str, state: &str, redirect_uri: &str) -> String {
852 let mut url = reqwest::Url::parse(GEMINI_AUTHORIZE_URL).unwrap();
853 url.query_pairs_mut()
854 .append_pair("client_id", crate::GEMINI_CLIENT_ID)
855 .append_pair("redirect_uri", redirect_uri)
856 .append_pair("response_type", "code")
857 .append_pair("scope", GEMINI_SCOPES)
858 .append_pair("code_challenge", challenge)
859 .append_pair("code_challenge_method", "S256")
860 .append_pair("state", state)
861 .append_pair("access_type", "offline")
862 .append_pair("prompt", "consent");
863 url.to_string()
864}
865
866pub async fn gemini_exchange(
867 vault: &Vault,
868 verifier: &str,
869 redirect_uri: &str,
870 code: &str,
871) -> Result<String> {
872 let resp = reqwest::Client::new()
873 .post(crate::GOOGLE_TOKEN_URL)
874 .form(&[
875 ("grant_type", "authorization_code"),
876 ("code", code),
877 ("client_id", crate::GEMINI_CLIENT_ID),
878 ("client_secret", &crate::gemini_client_secret()),
879 ("redirect_uri", redirect_uri),
880 ("code_verifier", verifier),
881 ])
882 .send()
883 .await?;
884 let tokens = read_token_response(resp).await?;
885 let email = tokens
886 .id_token
887 .as_deref()
888 .and_then(jwt_payload)
889 .and_then(|p| p.get("email").and_then(|v| v.as_str().map(String::from)));
890 let label = match &email {
891 Some(e) => format!("gemini ({e})"),
892 None => "gemini (oauth login)".into(),
893 };
894 let account = Account {
895 id: named_account_id(Provider::Gemini, "oauth", "default"),
896 provider: Provider::Gemini,
897 kind: "oauth".into(),
898 name: "default".into(),
899 description: None,
900 paused: false,
901 label: Some(label),
902 access_token: Some(tokens.access_token.clone()),
903 refresh_token: tokens.refresh_token,
904 id_token: tokens.id_token,
905 api_key: None,
906 expires_at_ms: tokens.expires_in.map(|s| now_ms() + s * 1000),
907 last_refresh_ms: Some(now_ms()),
908 account_meta: json!({"email": email}),
909 cooldown_until_ms: None,
910 status: "active".into(),
911 path: None,
912 };
913 vault.upsert(account).await?;
914 Ok("gemini-oauth".into())
915}
916
917pub async fn bind_loopback() -> Result<(TcpListener, u16)> {
918 let listener = TcpListener::bind("127.0.0.1:0")
919 .await
920 .context("binding a loopback port for the oauth callback")?;
921 let port = listener.local_addr()?.port();
922 Ok((listener, port))
923}
924
925async fn login_gemini(vault: &Vault) -> Result<String> {
926 let (listener, port) = bind_loopback().await?;
927 let redirect_uri = format!("http://localhost:{port}{GEMINI_CALLBACK_PATH}");
928 let pkce = generate_pkce();
929 let state = random_state();
930 let url = gemini_authorize_url(&pkce.challenge, &state, &redirect_uri);
931 println!("open this url and pick a Google account:\n\n {url}\n");
932 println!("waiting for the browser callback on {redirect_uri} ...");
933 open_browser(&url);
934 let code = wait_for_loopback_callback(&listener, &state, GEMINI_CALLBACK_PATH).await?;
935 gemini_exchange(vault, &pkce.verifier, &redirect_uri, &code).await
936}
937
938#[derive(Debug, Clone, Deserialize)]
939pub struct XaiDeviceStart {
940 pub device_code: String,
941 pub user_code: String,
942 pub verification_uri: String,
943 #[serde(default)]
944 pub verification_uri_complete: Option<String>,
945 pub expires_in: i64,
946 #[serde(default = "default_device_interval")]
947 pub interval: i64,
948}
949
950fn default_device_interval() -> i64 {
951 5
952}
953
954#[derive(Debug, Clone, PartialEq)]
955pub enum XaiDevicePoll {
956 Pending,
957 SlowDown,
958 Done(Box<XaiTokens>),
959 Failed(String),
960}
961
962#[derive(Debug, Clone, Deserialize, PartialEq)]
963pub struct XaiTokens {
964 pub access_token: String,
965 #[serde(default)]
966 pub refresh_token: Option<String>,
967 #[serde(default)]
968 pub id_token: Option<String>,
969 #[serde(default)]
970 pub expires_in: Option<i64>,
971}
972
973pub async fn xai_device_start(http: &reqwest::Client) -> Result<XaiDeviceStart> {
974 let resp = http
975 .post(XAI_DEVICE_CODE_URL)
976 .form(&[("client_id", XAI_CLIENT_ID), ("scope", XAI_SCOPES)])
977 .send()
978 .await?;
979 let status = resp.status();
980 let text = resp.text().await?;
981 if !status.is_success() {
982 bail!("xai device code request failed ({status}): {text}");
983 }
984 serde_json::from_str(&text).context("bad xai device code response")
985}
986
987pub fn parse_xai_device_poll(status: u16, body: &str) -> XaiDevicePoll {
988 if (200..300).contains(&status) {
989 return match serde_json::from_str::<XaiTokens>(body) {
990 Ok(tokens) => XaiDevicePoll::Done(Box::new(tokens)),
991 Err(e) => XaiDevicePoll::Failed(format!("bad xai token response: {e}")),
992 };
993 }
994 let err = serde_json::from_str::<serde_json::Value>(body)
995 .ok()
996 .and_then(|v| v["error"].as_str().map(String::from))
997 .unwrap_or_default();
998 match err.as_str() {
999 "authorization_pending" => XaiDevicePoll::Pending,
1000 "slow_down" => XaiDevicePoll::SlowDown,
1001 "access_denied" => XaiDevicePoll::Failed("authorization denied".into()),
1002 "expired_token" => XaiDevicePoll::Failed("device code expired".into()),
1003 other => XaiDevicePoll::Failed(format!(
1004 "xai token exchange failed ({status}): {}",
1005 if other.is_empty() { body } else { other }
1006 )),
1007 }
1008}
1009
1010pub async fn xai_device_poll_once(http: &reqwest::Client, device_code: &str) -> XaiDevicePoll {
1011 let resp = http
1012 .post(XAI_TOKEN_URL)
1013 .form(&[
1014 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
1015 ("device_code", device_code),
1016 ("client_id", XAI_CLIENT_ID),
1017 ])
1018 .send()
1019 .await;
1020 match resp {
1021 Ok(resp) => {
1022 let status = resp.status().as_u16();
1023 let body = resp.text().await.unwrap_or_default();
1024 parse_xai_device_poll(status, &body)
1025 }
1026 Err(e) => XaiDevicePoll::Failed(format!("xai token endpoint unreachable: {e}")),
1027 }
1028}
1029
1030pub async fn xai_upsert_from_tokens(vault: &Vault, tokens: &XaiTokens) -> Result<String> {
1031 let email = fetch_provider_email(
1034 &reqwest::Client::new(),
1035 Provider::Xai,
1036 &tokens.access_token,
1037 )
1038 .await;
1039 let label = match &email {
1040 Some(e) => format!("grok ({e})"),
1041 None => "grok (device login)".into(),
1042 };
1043 let mut account = Account {
1044 id: named_account_id(Provider::Xai, "oauth", "default"),
1045 provider: Provider::Xai,
1046 kind: "oauth".into(),
1047 name: "default".into(),
1048 description: email.clone(),
1049 paused: false,
1050 label: Some(label),
1051 access_token: Some(tokens.access_token.clone()),
1052 refresh_token: tokens.refresh_token.clone(),
1053 id_token: tokens.id_token.clone(),
1054 api_key: None,
1055 expires_at_ms: tokens
1056 .expires_in
1057 .map(|s| now_ms() + s * 1000)
1058 .or_else(|| jwt_exp_ms(&tokens.access_token)),
1059 last_refresh_ms: Some(now_ms()),
1060 account_meta: json!({
1061 "oidc_issuer": "https://auth.x.ai",
1062 "oidc_client_id": XAI_CLIENT_ID,
1063 "source": "device login",
1064 }),
1065 cooldown_until_ms: None,
1066 status: "active".into(),
1067 path: None,
1068 };
1069 if let Some(email) = email {
1070 persist_account_email(&mut account, &email);
1071 }
1072 vault.upsert(account).await?;
1073 Ok("xai-oauth".into())
1074}
1075
1076async fn login_grok(vault: &Vault) -> Result<String> {
1077 let http = reqwest::Client::new();
1078 let start = match xai_device_start(&http).await {
1079 Ok(s) => s,
1080 Err(e) => {
1081 println!("xai device flow unavailable ({e}); falling back to grok CLI import:");
1082 println!(" 1. run `grok` in another terminal and complete its login");
1083 println!(" 2. come back here to import the credentials");
1084 prompt_line("press Enter once grok login is done: ").await?;
1085 let outcome = import_grok(vault).await;
1086 if outcome.imported.is_empty() {
1087 bail!(
1088 "grok import found nothing ({})",
1089 outcome
1090 .note
1091 .unwrap_or_else(|| "no ~/.grok/auth.json".into())
1092 );
1093 }
1094 return Ok(outcome.imported.join(", "));
1095 }
1096 };
1097 let url = start
1098 .verification_uri_complete
1099 .clone()
1100 .unwrap_or_else(|| start.verification_uri.clone());
1101 println!("open this url on any device to authorize:\n\n {url}\n");
1102 println!("enter this code when asked: {}", start.user_code);
1103 open_browser(&url);
1104 let deadline = now_ms() + start.expires_in * 1000;
1105 let mut interval = start.interval.max(1) as u64;
1106 loop {
1107 if now_ms() > deadline {
1108 bail!("device code expired before authorization completed");
1109 }
1110 tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
1111 match xai_device_poll_once(&http, &start.device_code).await {
1112 XaiDevicePoll::Pending => continue,
1113 XaiDevicePoll::SlowDown => {
1114 interval += 5;
1115 continue;
1116 }
1117 XaiDevicePoll::Done(tokens) => return xai_upsert_from_tokens(vault, &tokens).await,
1118 XaiDevicePoll::Failed(e) => bail!("xai device login failed: {e}"),
1119 }
1120 }
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126
1127 fn test_codex_account(token: &str) -> Account {
1128 Account {
1129 id: named_account_id(Provider::Openai, "oauth", "default"),
1130 provider: Provider::Openai,
1131 kind: "oauth".into(),
1132 name: "default".into(),
1133 description: None,
1134 paused: false,
1135 label: Some("codex (test)".into()),
1136 access_token: Some(token.into()),
1137 refresh_token: Some(format!("refresh-{token}")),
1138 id_token: None,
1139 api_key: None,
1140 expires_at_ms: Some(now_ms() + 60_000),
1141 last_refresh_ms: Some(now_ms()),
1142 account_meta: json!({"account_id": format!("account-{token}")}),
1143 cooldown_until_ms: None,
1144 status: "active".into(),
1145 path: None,
1146 }
1147 }
1148
1149 #[tokio::test]
1150 async fn named_codex_save_preserves_existing_default() {
1151 let dir = std::env::temp_dir().join(format!(
1152 "alexandria-named-codex-{}-{}",
1153 std::process::id(),
1154 now_ms()
1155 ));
1156 let vault = Vault::open(dir.clone()).unwrap();
1157 vault.upsert(test_codex_account("default-token")).await.unwrap();
1158
1159 let named_id = save_named_login_account(
1160 &vault,
1161 test_codex_account("second-token"),
1162 "work",
1163 )
1164 .await
1165 .unwrap();
1166
1167 assert_eq!(named_id, "openai-oauth-work");
1168 let accounts = vault.list().await;
1169 assert_eq!(accounts.len(), 2);
1170 let default = accounts.iter().find(|account| account.name == "default").unwrap();
1171 let work = accounts.iter().find(|account| account.name == "work").unwrap();
1172 assert_eq!(default.access_token.as_deref(), Some("default-token"));
1173 assert_eq!(work.access_token.as_deref(), Some("second-token"));
1174 assert!(dir.join("openai-oauth.json").exists());
1175 assert!(dir.join("openai-oauth-work.json").exists());
1176 std::fs::remove_dir_all(dir).ok();
1177 }
1178
1179 fn identified_codex_account(token: &str, account_id: &str, email: &str) -> Account {
1180 let mut account = test_codex_account(token);
1181 account.account_meta = json!({
1182 "account_id": account_id,
1183 "email": email,
1184 "codex_limits": {"windows": []},
1185 });
1186 account.label = Some(format!("codex ({email})"));
1187 account
1188 }
1189
1190 #[tokio::test]
1191 async fn automatic_codex_identity_adds_reauths_and_preserves_workspaces() {
1192 let dir = std::env::temp_dir().join(format!(
1193 "alexandria-auto-codex-{}-{}",
1194 std::process::id(),
1195 now_ms()
1196 ));
1197 let vault = Vault::open(dir.clone()).unwrap();
1198 vault
1199 .upsert(identified_codex_account(
1200 "default-token",
1201 "workspace-default",
1202 "person@example.com",
1203 ))
1204 .await
1205 .unwrap();
1206
1207 let default_id = save_auto_codex_account(
1208 &vault,
1209 identified_codex_account(
1210 "default-reauthed",
1211 "workspace-default",
1212 "person@example.com",
1213 ),
1214 )
1215 .await
1216 .unwrap();
1217 assert_eq!(default_id, "openai-oauth");
1218 assert_eq!(vault.list().await.len(), 1);
1219
1220 let second_id = save_auto_codex_account(
1221 &vault,
1222 identified_codex_account(
1223 "second-token",
1224 "workspace-second",
1225 "second@example.com",
1226 ),
1227 )
1228 .await
1229 .unwrap();
1230 assert!(second_id.starts_with("openai-oauth-acct-"));
1231 assert_eq!(vault.list().await.len(), 2);
1232
1233 let repeated_id = save_auto_codex_account(
1234 &vault,
1235 identified_codex_account(
1236 "replacement-token",
1237 "workspace-second",
1238 "second@example.com",
1239 ),
1240 )
1241 .await
1242 .unwrap();
1243 assert_eq!(repeated_id, second_id);
1244 assert_eq!(vault.list().await.len(), 2);
1245 assert_eq!(
1246 vault
1247 .list()
1248 .await
1249 .into_iter()
1250 .find(|account| account.id == second_id)
1251 .unwrap()
1252 .access_token
1253 .as_deref(),
1254 Some("replacement-token")
1255 );
1256
1257 let third_id = save_auto_codex_account(
1258 &vault,
1259 identified_codex_account(
1260 "third-token",
1261 "workspace-third",
1262 "second@example.com",
1263 ),
1264 )
1265 .await
1266 .unwrap();
1267 assert_ne!(third_id, second_id);
1268 assert_eq!(vault.list().await.len(), 3);
1269 assert_eq!(
1270 vault
1271 .list()
1272 .await
1273 .into_iter()
1274 .find(|account| account.id == second_id)
1275 .unwrap()
1276 .description
1277 .as_deref(),
1278 Some("second@example.com")
1279 );
1280 std::fs::remove_dir_all(dir).ok();
1281 }
1282
1283 #[tokio::test]
1284 async fn automatic_codex_identity_rejects_unidentified_account_without_mutation() {
1285 let dir = std::env::temp_dir().join(format!(
1286 "alexandria-auto-codex-missing-{}-{}",
1287 std::process::id(),
1288 now_ms()
1289 ));
1290 let vault = Vault::open(dir.clone()).unwrap();
1291 let mut account = test_codex_account("unknown-token");
1292 account.account_meta = json!({});
1293 assert!(save_auto_codex_account(&vault, account).await.is_err());
1294 assert!(vault.list().await.is_empty());
1295 std::fs::remove_dir_all(dir).ok();
1296 }
1297
1298 #[test]
1299 fn codex_usage_snapshot_maps_windows_without_requiring_secondary() {
1300 let snapshot = codex_usage_snapshot(&json!({
1301 "plan_type": "plus",
1302 "rate_limit": {
1303 "primary_window": {
1304 "used_percent": 23,
1305 "reset_at": 1_800_000_000,
1306 "limit_window_seconds": 18_000
1307 }
1308 },
1309 "credits": {"has_credits": false, "unlimited": false, "balance": 0}
1310 }))
1311 .unwrap();
1312 assert_eq!(snapshot["plan"], "plus");
1313 assert_eq!(snapshot["windows"][0]["window"], "5h");
1314 assert_eq!(snapshot["windows"][0]["used_pct"], 23.0);
1315 assert_eq!(snapshot["windows"].as_array().unwrap().len(), 1);
1316 }
1317
1318 #[test]
1319 fn codex_device_poll_handles_pending_and_validates_pkce() {
1320 assert_eq!(parse_codex_device_poll(403, "{}"), CodexDevicePoll::Pending);
1321 assert_eq!(parse_codex_device_poll(404, "{}"), CodexDevicePoll::Pending);
1322 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1323 let body = json!({
1324 "authorization_code": "auth-code",
1325 "code_verifier": verifier,
1326 "code_challenge": pkce_challenge(verifier),
1327 })
1328 .to_string();
1329 assert_eq!(
1330 parse_codex_device_poll(200, &body),
1331 CodexDevicePoll::Done {
1332 authorization_code: "auth-code".into(),
1333 code_verifier: verifier.into(),
1334 }
1335 );
1336 let bad = json!({
1337 "authorization_code": "auth-code",
1338 "code_verifier": verifier,
1339 "code_challenge": "wrong",
1340 })
1341 .to_string();
1342 assert!(matches!(
1343 parse_codex_device_poll(200, &bad),
1344 CodexDevicePoll::Failed(_)
1345 ));
1346 }
1347
1348 #[test]
1349 fn pkce_shape() {
1350 let pkce = generate_pkce();
1351 assert_eq!(pkce.verifier.len(), 43);
1352 assert_eq!(pkce.challenge.len(), 43);
1353 for c in pkce.verifier.chars().chain(pkce.challenge.chars()) {
1354 assert!(c.is_ascii_alphanumeric() || c == '-' || c == '_');
1355 }
1356 assert_eq!(pkce.challenge, pkce_challenge(&pkce.verifier));
1357 assert_ne!(generate_pkce().verifier, pkce.verifier);
1358 }
1359
1360 #[test]
1361 fn pkce_rfc7636_vector() {
1362 assert_eq!(
1363 pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
1364 "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
1365 );
1366 }
1367
1368 #[test]
1369 fn anthropic_url_params() {
1370 let url = anthropic_authorize_url("chal", "stat");
1371 assert!(url.starts_with(ANTHROPIC_AUTHORIZE_URL));
1372 let parsed = reqwest::Url::parse(&url).unwrap();
1373 let q: HashMap<String, String> = parsed
1374 .query_pairs()
1375 .map(|(k, v)| (k.into_owned(), v.into_owned()))
1376 .collect();
1377 assert_eq!(q["code"], "true");
1378 assert_eq!(q["client_id"], ANTHROPIC_CLIENT_ID);
1379 assert_eq!(q["response_type"], "code");
1380 assert_eq!(q["redirect_uri"], ANTHROPIC_REDIRECT_URI);
1381 assert_eq!(q["scope"], ANTHROPIC_SCOPES);
1382 assert_eq!(q["code_challenge"], "chal");
1383 assert_eq!(q["code_challenge_method"], "S256");
1384 assert_eq!(q["state"], "stat");
1385 }
1386
1387 #[test]
1388 fn openai_url_params() {
1389 let url = openai_authorize_url("chal", "stat");
1390 assert!(url.starts_with(OPENAI_AUTHORIZE_URL));
1391 let parsed = reqwest::Url::parse(&url).unwrap();
1392 let q: HashMap<String, String> = parsed
1393 .query_pairs()
1394 .map(|(k, v)| (k.into_owned(), v.into_owned()))
1395 .collect();
1396 assert_eq!(q["response_type"], "code");
1397 assert_eq!(q["client_id"], OPENAI_CLIENT_ID);
1398 assert_eq!(q["redirect_uri"], OPENAI_REDIRECT_URI);
1399 assert_eq!(q["scope"], OPENAI_SCOPES);
1400 assert_eq!(q["code_challenge"], "chal");
1401 assert_eq!(q["code_challenge_method"], "S256");
1402 assert_eq!(q["state"], "stat");
1403 assert_eq!(q["id_token_add_organizations"], "true");
1404 assert_eq!(q["codex_cli_simplified_flow"], "true");
1405 assert_eq!(q["originator"], "pi");
1406 }
1407
1408 #[test]
1409 fn authorization_input_parsing() {
1410 assert_eq!(
1411 parse_authorization_input("abc#xyz"),
1412 (Some("abc".into()), Some("xyz".into()))
1413 );
1414 assert_eq!(
1415 parse_authorization_input(" plaincode \n"),
1416 (Some("plaincode".into()), None)
1417 );
1418 assert_eq!(
1419 parse_authorization_input("code=abc&state=xyz"),
1420 (Some("abc".into()), Some("xyz".into()))
1421 );
1422 assert_eq!(
1423 parse_authorization_input("http://localhost:1455/auth/callback?code=abc&state=xyz"),
1424 (Some("abc".into()), Some("xyz".into()))
1425 );
1426 assert_eq!(parse_authorization_input(""), (None, None));
1427 assert_eq!(parse_authorization_input(" "), (None, None));
1428 }
1429
1430 #[test]
1431 fn jwt_account_id_extraction() {
1432 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
1433 serde_json::to_vec(&json!({
1434 "https://api.openai.com/auth": {"chatgpt_account_id": "acct-123"}
1435 }))
1436 .unwrap(),
1437 );
1438 let token = format!("eyJhbGciOiJub25lIn0.{payload}.sig");
1439 assert_eq!(chatgpt_account_id(&token), Some("acct-123".into()));
1440 assert_eq!(chatgpt_account_id("not-a-jwt"), None);
1441 let empty = base64::engine::general_purpose::URL_SAFE_NO_PAD
1442 .encode(serde_json::to_vec(&json!({})).unwrap());
1443 assert_eq!(chatgpt_account_id(&format!("h.{empty}.s")), None);
1444 }
1445
1446 #[test]
1447 fn browser_command_per_platform() {
1448 let cmd = browser_open_command("https://example.com/auth?x=1");
1449 if cfg!(any(
1450 target_os = "macos",
1451 target_os = "linux",
1452 target_os = "windows"
1453 )) {
1454 let (program, args) = cmd.expect("major platforms have a browser opener");
1455 let expected = if cfg!(target_os = "macos") {
1456 "open"
1457 } else if cfg!(target_os = "windows") {
1458 "cmd"
1459 } else {
1460 "xdg-open"
1461 };
1462 assert_eq!(program, expected);
1463 assert_eq!(
1464 args.last().map(String::as_str),
1465 Some("https://example.com/auth?x=1")
1466 );
1467 if cfg!(target_os = "windows") {
1468 assert_eq!(args[..3], ["/C", "start", ""]);
1469 }
1470 } else {
1471 assert!(cmd.is_none());
1472 }
1473 }
1474
1475 #[test]
1476 fn xai_device_poll_parsing() {
1477 assert_eq!(
1478 parse_xai_device_poll(400, r#"{"error":"authorization_pending"}"#),
1479 XaiDevicePoll::Pending
1480 );
1481 assert_eq!(
1482 parse_xai_device_poll(429, r#"{"error":"slow_down"}"#),
1483 XaiDevicePoll::SlowDown
1484 );
1485 assert!(matches!(
1486 parse_xai_device_poll(400, r#"{"error":"access_denied"}"#),
1487 XaiDevicePoll::Failed(e) if e.contains("denied")
1488 ));
1489 assert!(matches!(
1490 parse_xai_device_poll(400, r#"{"error":"expired_token"}"#),
1491 XaiDevicePoll::Failed(e) if e.contains("expired")
1492 ));
1493 let done = parse_xai_device_poll(
1494 200,
1495 r#"{"access_token":"tok","refresh_token":"ref","expires_in":3600}"#,
1496 );
1497 match done {
1498 XaiDevicePoll::Done(t) => {
1499 assert_eq!(t.access_token, "tok");
1500 assert_eq!(t.refresh_token.as_deref(), Some("ref"));
1501 assert_eq!(t.expires_in, Some(3600));
1502 }
1503 other => panic!("expected Done, got {other:?}"),
1504 }
1505 assert!(matches!(
1506 parse_xai_device_poll(200, "not json"),
1507 XaiDevicePoll::Failed(_)
1508 ));
1509 assert!(matches!(
1510 parse_xai_device_poll(500, "boom"),
1511 XaiDevicePoll::Failed(e) if e.contains("500")
1512 ));
1513 }
1514
1515 #[test]
1516 fn callback_request_parsing() {
1517 let req = "GET /auth/callback?code=abc&state=xyz HTTP/1.1\r\nHost: localhost\r\n\r\n";
1518 let target = request_target(req).unwrap();
1519 assert_eq!(callback_path(target), OPENAI_CALLBACK_PATH);
1520 let q = callback_query(target);
1521 assert_eq!(q["code"], "abc");
1522 assert_eq!(q["state"], "xyz");
1523 assert_eq!(callback_path("/favicon.ico"), "/favicon.ico");
1524 assert!(callback_query("/favicon.ico").is_empty());
1525 assert!(request_target("").is_none());
1526 }
1527}