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;
9use sha2::{Digest, Sha256};
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::net::{TcpListener, TcpStream};
12
13use crate::{
14 import_grok, jwt_exp_ms, now_ms, Account, Vault, ANTHROPIC_CLIENT_ID, ANTHROPIC_TOKEN_URL,
15 OPENAI_CLIENT_ID, OPENAI_TOKEN_URL, XAI_CLIENT_ID, XAI_TOKEN_URL,
16};
17use alex_core::Provider;
18
19pub const PROVIDERS: &[&str] = &["claude", "codex", "grok", "gemini"];
20
21pub const ANTHROPIC_AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
22pub const ANTHROPIC_REDIRECT_URI: &str = "https://console.anthropic.com/oauth/code/callback";
23pub const ANTHROPIC_SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
24pub const OPENAI_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
25pub const OPENAI_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
26pub const OPENAI_SCOPES: &str = "openid profile email offline_access";
27pub const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
28pub(crate) const OPENAI_CALLBACK_ADDR: &str = "127.0.0.1:1455";
29const OPENAI_JWT_CLAIM: &str = "https://api.openai.com/auth";
30pub const XAI_DEVICE_CODE_URL: &str = "https://auth.x.ai/oauth2/device/code";
31pub const XAI_SCOPES: &str = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write";
32pub const GEMINI_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
33pub 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";
34pub const GEMINI_CALLBACK_PATH: &str = "/oauth2callback";
35
36pub struct Pkce {
37 pub verifier: String,
38 pub challenge: String,
39}
40
41fn base64url(bytes: &[u8]) -> String {
42 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
43}
44
45pub fn pkce_challenge(verifier: &str) -> String {
46 base64url(&Sha256::digest(verifier.as_bytes()))
47}
48
49pub fn generate_pkce() -> Pkce {
50 let mut bytes = [0u8; 32];
51 rand::thread_rng().fill_bytes(&mut bytes);
52 let verifier = base64url(&bytes);
53 let challenge = pkce_challenge(&verifier);
54 Pkce {
55 verifier,
56 challenge,
57 }
58}
59
60fn random_state() -> String {
61 let mut bytes = [0u8; 16];
62 rand::thread_rng().fill_bytes(&mut bytes);
63 bytes.iter().map(|b| format!("{b:02x}")).collect()
64}
65
66pub fn anthropic_authorize_url(challenge: &str, state: &str) -> String {
67 let mut url = reqwest::Url::parse(ANTHROPIC_AUTHORIZE_URL).unwrap();
68 url.query_pairs_mut()
69 .append_pair("code", "true")
70 .append_pair("client_id", ANTHROPIC_CLIENT_ID)
71 .append_pair("response_type", "code")
72 .append_pair("redirect_uri", ANTHROPIC_REDIRECT_URI)
73 .append_pair("scope", ANTHROPIC_SCOPES)
74 .append_pair("code_challenge", challenge)
75 .append_pair("code_challenge_method", "S256")
76 .append_pair("state", state);
77 url.to_string()
78}
79
80pub fn openai_authorize_url(challenge: &str, state: &str) -> String {
81 let mut url = reqwest::Url::parse(OPENAI_AUTHORIZE_URL).unwrap();
82 url.query_pairs_mut()
83 .append_pair("response_type", "code")
84 .append_pair("client_id", OPENAI_CLIENT_ID)
85 .append_pair("redirect_uri", OPENAI_REDIRECT_URI)
86 .append_pair("scope", OPENAI_SCOPES)
87 .append_pair("code_challenge", challenge)
88 .append_pair("code_challenge_method", "S256")
89 .append_pair("state", state)
90 .append_pair("id_token_add_organizations", "true")
91 .append_pair("codex_cli_simplified_flow", "true")
92 .append_pair("originator", "pi");
93 url.to_string()
94}
95
96pub fn parse_authorization_input(input: &str) -> (Option<String>, Option<String>) {
97 let value = input.trim();
98 if value.is_empty() {
99 return (None, None);
100 }
101 if let Ok(url) = reqwest::Url::parse(value) {
102 let find = |key: &str| {
103 url.query_pairs()
104 .find(|(k, _)| k == key)
105 .map(|(_, v)| v.into_owned())
106 };
107 return (find("code"), find("state"));
108 }
109 if let Some((code, state)) = value.split_once('#') {
110 return (Some(code.to_string()), Some(state.to_string()));
111 }
112 if value.contains("code=") {
113 let mut code = None;
114 let mut state = None;
115 for pair in value.split('&') {
116 match pair.split_once('=') {
117 Some(("code", v)) => code = Some(v.to_string()),
118 Some(("state", v)) => state = Some(v.to_string()),
119 _ => {}
120 }
121 }
122 return (code, state);
123 }
124 (Some(value.to_string()), None)
125}
126
127pub fn jwt_payload(token: &str) -> Option<serde_json::Value> {
128 let payload = token.split('.').nth(1)?;
129 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
130 .decode(payload)
131 .ok()?;
132 serde_json::from_slice(&bytes).ok()
133}
134
135pub fn chatgpt_account_id(token: &str) -> Option<String> {
136 jwt_payload(token)?
137 .get(OPENAI_JWT_CLAIM)?
138 .get("chatgpt_account_id")?
139 .as_str()
140 .map(String::from)
141}
142
143#[derive(Debug, Deserialize)]
144struct TokenResponse {
145 access_token: String,
146 #[serde(default)]
147 refresh_token: Option<String>,
148 #[serde(default)]
149 id_token: Option<String>,
150 #[serde(default)]
151 expires_in: Option<i64>,
152 #[serde(default)]
153 scope: Option<String>,
154}
155
156async fn read_token_response(resp: reqwest::Response) -> Result<TokenResponse> {
157 let status = resp.status();
158 let text = resp.text().await?;
159 if !status.is_success() {
160 bail!("token exchange failed ({status}): {text}");
161 }
162 serde_json::from_str(&text).context("bad token exchange response")
163}
164
165pub fn browser_open_command(url: &str) -> Option<(&'static str, Vec<String>)> {
166 if cfg!(target_os = "macos") {
167 Some(("open", vec![url.to_string()]))
168 } else if cfg!(target_os = "windows") {
169 Some((
170 "cmd",
171 vec!["/C".into(), "start".into(), String::new(), url.to_string()],
172 ))
173 } else if cfg!(target_os = "linux") {
174 Some(("xdg-open", vec![url.to_string()]))
175 } else {
176 None
177 }
178}
179
180fn open_browser(url: &str) {
181 if let Some((program, args)) = browser_open_command(url) {
182 let _ = std::process::Command::new(program).args(args).spawn();
183 }
184}
185
186async fn prompt_line(message: &str) -> Result<String> {
187 print!("{message}");
188 std::io::stdout().flush()?;
189 let line = tokio::task::spawn_blocking(|| {
190 let mut line = String::new();
191 std::io::stdin().read_line(&mut line).map(|_| line)
192 })
193 .await??;
194 Ok(line.trim().to_string())
195}
196
197fn request_target(request: &str) -> Option<&str> {
198 request.lines().next()?.split_whitespace().nth(1)
199}
200
201fn callback_path(target: &str) -> String {
202 reqwest::Url::parse(&format!("http://localhost{target}"))
203 .map(|u| u.path().to_string())
204 .unwrap_or_default()
205}
206
207fn callback_query(target: &str) -> HashMap<String, String> {
208 reqwest::Url::parse(&format!("http://localhost{target}"))
209 .map(|url| {
210 url.query_pairs()
211 .map(|(k, v)| (k.into_owned(), v.into_owned()))
212 .collect()
213 })
214 .unwrap_or_default()
215}
216
217async fn respond(stream: &mut TcpStream, status: &str, body: &str) {
218 let resp = format!(
219 "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
220 body.len()
221 );
222 let _ = stream.write_all(resp.as_bytes()).await;
223 let _ = stream.shutdown().await;
224}
225
226pub(crate) async fn wait_for_openai_callback(
227 listener: &TcpListener,
228 expected_state: &str,
229) -> Result<String> {
230 wait_for_loopback_callback(listener, expected_state, OPENAI_CALLBACK_PATH).await
231}
232
233pub(crate) async fn wait_for_loopback_callback(
234 listener: &TcpListener,
235 expected_state: &str,
236 expected_path: &str,
237) -> Result<String> {
238 loop {
239 let (mut stream, _) = listener.accept().await?;
240 let mut buf = vec![0u8; 8192];
241 let n = stream.read(&mut buf).await.unwrap_or(0);
242 let request = String::from_utf8_lossy(&buf[..n]).into_owned();
243 let Some(target) = request_target(&request) else {
244 respond(
245 &mut stream,
246 "400 Bad Request",
247 "<html><body>bad request</body></html>",
248 )
249 .await;
250 continue;
251 };
252 if callback_path(target) != expected_path {
253 respond(
254 &mut stream,
255 "404 Not Found",
256 "<html><body>not found</body></html>",
257 )
258 .await;
259 continue;
260 }
261 let params = callback_query(target);
262 if let Some(err) = params.get("error") {
263 respond(
264 &mut stream,
265 "400 Bad Request",
266 &format!("<html><body>login failed: {err}</body></html>"),
267 )
268 .await;
269 bail!("oauth provider returned error: {err}");
270 }
271 if params.get("state").map(String::as_str) != Some(expected_state) {
272 respond(
273 &mut stream,
274 "400 Bad Request",
275 "<html><body>state mismatch</body></html>",
276 )
277 .await;
278 continue;
279 }
280 let Some(code) = params.get("code") else {
281 respond(
282 &mut stream,
283 "400 Bad Request",
284 "<html><body>missing code</body></html>",
285 )
286 .await;
287 continue;
288 };
289 let code = code.clone();
290 respond(
291 &mut stream,
292 "200 OK",
293 "<html><body>Login complete. You can close this tab.</body></html>",
294 )
295 .await;
296 return Ok(code);
297 }
298}
299
300pub async fn login(vault: &Vault, provider: &str) -> Result<String> {
301 match provider {
302 "claude" | "anthropic" => login_claude(vault).await,
303 "codex" | "openai" | "chatgpt" => login_codex(vault).await,
304 "grok" | "xai" => login_grok(vault).await,
305 "gemini" | "google" => login_gemini(vault).await,
306 other => bail!("unknown provider '{other}' (expected claude|codex|grok|gemini)"),
307 }
308}
309
310pub async fn claude_exchange(vault: &Vault, verifier: &str, input: &str) -> Result<String> {
311 let (code, state) = parse_authorization_input(input);
312 let code = code.ok_or_else(|| anyhow!("no authorization code provided"))?;
313 if let Some(s) = &state {
314 if s != verifier {
315 bail!("oauth state mismatch");
316 }
317 }
318 let state = state.unwrap_or_else(|| verifier.to_string());
319 let resp = reqwest::Client::new()
320 .post(ANTHROPIC_TOKEN_URL)
321 .json(&json!({
322 "grant_type": "authorization_code",
323 "client_id": ANTHROPIC_CLIENT_ID,
324 "code": code,
325 "state": state,
326 "redirect_uri": ANTHROPIC_REDIRECT_URI,
327 "code_verifier": verifier,
328 }))
329 .send()
330 .await?;
331 let tokens = read_token_response(resp).await?;
332 let scopes: Vec<String> = tokens
333 .scope
334 .as_deref()
335 .map(|s| s.split_whitespace().map(String::from).collect())
336 .unwrap_or_default();
337 let account = Account {
338 id: "anthropic-oauth".into(),
339 provider: Provider::Anthropic,
340 kind: "oauth".into(),
341 label: Some("claude (oauth login)".into()),
342 access_token: Some(tokens.access_token),
343 refresh_token: tokens.refresh_token,
344 id_token: None,
345 api_key: None,
346 expires_at_ms: tokens.expires_in.map(|s| now_ms() + s * 1000),
347 last_refresh_ms: Some(now_ms()),
348 account_meta: json!({"scopes": scopes}),
349 cooldown_until_ms: None,
350 status: "active".into(),
351 };
352 vault.upsert(account).await?;
353 Ok("anthropic-oauth".into())
354}
355
356async fn login_claude(vault: &Vault) -> Result<String> {
357 let pkce = generate_pkce();
358 let url = anthropic_authorize_url(&pkce.challenge, &pkce.verifier);
359 println!("open this url to authorize:\n\n {url}\n");
360 open_browser(&url);
361 let input = prompt_line("paste the authorization code (format: code#state): ").await?;
362 claude_exchange(vault, &pkce.verifier, &input).await
363}
364
365pub async fn codex_exchange(vault: &Vault, verifier: &str, code: &str) -> Result<String> {
366 let resp = reqwest::Client::new()
367 .post(OPENAI_TOKEN_URL)
368 .form(&[
369 ("grant_type", "authorization_code"),
370 ("client_id", OPENAI_CLIENT_ID),
371 ("code", code),
372 ("code_verifier", verifier),
373 ("redirect_uri", OPENAI_REDIRECT_URI),
374 ])
375 .send()
376 .await?;
377 let tokens = read_token_response(resp).await?;
378 let account_id = tokens
379 .id_token
380 .as_deref()
381 .and_then(chatgpt_account_id)
382 .or_else(|| chatgpt_account_id(&tokens.access_token));
383 let account = Account {
384 id: "openai-oauth".into(),
385 provider: Provider::Openai,
386 kind: "oauth".into(),
387 label: Some("codex (chatgpt)".into()),
388 access_token: Some(tokens.access_token.clone()),
389 refresh_token: tokens.refresh_token,
390 id_token: tokens.id_token,
391 api_key: None,
392 expires_at_ms: tokens
393 .expires_in
394 .map(|s| now_ms() + s * 1000)
395 .or_else(|| jwt_exp_ms(&tokens.access_token)),
396 last_refresh_ms: Some(now_ms()),
397 account_meta: json!({"account_id": account_id}),
398 cooldown_until_ms: None,
399 status: "active".into(),
400 };
401 vault.upsert(account).await?;
402 Ok("openai-oauth".into())
403}
404
405async fn login_codex(vault: &Vault) -> Result<String> {
406 let listener = TcpListener::bind(OPENAI_CALLBACK_ADDR)
407 .await
408 .with_context(|| format!("binding {OPENAI_CALLBACK_ADDR} for the oauth callback"))?;
409 let pkce = generate_pkce();
410 let state = random_state();
411 let url = openai_authorize_url(&pkce.challenge, &state);
412 println!("open this url to authorize:\n\n {url}\n");
413 println!("waiting for the browser callback on {OPENAI_REDIRECT_URI} ...");
414 open_browser(&url);
415 let code = wait_for_openai_callback(&listener, &state).await?;
416 codex_exchange(vault, &pkce.verifier, &code).await
417}
418
419pub fn gemini_authorize_url(challenge: &str, state: &str, redirect_uri: &str) -> String {
420 let mut url = reqwest::Url::parse(GEMINI_AUTHORIZE_URL).unwrap();
421 url.query_pairs_mut()
422 .append_pair("client_id", crate::GEMINI_CLIENT_ID)
423 .append_pair("redirect_uri", redirect_uri)
424 .append_pair("response_type", "code")
425 .append_pair("scope", GEMINI_SCOPES)
426 .append_pair("code_challenge", challenge)
427 .append_pair("code_challenge_method", "S256")
428 .append_pair("state", state)
429 .append_pair("access_type", "offline")
430 .append_pair("prompt", "consent");
431 url.to_string()
432}
433
434pub async fn gemini_exchange(
435 vault: &Vault,
436 verifier: &str,
437 redirect_uri: &str,
438 code: &str,
439) -> Result<String> {
440 let resp = reqwest::Client::new()
441 .post(crate::GOOGLE_TOKEN_URL)
442 .form(&[
443 ("grant_type", "authorization_code"),
444 ("code", code),
445 ("client_id", crate::GEMINI_CLIENT_ID),
446 ("client_secret", &crate::gemini_client_secret()),
447 ("redirect_uri", redirect_uri),
448 ("code_verifier", verifier),
449 ])
450 .send()
451 .await?;
452 let tokens = read_token_response(resp).await?;
453 let email = tokens
454 .id_token
455 .as_deref()
456 .and_then(jwt_payload)
457 .and_then(|p| p.get("email").and_then(|v| v.as_str().map(String::from)));
458 let label = match &email {
459 Some(e) => format!("gemini ({e})"),
460 None => "gemini (oauth login)".into(),
461 };
462 let account = Account {
463 id: "gemini-oauth".into(),
464 provider: Provider::Gemini,
465 kind: "oauth".into(),
466 label: Some(label),
467 access_token: Some(tokens.access_token.clone()),
468 refresh_token: tokens.refresh_token,
469 id_token: tokens.id_token,
470 api_key: None,
471 expires_at_ms: tokens.expires_in.map(|s| now_ms() + s * 1000),
472 last_refresh_ms: Some(now_ms()),
473 account_meta: json!({"email": email}),
474 cooldown_until_ms: None,
475 status: "active".into(),
476 };
477 vault.upsert(account).await?;
478 Ok("gemini-oauth".into())
479}
480
481pub async fn bind_loopback() -> Result<(TcpListener, u16)> {
482 let listener = TcpListener::bind("127.0.0.1:0")
483 .await
484 .context("binding a loopback port for the oauth callback")?;
485 let port = listener.local_addr()?.port();
486 Ok((listener, port))
487}
488
489async fn login_gemini(vault: &Vault) -> Result<String> {
490 let (listener, port) = bind_loopback().await?;
491 let redirect_uri = format!("http://localhost:{port}{GEMINI_CALLBACK_PATH}");
492 let pkce = generate_pkce();
493 let state = random_state();
494 let url = gemini_authorize_url(&pkce.challenge, &state, &redirect_uri);
495 println!("open this url and pick a Google account:\n\n {url}\n");
496 println!("waiting for the browser callback on {redirect_uri} ...");
497 open_browser(&url);
498 let code = wait_for_loopback_callback(&listener, &state, GEMINI_CALLBACK_PATH).await?;
499 gemini_exchange(vault, &pkce.verifier, &redirect_uri, &code).await
500}
501
502#[derive(Debug, Clone, Deserialize)]
503pub struct XaiDeviceStart {
504 pub device_code: String,
505 pub user_code: String,
506 pub verification_uri: String,
507 #[serde(default)]
508 pub verification_uri_complete: Option<String>,
509 pub expires_in: i64,
510 #[serde(default = "default_device_interval")]
511 pub interval: i64,
512}
513
514fn default_device_interval() -> i64 {
515 5
516}
517
518#[derive(Debug, Clone, PartialEq)]
519pub enum XaiDevicePoll {
520 Pending,
521 SlowDown,
522 Done(Box<XaiTokens>),
523 Failed(String),
524}
525
526#[derive(Debug, Clone, Deserialize, PartialEq)]
527pub struct XaiTokens {
528 pub access_token: String,
529 #[serde(default)]
530 pub refresh_token: Option<String>,
531 #[serde(default)]
532 pub expires_in: Option<i64>,
533}
534
535pub async fn xai_device_start(http: &reqwest::Client) -> Result<XaiDeviceStart> {
536 let resp = http
537 .post(XAI_DEVICE_CODE_URL)
538 .form(&[("client_id", XAI_CLIENT_ID), ("scope", XAI_SCOPES)])
539 .send()
540 .await?;
541 let status = resp.status();
542 let text = resp.text().await?;
543 if !status.is_success() {
544 bail!("xai device code request failed ({status}): {text}");
545 }
546 serde_json::from_str(&text).context("bad xai device code response")
547}
548
549pub fn parse_xai_device_poll(status: u16, body: &str) -> XaiDevicePoll {
550 if (200..300).contains(&status) {
551 return match serde_json::from_str::<XaiTokens>(body) {
552 Ok(tokens) => XaiDevicePoll::Done(Box::new(tokens)),
553 Err(e) => XaiDevicePoll::Failed(format!("bad xai token response: {e}")),
554 };
555 }
556 let err = serde_json::from_str::<serde_json::Value>(body)
557 .ok()
558 .and_then(|v| v["error"].as_str().map(String::from))
559 .unwrap_or_default();
560 match err.as_str() {
561 "authorization_pending" => XaiDevicePoll::Pending,
562 "slow_down" => XaiDevicePoll::SlowDown,
563 "access_denied" => XaiDevicePoll::Failed("authorization denied".into()),
564 "expired_token" => XaiDevicePoll::Failed("device code expired".into()),
565 other => XaiDevicePoll::Failed(format!(
566 "xai token exchange failed ({status}): {}",
567 if other.is_empty() { body } else { other }
568 )),
569 }
570}
571
572pub async fn xai_device_poll_once(http: &reqwest::Client, device_code: &str) -> XaiDevicePoll {
573 let resp = http
574 .post(XAI_TOKEN_URL)
575 .form(&[
576 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
577 ("device_code", device_code),
578 ("client_id", XAI_CLIENT_ID),
579 ])
580 .send()
581 .await;
582 match resp {
583 Ok(resp) => {
584 let status = resp.status().as_u16();
585 let body = resp.text().await.unwrap_or_default();
586 parse_xai_device_poll(status, &body)
587 }
588 Err(e) => XaiDevicePoll::Failed(format!("xai token endpoint unreachable: {e}")),
589 }
590}
591
592pub async fn xai_upsert_from_tokens(vault: &Vault, tokens: &XaiTokens) -> Result<String> {
593 let email = jwt_payload(&tokens.access_token)
594 .and_then(|p| p.get("email").and_then(|v| v.as_str().map(String::from)));
595 let label = match &email {
596 Some(e) => format!("grok ({e})"),
597 None => "grok (device login)".into(),
598 };
599 let account = Account {
600 id: "xai-oauth".into(),
601 provider: Provider::Xai,
602 kind: "oauth".into(),
603 label: Some(label),
604 access_token: Some(tokens.access_token.clone()),
605 refresh_token: tokens.refresh_token.clone(),
606 id_token: None,
607 api_key: None,
608 expires_at_ms: tokens
609 .expires_in
610 .map(|s| now_ms() + s * 1000)
611 .or_else(|| jwt_exp_ms(&tokens.access_token)),
612 last_refresh_ms: Some(now_ms()),
613 account_meta: json!({
614 "oidc_issuer": "https://auth.x.ai",
615 "oidc_client_id": XAI_CLIENT_ID,
616 "source": "device login",
617 }),
618 cooldown_until_ms: None,
619 status: "active".into(),
620 };
621 vault.upsert(account).await?;
622 Ok("xai-oauth".into())
623}
624
625async fn login_grok(vault: &Vault) -> Result<String> {
626 let http = reqwest::Client::new();
627 let start = match xai_device_start(&http).await {
628 Ok(s) => s,
629 Err(e) => {
630 println!("xai device flow unavailable ({e}); falling back to grok CLI import:");
631 println!(" 1. run `grok` in another terminal and complete its login");
632 println!(" 2. come back here to import the credentials");
633 prompt_line("press Enter once grok login is done: ").await?;
634 let outcome = import_grok(vault).await;
635 if outcome.imported.is_empty() {
636 bail!(
637 "grok import found nothing ({})",
638 outcome
639 .note
640 .unwrap_or_else(|| "no ~/.grok/auth.json".into())
641 );
642 }
643 return Ok(outcome.imported.join(", "));
644 }
645 };
646 let url = start
647 .verification_uri_complete
648 .clone()
649 .unwrap_or_else(|| start.verification_uri.clone());
650 println!("open this url on any device to authorize:\n\n {url}\n");
651 println!("enter this code when asked: {}", start.user_code);
652 open_browser(&url);
653 let deadline = now_ms() + start.expires_in * 1000;
654 let mut interval = start.interval.max(1) as u64;
655 loop {
656 if now_ms() > deadline {
657 bail!("device code expired before authorization completed");
658 }
659 tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
660 match xai_device_poll_once(&http, &start.device_code).await {
661 XaiDevicePoll::Pending => continue,
662 XaiDevicePoll::SlowDown => {
663 interval += 5;
664 continue;
665 }
666 XaiDevicePoll::Done(tokens) => return xai_upsert_from_tokens(vault, &tokens).await,
667 XaiDevicePoll::Failed(e) => bail!("xai device login failed: {e}"),
668 }
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 #[test]
677 fn pkce_shape() {
678 let pkce = generate_pkce();
679 assert_eq!(pkce.verifier.len(), 43);
680 assert_eq!(pkce.challenge.len(), 43);
681 for c in pkce.verifier.chars().chain(pkce.challenge.chars()) {
682 assert!(c.is_ascii_alphanumeric() || c == '-' || c == '_');
683 }
684 assert_eq!(pkce.challenge, pkce_challenge(&pkce.verifier));
685 assert_ne!(generate_pkce().verifier, pkce.verifier);
686 }
687
688 #[test]
689 fn pkce_rfc7636_vector() {
690 assert_eq!(
691 pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
692 "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
693 );
694 }
695
696 #[test]
697 fn anthropic_url_params() {
698 let url = anthropic_authorize_url("chal", "stat");
699 assert!(url.starts_with(ANTHROPIC_AUTHORIZE_URL));
700 let parsed = reqwest::Url::parse(&url).unwrap();
701 let q: HashMap<String, String> = parsed
702 .query_pairs()
703 .map(|(k, v)| (k.into_owned(), v.into_owned()))
704 .collect();
705 assert_eq!(q["code"], "true");
706 assert_eq!(q["client_id"], ANTHROPIC_CLIENT_ID);
707 assert_eq!(q["response_type"], "code");
708 assert_eq!(q["redirect_uri"], ANTHROPIC_REDIRECT_URI);
709 assert_eq!(q["scope"], ANTHROPIC_SCOPES);
710 assert_eq!(q["code_challenge"], "chal");
711 assert_eq!(q["code_challenge_method"], "S256");
712 assert_eq!(q["state"], "stat");
713 }
714
715 #[test]
716 fn openai_url_params() {
717 let url = openai_authorize_url("chal", "stat");
718 assert!(url.starts_with(OPENAI_AUTHORIZE_URL));
719 let parsed = reqwest::Url::parse(&url).unwrap();
720 let q: HashMap<String, String> = parsed
721 .query_pairs()
722 .map(|(k, v)| (k.into_owned(), v.into_owned()))
723 .collect();
724 assert_eq!(q["response_type"], "code");
725 assert_eq!(q["client_id"], OPENAI_CLIENT_ID);
726 assert_eq!(q["redirect_uri"], OPENAI_REDIRECT_URI);
727 assert_eq!(q["scope"], OPENAI_SCOPES);
728 assert_eq!(q["code_challenge"], "chal");
729 assert_eq!(q["code_challenge_method"], "S256");
730 assert_eq!(q["state"], "stat");
731 assert_eq!(q["id_token_add_organizations"], "true");
732 assert_eq!(q["codex_cli_simplified_flow"], "true");
733 assert_eq!(q["originator"], "pi");
734 }
735
736 #[test]
737 fn authorization_input_parsing() {
738 assert_eq!(
739 parse_authorization_input("abc#xyz"),
740 (Some("abc".into()), Some("xyz".into()))
741 );
742 assert_eq!(
743 parse_authorization_input(" plaincode \n"),
744 (Some("plaincode".into()), None)
745 );
746 assert_eq!(
747 parse_authorization_input("code=abc&state=xyz"),
748 (Some("abc".into()), Some("xyz".into()))
749 );
750 assert_eq!(
751 parse_authorization_input("http://localhost:1455/auth/callback?code=abc&state=xyz"),
752 (Some("abc".into()), Some("xyz".into()))
753 );
754 assert_eq!(parse_authorization_input(""), (None, None));
755 assert_eq!(parse_authorization_input(" "), (None, None));
756 }
757
758 #[test]
759 fn jwt_account_id_extraction() {
760 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
761 serde_json::to_vec(&json!({
762 "https://api.openai.com/auth": {"chatgpt_account_id": "acct-123"}
763 }))
764 .unwrap(),
765 );
766 let token = format!("eyJhbGciOiJub25lIn0.{payload}.sig");
767 assert_eq!(chatgpt_account_id(&token), Some("acct-123".into()));
768 assert_eq!(chatgpt_account_id("not-a-jwt"), None);
769 let empty = base64::engine::general_purpose::URL_SAFE_NO_PAD
770 .encode(serde_json::to_vec(&json!({})).unwrap());
771 assert_eq!(chatgpt_account_id(&format!("h.{empty}.s")), None);
772 }
773
774 #[test]
775 fn browser_command_per_platform() {
776 let cmd = browser_open_command("https://example.com/auth?x=1");
777 if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) {
778 let (program, args) = cmd.expect("major platforms have a browser opener");
779 let expected = if cfg!(target_os = "macos") {
780 "open"
781 } else if cfg!(target_os = "windows") {
782 "cmd"
783 } else {
784 "xdg-open"
785 };
786 assert_eq!(program, expected);
787 assert_eq!(
788 args.last().map(String::as_str),
789 Some("https://example.com/auth?x=1")
790 );
791 if cfg!(target_os = "windows") {
792 assert_eq!(args[..3], ["/C", "start", ""]);
793 }
794 } else {
795 assert!(cmd.is_none());
796 }
797 }
798
799 #[test]
800 fn xai_device_poll_parsing() {
801 assert_eq!(
802 parse_xai_device_poll(400, r#"{"error":"authorization_pending"}"#),
803 XaiDevicePoll::Pending
804 );
805 assert_eq!(
806 parse_xai_device_poll(429, r#"{"error":"slow_down"}"#),
807 XaiDevicePoll::SlowDown
808 );
809 assert!(matches!(
810 parse_xai_device_poll(400, r#"{"error":"access_denied"}"#),
811 XaiDevicePoll::Failed(e) if e.contains("denied")
812 ));
813 assert!(matches!(
814 parse_xai_device_poll(400, r#"{"error":"expired_token"}"#),
815 XaiDevicePoll::Failed(e) if e.contains("expired")
816 ));
817 let done = parse_xai_device_poll(
818 200,
819 r#"{"access_token":"tok","refresh_token":"ref","expires_in":3600}"#,
820 );
821 match done {
822 XaiDevicePoll::Done(t) => {
823 assert_eq!(t.access_token, "tok");
824 assert_eq!(t.refresh_token.as_deref(), Some("ref"));
825 assert_eq!(t.expires_in, Some(3600));
826 }
827 other => panic!("expected Done, got {other:?}"),
828 }
829 assert!(matches!(
830 parse_xai_device_poll(200, "not json"),
831 XaiDevicePoll::Failed(_)
832 ));
833 assert!(matches!(
834 parse_xai_device_poll(500, "boom"),
835 XaiDevicePoll::Failed(e) if e.contains("500")
836 ));
837 }
838
839 #[test]
840 fn callback_request_parsing() {
841 let req = "GET /auth/callback?code=abc&state=xyz HTTP/1.1\r\nHost: localhost\r\n\r\n";
842 let target = request_target(req).unwrap();
843 assert_eq!(callback_path(target), OPENAI_CALLBACK_PATH);
844 let q = callback_query(target);
845 assert_eq!(q["code"], "abc");
846 assert_eq!(q["state"], "xyz");
847 assert_eq!(callback_path("/favicon.ico"), "/favicon.ico");
848 assert!(callback_query("/favicon.ico").is_empty());
849 assert!(request_target("").is_none());
850 }
851}