Skip to main content

alex_auth/
login.rs

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    // Amp login is an idempotent key import/upsert, not a fresh OAuth account.
332    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
369/// Amp auth: prefer importing CLI secrets, else AMP_API_KEY env, else paste prompt.
370async 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    // Prefer xAI's HTTPS OIDC userinfo response over locally decoded JWT
1032    // claims. The latter are not signature-verified here.
1033    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}