use super::callback::{CallbackServer, Redirect, percent_decode};
use super::pkce::{Pkce, base64_decode, base64url, random_bytes};
use super::{Credential, Service, save, stored};
use crate::ai::types::AiError;
use serde_json::{Value, json};
use std::io::Read;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
const REFRESH_MARGIN_MS: u64 = 5 * 60 * 1000;
static REFRESH_LOCK: Mutex<()> = Mutex::new(());
struct Spec {
authorize: &'static str,
token: &'static str,
client_id: &'static str,
scope: &'static str,
port: u16,
path: &'static str,
json_body: bool,
}
fn spec(service: Service) -> Spec {
match service {
Service::Claude => Spec {
authorize: "https://claude.ai/oauth/authorize",
token: "https://platform.claude.com/v1/oauth/token",
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
scope: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
port: 53692,
path: "/callback",
json_body: true,
},
Service::ChatGpt => Spec {
authorize: "https://auth.openai.com/oauth/authorize",
token: "https://auth.openai.com/oauth/token",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
scope: "openid profile email offline_access",
port: 1455,
path: "/auth/callback",
json_body: false,
},
}
}
pub fn encode_component(text: &str) -> String {
let mut out = String::new();
for byte in text.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char),
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn form(pairs: &[(&str, &str)]) -> String {
pairs.iter().map(|(key, value)| format!("{key}={}", encode_component(value))).collect::<Vec<_>>().join("&")
}
fn now_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_millis() as u64)
}
fn account_id(access: &str) -> Option<String> {
let payload = base64_decode(access.split('.').nth(1)?)?;
let claims: Value = serde_json::from_slice(&payload).ok()?;
claims["https://api.openai.com/auth"]["chatgpt_account_id"].as_str().map(str::to_owned)
}
fn post_token(service: Service, params: &[(&str, &str)]) -> Result<Credential, AiError> {
let spec = spec(service);
let (content_type, body) = if spec.json_body {
let object: serde_json::Map<String, Value> = params.iter().map(|(k, v)| ((*k).to_owned(), json!(v))).collect();
("application/json", Value::Object(object).to_string())
} else {
("application/x-www-form-urlencoded", form(params))
};
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.http_status_as_error(false)
.build()
.into();
let response = agent
.post(spec.token)
.header("content-type", content_type)
.header("accept", "application/json")
.send(body.as_bytes())
.map_err(|error| AiError::Transport(error.to_string()))?;
let code = response.status().as_u16();
let mut text = String::new();
response
.into_body()
.into_reader()
.take(100_000)
.read_to_string(&mut text)
.map_err(|error| AiError::Transport(error.to_string()))?;
if code >= 400 {
return Err(AiError::Status { code, body: text.chars().take(300).collect() });
}
parse_token(service, &text)
}
fn parse_token(service: Service, text: &str) -> Result<Credential, AiError> {
let value: Value = serde_json::from_str(text).map_err(|error| AiError::Decode(error.to_string()))?;
let field = |name: &str| {
value[name].as_str().map(str::to_owned).ok_or_else(|| AiError::Decode(format!("falta «{name}» en el token")))
};
let access = field("access_token")?;
let expires_in = value["expires_in"].as_u64().unwrap_or(3600);
let account_id = (service == Service::ChatGpt).then(|| account_id(&access)).flatten();
if service == Service::ChatGpt && account_id.is_none() {
return Err(AiError::Decode("el token no trae la cuenta de ChatGPT".into()));
}
Ok(Credential { access, refresh: field("refresh_token")?, expires_ms: now_ms() + expires_in * 1000, account_id })
}
fn authorize_url(service: Service, redirect_uri: &str, challenge: &str, state: &str) -> String {
let spec = spec(service);
let mut pairs = vec![
("response_type", "code"),
("client_id", spec.client_id),
("redirect_uri", redirect_uri),
("scope", spec.scope),
("code_challenge", challenge),
("code_challenge_method", "S256"),
("state", state),
];
match service {
Service::Claude => pairs.push(("code", "true")),
Service::ChatGpt => pairs.extend([
("id_token_add_organizations", "true"),
("codex_cli_simplified_flow", "true"),
("originator", "aura"),
]),
}
format!("{}?{}", spec.authorize, form(&pairs))
}
pub fn login(service: Service, open: &dyn Fn(&str), cancelled: &dyn Fn() -> bool) -> Result<(), AiError> {
let transport = |message: String| AiError::Transport(message);
let spec = spec(service);
let pkce = Pkce::generate().map_err(transport)?;
let state = match service {
Service::Claude => pkce.verifier.clone(),
Service::ChatGpt => base64url(&random_bytes::<16>().map_err(transport)?),
};
let server = CallbackServer::bind(spec.port).map_err(transport)?;
if service == Service::ChatGpt && server.port != spec.port {
return Err(transport(format!("el puerto {} está ocupado; ciérralo y reintenta", spec.port)));
}
let redirect_uri = format!("http://localhost:{}{}", server.port, spec.path);
open(&authorize_url(service, &redirect_uri, &pkce.challenge, &state));
let Redirect { code, state: received } = server.wait(spec.path, LOGIN_TIMEOUT, cancelled).map_err(transport)?;
if !received.is_empty() && received != state {
return Err(transport("la respuesta del navegador no coincide con la solicitud (state)".into()));
}
let credential = post_token(
service,
&[
("grant_type", "authorization_code"),
("client_id", spec.client_id),
("code", &percent_decode(&code)),
("state", &state),
("redirect_uri", &redirect_uri),
("code_verifier", &pkce.verifier),
],
)?;
save(service, &credential).map_err(AiError::Transport)
}
pub fn fresh(service: Service) -> Result<Credential, AiError> {
let missing = || AiError::Transport(format!("inicia sesión en {} desde el selector de modelos", service.account()));
let current = stored(service).ok_or_else(missing)?;
if current.expires_ms > now_ms() + REFRESH_MARGIN_MS {
return Ok(current);
}
let _guard = REFRESH_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let current = stored(service).ok_or_else(missing)?;
if current.expires_ms > now_ms() + REFRESH_MARGIN_MS {
return Ok(current);
}
let client_id = spec(service).client_id;
let mut renewed = post_token(
service,
&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", ¤t.refresh)],
)?;
renewed.account_id = renewed.account_id.or(current.account_id);
save(service, &renewed).map_err(AiError::Transport)?;
Ok(renewed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn la_url_de_claude_lleva_pkce_y_el_alcance() {
let url = authorize_url(Service::Claude, "http://localhost:1/callback", "CH", "ST");
assert!(url.starts_with("https://claude.ai/oauth/authorize?response_type=code&client_id="));
assert!(url.contains("code_challenge=CH&code_challenge_method=S256&state=ST&code=true"));
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1%2Fcallback"));
assert!(url.contains("user%3Ainference"));
}
#[test]
fn la_url_de_chatgpt_lleva_los_parametros_del_flujo_simplificado() {
let url = authorize_url(Service::ChatGpt, "http://localhost:1455/auth/callback", "CH", "ST");
assert!(url.starts_with("https://auth.openai.com/oauth/authorize?"));
assert!(url.contains("codex_cli_simplified_flow=true") && url.contains("id_token_add_organizations=true"));
assert!(url.contains("scope=openid%20profile%20email%20offline_access"));
}
#[test]
fn el_token_de_chatgpt_exige_la_cuenta() {
let claims = base64url(br#"{"https://api.openai.com/auth":{"chatgpt_account_id":"acc-1"}}"#);
let body = format!(r#"{{"access_token":"h.{claims}.s","refresh_token":"r","expires_in":60}}"#);
let credential = parse_token(Service::ChatGpt, &body).unwrap();
assert_eq!(credential.account_id.as_deref(), Some("acc-1"));
assert!(parse_token(Service::ChatGpt, r#"{"access_token":"x","refresh_token":"r"}"#).is_err());
let claude =
parse_token(Service::Claude, r#"{"access_token":"x","refresh_token":"r","expires_in":10}"#).unwrap();
assert!(claude.account_id.is_none() && claude.expires_ms > now_ms());
}
}