use crate::config::{self, AuthProviderRecord, AuthState, McPaths, ProviderCredential};
use crate::http_body::{DEFAULT_BOUNDED_BODY_MAX_BYTES, read_bounded_response_text};
use crate::providers::{ANTHROPIC_PROVIDER, CLAUDE_CODE_PROVIDER, OPENAI_CODEX_PROVIDER};
use anyhow::Context;
use base64::Engine;
use chrono::Utc;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::fmt;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::Receiver,
};
use std::time::{Duration, Instant};
pub(crate) const OPENAI_CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub(crate) const OPENAI_CODEX_RELOGIN_GUIDANCE: &str =
"openai-codex OAuth credentials need re-login; run /login openai-codex";
pub(crate) const CLAUDE_CODE_LOGIN_STATUS: &str = "setup via Claude Code CLI";
const CLAUDE_CODE_READY_STATUS: &str = "ready via Claude Code auth";
const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
const REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
const CALLBACK_ADDR: &str = "127.0.0.1:1455";
const SCOPE: &str = "openid profile email offline_access";
const REFRESH_SKEW_SECS: i64 = 300;
const LOGIN_WAIT_TIMEOUT: Duration = Duration::from_secs(300);
#[cfg(not(test))]
const CALLBACK_STREAM_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
const CALLBACK_STREAM_TIMEOUT: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoginProvider {
pub(crate) id: &'static str,
pub(crate) label: &'static str,
pub(crate) description: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LoginProviderStatus {
Missing,
Configured,
NeedsRelogin,
}
impl LoginProviderStatus {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Configured => "configured/refreshable",
Self::NeedsRelogin => "needs re-login",
}
}
}
pub(crate) const CUSTOM_PROVIDER_LOGIN_ID: &str = "custom-provider";
pub(crate) fn providers() -> Vec<LoginProvider> {
vec![
LoginProvider {
id: OPENAI_CODEX_PROVIDER,
label: "OpenAI Codex",
description: "ChatGPT account OAuth for Codex-backed assistant models",
},
LoginProvider {
id: ANTHROPIC_PROVIDER,
label: "Anthropic",
description: "Anthropic Messages API using ANTHROPIC_API_KEY or provider-keyed API-key auth",
},
LoginProvider {
id: CLAUDE_CODE_PROVIDER,
label: "Claude Code",
description: "Local Claude Code CLI subscription auth; run claude auth login externally",
},
LoginProvider {
id: CUSTOM_PROVIDER_LOGIN_ID,
label: "Custom Provider",
description: "OpenAI-compatible API root plus optional API-key environment variable name",
},
]
}
pub(crate) fn claude_code_login_instructions() -> &'static str {
"Claude Code setup: run `claude auth login` in your shell, then use `claude-code/<model>` in magi-code. magi-code reads Claude Code OAuth credentials from macOS Keychain service `Claude Code-credentials` or `~/.claude/.credentials.json`. You can also configure a provider-keyed API key for `claude-code` in magi-code auth.json; API-key fallback bills Anthropic API credits."
}
pub(crate) fn claude_code_provider_status(paths: &McPaths) -> anyhow::Result<LoginProviderStatus> {
let auth = config::read_auth(paths)
.map_err(|error| anyhow::anyhow!("could not read auth store: {error}"))?;
Ok(match auth.providers.get(CLAUDE_CODE_PROVIDER) {
Some(AuthProviderRecord::ApiKey { key }) if !key.is_empty() => {
LoginProviderStatus::Configured
}
Some(_) => LoginProviderStatus::NeedsRelogin,
None => LoginProviderStatus::Missing,
})
}
pub(crate) fn provider_status(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<LoginProviderStatus> {
if provider_id == CLAUDE_CODE_PROVIDER {
return claude_code_provider_status(paths);
}
if provider_id == ANTHROPIC_PROVIDER {
if std::env::var("ANTHROPIC_API_KEY")
.ok()
.is_some_and(|v| !v.is_empty())
{
return Ok(LoginProviderStatus::Configured);
}
let auth = config::read_auth(paths)
.map_err(|error| anyhow::anyhow!("could not read auth store: {error}"))?;
return Ok(match auth.providers.get(provider_id) {
Some(AuthProviderRecord::ApiKey { key }) if !key.is_empty() => {
LoginProviderStatus::Configured
}
Some(_) => LoginProviderStatus::NeedsRelogin,
None => LoginProviderStatus::Missing,
});
}
let auth = config::read_auth(paths)
.map_err(|error| anyhow::anyhow!("could not read auth store: {error}"))?;
Ok(match auth.providers.get(provider_id) {
Some(AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
}) if oauth_record_is_configured_or_refreshable(access, refresh, *expires, account_id) => {
LoginProviderStatus::Configured
}
Some(_) => LoginProviderStatus::NeedsRelogin,
None => LoginProviderStatus::Missing,
})
}
fn oauth_record_is_configured_or_refreshable(
access: &str,
refresh: &Option<String>,
expires: Option<i64>,
account_id: &Option<String>,
) -> bool {
if account_id.as_ref().is_none_or(|id| id.is_empty()) {
return false;
}
if refresh.as_ref().is_some_and(|value| !value.is_empty()) {
return true;
}
!access.is_empty()
&& expires.is_some_and(|expires| expires > Utc::now().timestamp() + REFRESH_SKEW_SECS)
}
pub(crate) fn provider_list_text(paths: &McPaths) -> String {
let mut out = String::from("login providers:\n");
let mut auth_error = None;
for provider in providers() {
let status = if provider.id == CUSTOM_PROVIDER_LOGIN_ID {
"configure with prompts".to_string()
} else if provider.id == ANTHROPIC_PROVIDER {
match provider_status(paths, provider.id) {
Ok(LoginProviderStatus::Configured) => "ready via ANTHROPIC_API_KEY".to_string(),
Ok(_) => "set ANTHROPIC_API_KEY".to_string(),
Err(error) => {
auth_error.get_or_insert_with(|| error.to_string());
"auth store error".to_string()
}
}
} else if provider.id == CLAUDE_CODE_PROVIDER {
match claude_code_provider_status(paths) {
Ok(LoginProviderStatus::Configured) => CLAUDE_CODE_READY_STATUS.to_string(),
Ok(_) => CLAUDE_CODE_LOGIN_STATUS.to_string(),
Err(error) => {
auth_error.get_or_insert_with(|| error.to_string());
"auth store error".to_string()
}
}
} else {
match provider_status(paths, provider.id) {
Ok(status) => status.label().to_string(),
Err(error) => {
auth_error.get_or_insert_with(|| error.to_string());
"auth store error".to_string()
}
}
};
out.push_str(&format!(
"- {} ({}) [{status}]: {}\n",
provider.label, provider.id, provider.description
));
}
if let Ok(settings) = config::read_settings(paths) {
for (id, provider) in settings.custom_providers {
let (status, auth_description) = match &provider.api_key_env_var {
Some(env_var) if std::env::var(env_var).ok().is_some_and(|v| !v.is_empty()) => {
("ready", format!("using {env_var}"))
}
Some(env_var) => ("configured; missing env", format!("using {env_var}")),
None => ("ready", "API key not required".to_string()),
};
out.push_str(&format!(
"- {} ({}) [{status}]: custom OpenAI-compatible provider; {auth_description}\n",
provider.label, id
));
}
}
if let Some(error) = auth_error {
out.push_str(&format!("auth store error: {error}\n"));
}
out.push_str(
"usage: /login openai-codex, /login claude-code, or /login custom-provider; for anthropic set ANTHROPIC_API_KEY; /login claude-code shows setup for `claude auth login`",
);
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LogoutProviderStatus {
Missing,
Configured,
NeedsRelogin,
}
impl LogoutProviderStatus {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Missing => "not configured",
Self::Configured => "configured",
Self::NeedsRelogin => "needs re-login",
}
}
}
pub(crate) fn logout_provider_status(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<LogoutProviderStatus> {
let auth = config::read_auth(paths)?;
Ok(match auth.providers.get(provider_id) {
Some(AuthProviderRecord::OAuth {
access,
refresh,
account_id,
..
}) if (!access.is_empty() || refresh.as_ref().is_some_and(|value| !value.is_empty()))
&& account_id.as_ref().is_some_and(|id| !id.is_empty()) =>
{
LogoutProviderStatus::Configured
}
Some(_) => LogoutProviderStatus::NeedsRelogin,
None => LogoutProviderStatus::Missing,
})
}
pub(crate) fn logout_provider_list_text(paths: &McPaths) -> anyhow::Result<String> {
let mut out = String::from("logout providers:\n");
for provider in providers() {
let status = logout_provider_status(paths, provider.id)?.label();
out.push_str(&format!(
"- {} ({}) [{status}]: {}\n",
provider.label, provider.id, provider.description
));
}
if let Ok(settings) = config::read_settings(paths) {
for (id, provider) in settings.custom_providers {
out.push_str(&format!(
"- {} ({}) [configured]: custom provider metadata\n",
provider.label, id
));
}
}
out.push_str("usage: /logout openai-codex or /logout <custom-provider-id>");
Ok(out)
}
pub(crate) fn validate_logout_provider(provider_id: &str) -> anyhow::Result<LoginProvider> {
providers()
.into_iter()
.find(|provider| provider.id == provider_id)
.ok_or_else(|| {
anyhow::anyhow!(
"unsupported logout provider '{provider_id}'; supported providers: openai-codex and configured custom provider ids"
)
})
}
#[derive(Clone)]
pub(crate) struct OAuthAttempt {
verifier: String,
state: String,
challenge: String,
}
impl fmt::Debug for OAuthAttempt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuthAttempt")
.field("verifier", &"<redacted>")
.field("state", &"<redacted>")
.field("challenge", &"<redacted>")
.finish()
}
}
impl OAuthAttempt {
pub(crate) fn new() -> Self {
let verifier = random_urlsafe(64);
let state = random_urlsafe(32);
let digest = Sha256::digest(verifier.as_bytes());
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
Self {
verifier,
state,
challenge,
}
}
pub(crate) fn authorization_url(&self) -> String {
let pairs = [
("response_type", "code"),
("client_id", OPENAI_CODEX_CLIENT_ID),
("redirect_uri", REDIRECT_URI),
("scope", SCOPE),
("code_challenge", self.challenge.as_str()),
("code_challenge_method", "S256"),
("state", self.state.as_str()),
("id_token_add_organizations", "true"),
("codex_cli_simplified_flow", "true"),
("originator", "pi"),
];
let query = pairs
.into_iter()
.map(|(k, v)| format!("{}={}", pct(k), pct(v)))
.collect::<Vec<_>>()
.join("&");
format!("{AUTHORIZE_URL}?{query}")
}
}
pub(crate) struct LoginResult {
pub(crate) message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoginInstructions {
pub(crate) url: String,
pub(crate) message: String,
}
pub(crate) fn configure_custom_provider(
paths: &McPaths,
id: &str,
label: &str,
base_url: &str,
api_key_env_var: &str,
) -> anyhow::Result<LoginResult> {
let id = config::validate_custom_provider_id(id)?;
let config = config::make_custom_provider_config(label, base_url, api_key_env_var)?;
let label = config.label.clone();
let auth_message = config
.api_key_env_var
.as_ref()
.map(|env_name| {
format!("API key env var: {env_name}. Set/export it with your API key before use.")
})
.unwrap_or_else(|| {
"API key not required; no Authorization header will be sent.".to_string()
});
config::upsert_custom_provider(paths, &id, config)?;
Ok(LoginResult {
message: format!(
"Custom provider '{label}' ({id}) configured. {auth_message} Next: run /setmodel {id}/<model-name>."
),
})
}
pub(crate) fn login_openai_codex(paths: &McPaths) -> anyhow::Result<LoginResult> {
let cancel = Arc::new(AtomicBool::new(false));
login_openai_codex_with_controls(paths, Arc::clone(&cancel), None, |instructions| {
eprintln!("{}", instructions.message);
})
}
pub(crate) fn login_openai_codex_with_controls(
paths: &McPaths,
cancel: Arc<AtomicBool>,
manual_rx: Option<Receiver<String>>,
mut progress: impl FnMut(LoginInstructions) + Send + 'static,
) -> anyhow::Result<LoginResult> {
let attempt = OAuthAttempt::new();
let url = attempt.authorization_url();
progress(LoginInstructions {
url: url.clone(),
message: format!(
"OpenAI Codex login: open this URL in your browser:\n{url}\nWaiting up to 5 minutes for browser callback on {REDIRECT_URI}. If callback cannot reach this terminal, paste the final redirect URL or authorization code into the active login prompt."
),
});
let code = capture_loopback_or_manual_code(
&attempt.state,
LOGIN_WAIT_TIMEOUT,
&cancel,
manual_rx.as_ref(),
)?;
if cancel.load(Ordering::Relaxed) {
anyhow::bail!("OpenAI Codex login cancelled; credentials unchanged")
}
let token = exchange_code(&attempt, &code)?;
if cancel.load(Ordering::Relaxed) {
anyhow::bail!("OpenAI Codex login cancelled; credentials unchanged")
}
persist_token(paths, token)?;
Ok(LoginResult {
message: "OpenAI Codex configured".to_string(),
})
}
pub(crate) fn codex_credential_from_store(paths: &McPaths) -> anyhow::Result<ProviderCredential> {
codex_credential_from_store_with_exchange(paths, refresh_token)
}
pub(crate) fn codex_credential_from_store_with_exchange(
paths: &McPaths,
exchange: impl FnOnce(&str) -> anyhow::Result<NormalizedToken>,
) -> anyhow::Result<ProviderCredential> {
let auth = config::read_auth(paths)?;
let Some(record) = auth.providers.get(OPENAI_CODEX_PROVIDER).cloned() else {
anyhow::bail!("missing OAuth auth for provider 'openai-codex'; run /login openai-codex")
};
let AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
} = record
else {
anyhow::bail!("provider 'openai-codex' requires OAuth auth; run /login openai-codex")
};
let now = Utc::now().timestamp();
let near_expiry = expires.is_none_or(|expires| expires <= now + REFRESH_SKEW_SECS);
if !near_expiry && !access.is_empty() && account_id.as_ref().is_some_and(|id| !id.is_empty()) {
return Ok(ProviderCredential::OAuth { access, account_id });
}
let refresh = refresh
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!(OPENAI_CODEX_RELOGIN_GUIDANCE))?;
let token = exchange(&refresh)?;
persist_token(paths, token.clone())?;
Ok(ProviderCredential::OAuth {
access: token.access,
account_id: Some(token.account_id),
})
}
pub(crate) fn force_refresh_codex_credential_from_store(
paths: &McPaths,
) -> anyhow::Result<ProviderCredential> {
force_refresh_codex_credential_from_store_with_exchange(paths, refresh_token)
}
fn force_refresh_codex_credential_from_store_with_exchange(
paths: &McPaths,
exchange: impl FnOnce(&str) -> anyhow::Result<NormalizedToken>,
) -> anyhow::Result<ProviderCredential> {
let auth = config::read_auth(paths)?;
let Some(record) = auth.providers.get(OPENAI_CODEX_PROVIDER).cloned() else {
anyhow::bail!(OPENAI_CODEX_RELOGIN_GUIDANCE)
};
let AuthProviderRecord::OAuth { refresh, .. } = record else {
anyhow::bail!(OPENAI_CODEX_RELOGIN_GUIDANCE)
};
let refresh = refresh
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!(OPENAI_CODEX_RELOGIN_GUIDANCE))?;
let token = exchange(&refresh)?;
persist_token(paths, token.clone())?;
Ok(ProviderCredential::OAuth {
access: token.access,
account_id: Some(token.account_id),
})
}
pub(crate) fn refreshed_auth_state(
paths: &McPaths,
current: &AuthState,
) -> anyhow::Result<AuthState> {
if current.provider() != OPENAI_CODEX_PROVIDER {
return Ok(current.clone());
}
let credential = codex_credential_from_store(paths)?;
Ok(AuthState::Ready {
provider: OPENAI_CODEX_PROVIDER.to_string(),
credential,
})
}
fn persist_token(paths: &McPaths, token: NormalizedToken) -> anyhow::Result<()> {
config::update_auth(paths, |auth| {
let previous_refresh = match auth.providers.get(OPENAI_CODEX_PROVIDER) {
Some(AuthProviderRecord::OAuth { refresh, .. }) => refresh.clone(),
_ => None,
};
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: token.access,
refresh: token.refresh.or(previous_refresh),
expires: token.expires,
account_id: Some(token.account_id),
},
);
})?;
Ok(())
}
fn capture_loopback_or_manual_code(
expected_state: &str,
timeout: Duration,
cancel: &AtomicBool,
manual_rx: Option<&Receiver<String>>,
) -> anyhow::Result<String> {
let listener = match TcpListener::bind(CALLBACK_ADDR) {
Ok(listener) => {
listener.set_nonblocking(true)?;
Some(listener)
}
Err(_error) if manual_rx.is_some() => None,
Err(error) => return Err(error).with_context(|| "could not bind OAuth callback on 127.0.0.1:1455; use manual paste fallback if available, or retry from a terminal with local loopback access"),
};
let deadline = Instant::now() + timeout;
loop {
if cancel.load(Ordering::Relaxed) {
anyhow::bail!("OpenAI Codex login cancelled; credentials unchanged")
}
if let Some(rx) = manual_rx {
match rx.try_recv() {
Ok(input) => return parse_manual_fallback_input(&input, expected_state),
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) if listener.is_none() => {
anyhow::bail!(
"manual OAuth fallback input closed and loopback callback is unavailable; retry /login openai-codex from a terminal with local loopback access"
)
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {}
}
}
if let Some(listener) = &listener {
match listener.accept() {
Ok((mut stream, _)) => return handle_callback_stream(&mut stream, expected_state),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Err(error) => return Err(error.into()),
}
}
if Instant::now() >= deadline {
anyhow::bail!(
"OAuth callback timed out; retry /login openai-codex or use manual paste fallback"
)
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn handle_callback_stream(stream: &mut TcpStream, expected_state: &str) -> anyhow::Result<String> {
stream.set_read_timeout(Some(CALLBACK_STREAM_TIMEOUT))?;
stream.set_write_timeout(Some(CALLBACK_STREAM_TIMEOUT))?;
let mut buf = [0_u8; 4096];
let n = stream.read(&mut buf).map_err(|error| {
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) {
anyhow::anyhow!(
"OAuth callback read timed out; retry /login openai-codex or use manual paste fallback"
)
} else {
error.into()
}
})?;
let request = String::from_utf8_lossy(&buf[..n]);
let first = request.lines().next().unwrap_or_default();
let result = parse_callback_request_line(first, expected_state);
let (status, body) = if result.is_ok() {
(
"200 OK",
"OpenAI Codex login complete. You can close this tab.",
)
} else {
(
"400 Bad Request",
"OpenAI Codex login failed. Return to your terminal.",
)
};
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
result
}
fn parse_callback_request_line(line: &str, expected_state: &str) -> anyhow::Result<String> {
let Some(target) = line
.strip_prefix("GET ")
.and_then(|rest| rest.split_whitespace().next())
else {
anyhow::bail!("OAuth callback was malformed")
};
parse_redirect_target(target, expected_state)
}
pub(crate) fn parse_manual_fallback_input(
input: &str,
expected_state: &str,
) -> anyhow::Result<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
anyhow::bail!("manual OAuth fallback was empty")
}
if trimmed.contains("?") || trimmed.starts_with("http://") || trimmed.starts_with("https://") {
let target = trimmed
.split_once("://")
.and_then(|(_, rest)| rest.find('/').map(|idx| &rest[idx..]))
.unwrap_or(trimmed);
return parse_redirect_target(target, expected_state).map_err(|error| {
anyhow::anyhow!(
"manual OAuth fallback rejected: {}",
redact_oauth_text(&error.to_string())
)
});
}
if trimmed.contains(char::is_whitespace) || trimmed.contains('&') || trimmed.contains('=') {
anyhow::bail!(
"manual OAuth fallback was malformed; paste the full redirect URL or authorization code"
)
}
Ok(trimmed.to_string())
}
fn parse_redirect_target(target: &str, expected_state: &str) -> anyhow::Result<String> {
let (path, query) = target.split_once('?').unwrap_or((target, ""));
if !path.ends_with("/auth/callback") {
anyhow::bail!("OAuth callback used an unexpected path")
}
let params = parse_query(query)?;
if let Some(error) = params.iter().find(|(k, _)| k == "error").map(|(_, v)| v) {
anyhow::bail!(
"OAuth provider rejected login: {}",
redact_oauth_text(error)
)
}
let state = params
.iter()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.as_str())
.unwrap_or_default();
if state != expected_state {
anyhow::bail!("OAuth callback state did not match; login was not completed")
}
params
.iter()
.find(|(k, _)| k == "code")
.map(|(_, v)| v.clone())
.filter(|v| !v.is_empty())
.ok_or_else(|| anyhow::anyhow!("OAuth callback did not include an authorization code"))
}
#[derive(Debug, Clone)]
pub(crate) struct NormalizedToken {
pub(crate) access: String,
pub(crate) refresh: Option<String>,
pub(crate) expires: Option<i64>,
pub(crate) account_id: String,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
expires_in: Option<i64>,
#[serde(rename = "accountId")]
account_id_camel: Option<String>,
account_id: Option<String>,
id_token: Option<String>,
}
fn exchange_code(attempt: &OAuthAttempt, code: &str) -> anyhow::Result<NormalizedToken> {
let body = [
("grant_type", "authorization_code"),
("client_id", OPENAI_CODEX_CLIENT_ID),
("redirect_uri", REDIRECT_URI),
("code", code),
("code_verifier", attempt.verifier.as_str()),
];
post_token_form(&body)
}
pub(crate) fn refresh_token(refresh: &str) -> anyhow::Result<NormalizedToken> {
let body = [
("grant_type", "refresh_token"),
("client_id", OPENAI_CODEX_CLIENT_ID),
("refresh_token", refresh),
];
post_token_form(&body)
}
fn post_token_form(body: &[(&str, &str)]) -> anyhow::Result<NormalizedToken> {
let response = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()?
.post(TOKEN_URL)
.form(body)
.send()?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("OpenAI Codex OAuth token exchange failed with status {status}")
}
let text = read_bounded_response_text(response, DEFAULT_BOUNDED_BODY_MAX_BYTES)
.with_context(|| "OpenAI Codex OAuth token response body read failed")?;
let response = serde_json::from_str::<TokenResponse>(&text)?;
normalize_token_response(response)
}
fn normalize_token_response(response: TokenResponse) -> anyhow::Result<NormalizedToken> {
let access = response
.access_token
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("OAuth token response missing access token"))?;
let account_id = response
.account_id_camel
.or(response.account_id)
.or_else(|| config::extract_oauth_account_id_from_jwt(&access))
.or_else(|| {
response
.id_token
.as_deref()
.and_then(config::extract_oauth_account_id_from_jwt)
})
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("OAuth token response missing ChatGPT account id"))?;
let expires = response
.expires_in
.map(|seconds| {
Utc::now()
.timestamp()
.checked_add(seconds)
.ok_or_else(|| anyhow::anyhow!("OAuth token response expires_in is too large"))
})
.transpose()?;
Ok(NormalizedToken {
access,
refresh: response.refresh_token.filter(|value| !value.is_empty()),
expires,
account_id,
})
}
pub(crate) fn redact_oauth_text(text: &str) -> String {
crate::output::redact_sensitive_text(text)
.split("code=")
.next()
.map(|prefix| {
if prefix.len() == text.len() {
prefix.to_string()
} else {
format!("{prefix}code=<redacted>")
}
})
.unwrap_or_else(|| "<redacted>".to_string())
}
fn random_urlsafe(bytes: usize) -> String {
let mut out = Vec::new();
while out.len() < bytes {
out.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
}
out.truncate(bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(out)
}
fn pct(input: &str) -> String {
let mut out = String::new();
for b in input.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(char::from(b))
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn parse_query(query: &str) -> anyhow::Result<Vec<(String, String)>> {
query
.split('&')
.filter(|part| !part.is_empty())
.map(|part| {
let (k, v) = part.split_once('=').unwrap_or((part, ""));
Ok((decode_pct(k)?, decode_pct(v)?))
})
.collect()
}
fn decode_pct(input: &str) -> anyhow::Result<String> {
let mut out = Vec::new();
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
anyhow::bail!("OAuth callback query contains malformed percent escape");
}
let high = hex_digit(bytes[i + 1]).ok_or_else(|| {
anyhow::anyhow!("OAuth callback query contains malformed percent escape")
})?;
let low = hex_digit(bytes[i + 2]).ok_or_else(|| {
anyhow::anyhow!("OAuth callback query contains malformed percent escape")
})?;
out.push((high << 4) | low);
i += 3;
continue;
}
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
i += 1;
}
String::from_utf8(out)
.map_err(|_| anyhow::anyhow!("OAuth callback query contains invalid UTF-8"))
}
fn hex_digit(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn custom_provider_reconfiguration_preserves_advanced_fields() {
let temp = TempDir::new().unwrap();
let mut paths = McPaths::from_root(temp.path().join("mc"));
paths.project_settings_file = temp.path().join("repo/.magi-code/settings.json");
configure_custom_provider(&paths, "local", "Initial", "http://one.test/v1", "KEY").unwrap();
let mut settings = config::read_settings(&paths).unwrap();
let provider = settings.custom_providers.get_mut("local").unwrap();
provider.models_dev_provider = Some("models-dev".to_string());
provider.use_responses_endpoint = true;
provider.reasoning_protocol = config::CustomReasoningProtocol::AnthropicLike;
provider.extra_models = vec!["extra-model".to_string()];
config::write_settings(&paths, &settings).unwrap();
configure_custom_provider(&paths, "local", "Updated", "http://two.test/v1", "NEW_KEY")
.unwrap();
let reloaded = config::read_settings(&paths).unwrap();
let provider = reloaded.custom_providers.get("local").unwrap();
assert_eq!(provider.label, "Updated");
assert_eq!(provider.base_url, "http://two.test/v1");
assert_eq!(provider.api_key_env_var.as_deref(), Some("NEW_KEY"));
assert_eq!(provider.models_dev_provider.as_deref(), Some("models-dev"));
assert!(provider.use_responses_endpoint);
assert_eq!(
provider.reasoning_protocol,
config::CustomReasoningProtocol::AnthropicLike
);
assert_eq!(provider.extra_models, ["extra-model"]);
let raw = std::fs::read_to_string(&paths.settings_file).unwrap();
assert!(raw.contains("anthropic-like"));
}
#[test]
fn custom_provider_reconfiguration_does_not_copy_project_overrides() {
let temp = TempDir::new().unwrap();
let mut paths = McPaths::from_root(temp.path().join("mc"));
paths.project_settings_file = temp.path().join("repo/.magi-code/settings.json");
configure_custom_provider(&paths, "local", "Initial", "http://one.test/v1", "KEY").unwrap();
std::fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
std::fs::write(
&paths.project_settings_file,
r#"{"custom_providers":{"local":{"label":"Project","base_url":"http://project.test/v1","reasoning_protocol":"anthropic-like","use_responses_endpoint":true,"extra_models":["project-model"]}}}"#,
)
.unwrap();
configure_custom_provider(&paths, "local", "Updated", "http://two.test/v1", "KEY").unwrap();
let provider = serde_json::from_str::<config::Settings>(
&std::fs::read_to_string(&paths.settings_file).unwrap(),
)
.unwrap()
.custom_providers
.remove("local")
.unwrap();
assert_eq!(
provider.reasoning_protocol,
config::CustomReasoningProtocol::GptLike
);
assert!(!provider.use_responses_endpoint);
assert!(provider.extra_models.is_empty());
}
#[test]
fn provider_list_includes_custom_provider() {
let list = providers();
assert_eq!(list.len(), 4);
assert_eq!(list[0].id, OPENAI_CODEX_PROVIDER);
assert_eq!(list[0].label, "OpenAI Codex");
assert_eq!(list[1].id, ANTHROPIC_PROVIDER);
assert_eq!(list[1].label, "Anthropic");
assert_eq!(list[2].id, CLAUDE_CODE_PROVIDER);
assert_eq!(list[2].label, "Claude Code");
assert_eq!(list[3].id, CUSTOM_PROVIDER_LOGIN_ID);
assert_eq!(list[3].label, "Custom Provider");
}
#[test]
fn claude_code_login_instructions_names_external_setup_and_credential_sources() {
let instructions = claude_code_login_instructions();
for marker in [
"claude auth login",
"Claude Code-credentials",
"~/.claude/.credentials.json",
"provider-keyed API key",
"Anthropic API credits",
] {
assert!(instructions.contains(marker), "{instructions}");
}
}
#[test]
fn provider_list_mentions_login_claude_code() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let list = provider_list_text(&paths);
assert!(list.contains("/login claude-code"), "{list}");
assert!(list.contains("claude auth login"), "{list}");
}
#[test]
fn claude_code_provider_status_uses_provider_keyed_api_key() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(
claude_code_provider_status(&paths).unwrap(),
LoginProviderStatus::Missing
);
let mut auth = config::Auth::default();
auth.providers.insert(
CLAUDE_CODE_PROVIDER.to_string(),
AuthProviderRecord::ApiKey {
key: "sk-ant-api-test".into(),
},
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
claude_code_provider_status(&paths).unwrap(),
LoginProviderStatus::Configured
);
let list = provider_list_text(&paths);
assert!(list.contains("Claude Code (claude-code) [ready via Claude Code auth]"));
}
#[test]
fn login_wait_timeout_is_five_minutes() {
assert_eq!(LOGIN_WAIT_TIMEOUT.as_secs(), 300);
}
#[test]
fn callback_stream_read_timeout_is_bounded() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let client = TcpStream::connect(addr).unwrap();
let (mut server, _) = listener.accept().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
let result = handle_callback_stream(&mut server, "expected");
tx.send(result.map(|code| code.is_empty())).unwrap();
});
let timed_out = match rx.recv_timeout(Duration::from_millis(250)) {
Ok(result) => {
let error = result.unwrap_err().to_string();
assert!(!error.contains("expected"), "{error}");
false
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => true,
Err(error) => panic!("callback timeout test channel failed: {error}"),
};
drop(client);
handle.join().unwrap();
assert!(
!timed_out,
"silent OAuth callback client blocked beyond bounded read timeout"
);
}
#[test]
fn manual_fallback_disconnect_returns_promptly_when_loopback_unavailable() {
let _held_callback_port = TcpListener::bind(CALLBACK_ADDR).unwrap();
let (tx, rx) = std::sync::mpsc::channel::<String>();
drop(tx);
let cancel = AtomicBool::new(false);
let started = Instant::now();
let error = capture_loopback_or_manual_code(
"expected-state",
Duration::from_secs(30),
&cancel,
Some(&rx),
)
.unwrap_err()
.to_string();
assert!(started.elapsed() < Duration::from_millis(200), "{error}");
assert!(
error.contains("manual OAuth fallback input closed"),
"{error}"
);
assert!(!error.contains("expected-state"), "{error}");
}
#[test]
fn authorization_url_contains_codex_oauth_parameters() {
let attempt = OAuthAttempt {
verifier: "verifier".into(),
state: "state value".into(),
challenge: "challenge".into(),
};
let url = attempt.authorization_url();
assert!(url.starts_with(AUTHORIZE_URL));
assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
assert!(url.contains("scope=openid%20profile%20email%20offline_access"));
assert!(url.contains("code_challenge=challenge"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("state=state%20value"));
assert!(url.contains("id_token_add_organizations=true"));
assert!(url.contains("codex_cli_simplified_flow=true"));
assert!(url.contains("originator=pi"));
}
#[test]
fn callback_parser_validates_state_and_redacts_failures() {
let code_key = "code";
assert_eq!(
parse_callback_request_line(
&format!("GET /auth/callback?{code_key}=placeholder&state=s HTTP/1.1"),
"s",
)
.unwrap(),
"placeholder"
);
assert_eq!(
parse_callback_request_line(
"GET /auth/callback?code=hello+world%21&state=s HTTP/1.1",
"s"
)
.unwrap(),
"hello world!"
);
let err = parse_callback_request_line(
&format!("GET /auth/callback?{code_key}=placeholder&state=wrong HTTP/1.1"),
"s",
)
.unwrap_err()
.to_string();
assert!(err.contains("state did not match"));
assert!(!err.contains("placeholder"));
}
#[test]
fn callback_parser_rejects_malformed_percent_encoding() {
for target in [
"GET /auth/callback?code=abc%&state=s HTTP/1.1",
"GET /auth/callback?code=abc%G0&state=s HTTP/1.1",
"GET /auth/callback?code=abc%é&state=s HTTP/1.1",
] {
let error = parse_callback_request_line(target, "s")
.unwrap_err()
.to_string();
assert!(error.contains("malformed percent"), "{error}");
assert!(!error.contains("abc"), "{error}");
}
}
#[test]
fn callback_parser_rejects_invalid_utf8_query_values() {
let error =
parse_callback_request_line("GET /auth/callback?code=%FF&state=s HTTP/1.1", "s")
.unwrap_err()
.to_string();
assert!(error.contains("invalid UTF-8"), "{error}");
assert!(!error.contains("%FF"), "{error}");
}
#[test]
fn manual_fallback_parses_redirect_url_and_validates_state() {
assert_eq!(
parse_manual_fallback_input(
"http://localhost:1455/auth/callback?code=manual-code&state=expected",
"expected",
)
.unwrap(),
"manual-code"
);
assert_eq!(
parse_manual_fallback_input(
"http://127.0.0.1:1455/auth/callback?code=manual-code&state=expected",
"expected",
)
.unwrap(),
"manual-code"
);
let err = parse_manual_fallback_input(
"http://127.0.0.1:1455/auth/callback?code=manual-code&state=wrong",
"expected",
)
.unwrap_err()
.to_string();
assert!(err.contains("state did not match"));
assert!(!err.contains("manual-code"));
assert_eq!(
parse_manual_fallback_input("raw-code", "expected").unwrap(),
"raw-code"
);
}
fn fake_jwt(payload_json: &str) -> String {
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json);
format!("{header}.{payload}.")
}
#[test]
fn token_response_extracts_nested_pi_chatgpt_account_id_from_access_token() {
let token = normalize_token_response(TokenResponse {
access_token: Some(fake_jwt(
r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"acct_pi"}}"#,
)),
refresh_token: None,
expires_in: None,
account_id_camel: None,
account_id: None,
id_token: None,
})
.unwrap();
assert_eq!(token.account_id, "acct_pi");
}
#[test]
fn token_response_rejects_expiry_overflow() {
let error = normalize_token_response(TokenResponse {
access_token: Some(fake_jwt(
r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"acct_pi"}}"#,
)),
refresh_token: None,
expires_in: Some(i64::MAX),
account_id_camel: None,
account_id: None,
id_token: None,
})
.unwrap_err()
.to_string();
assert!(
error.contains("OAuth token response expires_in is too large"),
"{error}"
);
}
#[test]
fn auth_persist_preserves_unrelated_records() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut auth = config::Auth::default();
auth.providers.insert(
"openai".to_string(),
AuthProviderRecord::ApiKey {
key: "secret".into(),
},
);
config::write_auth(&paths, &auth).unwrap();
persist_token(
&paths,
NormalizedToken {
access: "access".into(),
refresh: Some("refresh".into()),
expires: Some(9),
account_id: "acct".into(),
},
)
.unwrap();
let auth = config::read_auth(&paths).unwrap();
assert!(matches!(
auth.providers.get("openai"),
Some(AuthProviderRecord::ApiKey { .. })
));
assert!(matches!(
auth.providers.get(OPENAI_CODEX_PROVIDER),
Some(AuthProviderRecord::OAuth { .. })
));
}
#[test]
fn persist_token_preserves_previous_refresh_when_response_omits_replacement() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut auth = config::Auth::default();
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "old-access".into(),
refresh: Some("old-refresh".into()),
expires: Some(1),
account_id: Some("acct".into()),
},
);
config::write_auth(&paths, &auth).unwrap();
persist_token(
&paths,
NormalizedToken {
access: "new-access".into(),
refresh: None,
expires: Some(2),
account_id: "acct".into(),
},
)
.unwrap();
let auth = config::read_auth(&paths).unwrap();
let Some(AuthProviderRecord::OAuth {
access, refresh, ..
}) = auth.providers.get(OPENAI_CODEX_PROVIDER)
else {
panic!("missing codex oauth record")
};
assert_eq!(access, "new-access");
assert_eq!(refresh.as_deref(), Some("old-refresh"));
}
#[test]
fn force_refresh_codex_credential_bypasses_future_expiry_and_persists_returned_identity() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut auth = config::Auth::default();
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "old-access".into(),
refresh: Some("old-refresh".into()),
expires: Some(Utc::now().timestamp() + 3600),
account_id: Some("old-acct".into()),
},
);
config::write_auth(&paths, &auth).unwrap();
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_for_exchange = std::sync::Arc::clone(&calls);
let credential =
force_refresh_codex_credential_from_store_with_exchange(&paths, |refresh| {
calls_for_exchange.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
assert_eq!(refresh, "old-refresh");
Ok(NormalizedToken {
access: "new-access".into(),
refresh: None,
expires: Some(123),
account_id: "new-acct".into(),
})
})
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(
credential,
ProviderCredential::OAuth {
access: "new-access".into(),
account_id: Some("new-acct".into())
}
);
let auth = config::read_auth(&paths).unwrap();
let Some(AuthProviderRecord::OAuth {
access,
refresh,
expires,
account_id,
}) = auth.providers.get(OPENAI_CODEX_PROVIDER)
else {
panic!("missing codex oauth record")
};
assert_eq!(access, "new-access");
assert_eq!(refresh.as_deref(), Some("old-refresh"));
assert_eq!(*expires, Some(123));
assert_eq!(account_id.as_deref(), Some("new-acct"));
}
#[test]
fn provider_status_expired_without_refresh_needs_relogin() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut auth = config::Auth::default();
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "expired-access".into(),
refresh: None,
expires: Some(Utc::now().timestamp() - 60),
account_id: Some("acct".into()),
},
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
provider_status(&paths, OPENAI_CODEX_PROVIDER).unwrap(),
LoginProviderStatus::NeedsRelogin
);
}
#[test]
fn provider_status_reports_auth_store_errors_distinctly() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
std::fs::create_dir_all(&paths.root).unwrap();
std::fs::write(&paths.auth_file, "not json").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&paths.auth_file).unwrap().permissions();
permissions.set_mode(0o600);
std::fs::set_permissions(&paths.auth_file, permissions).unwrap();
}
let error = provider_status(&paths, OPENAI_CODEX_PROVIDER)
.unwrap_err()
.to_string();
assert!(error.contains("could not read auth store"), "{error}");
assert!(
error.contains("expected") || error.contains("key"),
"{error}"
);
let list = provider_list_text(&paths);
assert!(list.contains("[auth store error]"), "{list}");
assert!(!list.contains("[needs re-login]"), "{list}");
}
#[test]
fn provider_status_missing_and_needs_relogin_remain_distinct() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(
provider_status(&paths, OPENAI_CODEX_PROVIDER).unwrap(),
LoginProviderStatus::Missing
);
let mut auth = config::Auth::default();
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "access-without-account".into(),
refresh: None,
expires: Some(Utc::now().timestamp() + 3600),
account_id: None,
},
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
provider_status(&paths, OPENAI_CODEX_PROVIDER).unwrap(),
LoginProviderStatus::NeedsRelogin
);
}
#[test]
fn logout_provider_list_matches_login_provider_ids() {
let login_ids: Vec<_> = providers()
.into_iter()
.map(|provider| provider.id)
.collect();
let logout_ids: Vec<_> = providers()
.into_iter()
.map(|provider| provider.id)
.collect();
assert_eq!(logout_ids, login_ids);
assert_eq!(
logout_ids,
vec![
OPENAI_CODEX_PROVIDER,
ANTHROPIC_PROVIDER,
CLAUDE_CODE_PROVIDER,
CUSTOM_PROVIDER_LOGIN_ID
]
);
}
#[test]
fn logout_provider_status_labels_are_secret_free() {
let labels = [
LogoutProviderStatus::Missing.label(),
LogoutProviderStatus::Configured.label(),
LogoutProviderStatus::NeedsRelogin.label(),
];
for label in labels {
assert!(!label.contains("access"));
assert!(!label.contains("refresh"));
assert!(!label.contains("acct"));
}
}
#[test]
fn logout_provider_status_propagates_auth_read_errors() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
std::fs::create_dir_all(&paths.root).unwrap();
std::fs::write(&paths.auth_file, "not json").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&paths.auth_file).unwrap().permissions();
permissions.set_mode(0o600);
std::fs::set_permissions(&paths.auth_file, permissions).unwrap();
}
let error = logout_provider_status(&paths, OPENAI_CODEX_PROVIDER)
.unwrap_err()
.to_string();
assert!(
error.contains("expected") || error.contains("key"),
"{error}"
);
}
#[test]
fn debug_redacts_attempt_secrets() {
let text = format!(
"{:?}",
OAuthAttempt {
verifier: "verifier-secret".into(),
state: "state-secret".into(),
challenge: "challenge-secret".into()
}
);
assert!(!text.contains("verifier-secret"));
assert!(!text.contains("state-secret"));
assert!(!text.contains("challenge-secret"));
}
}