aura-gui 0.2.1

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Recorrido OAuth: URL de autorización, canje del código, renovación del acceso y bloqueo contra renovaciones
//! simultáneas (el token de renovación rota: usarlo dos veces a la vez invalidaría la sesión).

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);
/// Se renueva con este margen para no mandar un token que caduque a mitad de petición.
const REFRESH_MARGIN_MS: u64 = 5 * 60 * 1000;

static REFRESH_LOCK: Mutex<()> = Mutex::new(());

/// Datos fijos de cada servicio (los de su cliente oficial: es lo que acepta su servidor de autorización).
struct Spec {
    authorize: &'static str,
    token: &'static str,
    client_id: &'static str,
    scope: &'static str,
    port: u16,
    path: &'static str,
    /// Claude pide el canje en JSON; ChatGPT, como formulario.
    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)
}

/// `chatgpt_account_id` del JWT: el backend de ChatGPT lo exige en cada petición.
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 })
}

/// Dirección que el navegador debe abrir para iniciar sesión.
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))
}

/// Inicia sesión: `open` abre la URL en el navegador; bloquea hasta que el usuario termina (llamar desde un hilo).
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)?;
    // Claude usa el verificador como estado; ChatGPT, un valor aparte.
    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)
}

/// Sesión vigente de `service`, renovándola si está por caducar (bloqueante).
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());
    // Otro hilo pudo renovar mientras esperábamos el bloqueo.
    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", &current.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());
    }
}