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;
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}