use crate::auth;
use crate::auth::{
AuthProviderRecord, CredentialReadiness, OPENAI_CODEX_CLIENT_ID,
OPENAI_CODEX_REDIRECT_URI as REDIRECT_URI,
};
use crate::config::{self, McPaths};
use crate::providers::{ANTHROPIC_PROVIDER, CLAUDE_SUBSCRIPTION_PROVIDER, OPENAI_CODEX_PROVIDER};
use anyhow::Context;
use base64::Engine;
use chrono::Utc;
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};
const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
const CALLBACK_ADDR: &str = "127.0.0.1:1455";
const SCOPE: &str = "openid profile email offline_access";
const LOGIN_WAIT_TIMEOUT: Duration = Duration::from_secs(300);
const CALLBACK_STREAM_TIMEOUT: Duration = Duration::from_secs(5);
#[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) const OPENAI_PRESET_LOGIN_ID: &str = "openai";
const CUSTOM_PROVIDER_SETUP_STATUS: &str = "configure with prompts";
const ANTHROPIC_READY_STATUS: &str = "ready via Anthropic API key";
const ANTHROPIC_NEEDS_API_KEY_STATUS: &str = "needs API key";
pub(crate) fn anthropic_api_key_setup_guidance() -> &'static str {
"export ANTHROPIC_API_KEY before launching magi-code, then restart magi-code; or configure provider-keyed anthropic API-key auth"
}
pub(crate) fn unsupported_login_provider_message(provider_id: &str) -> String {
format!(
"unsupported login provider '{provider_id}'; supported providers: openai-codex, anthropic, claude-subscription, openai, custom-provider; for anthropic {}",
anthropic_api_key_setup_guidance()
)
}
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_SUBSCRIPTION_PROVIDER,
label: "Claude Subscription",
description: "Claude Code CLI-managed claude.ai subscription; run `claude auth login`",
},
LoginProvider {
id: OPENAI_PRESET_LOGIN_ID,
label: "OpenAI",
description: "OpenAI API using OPENAI_API_KEY (custom provider preset)",
},
LoginProvider {
id: CUSTOM_PROVIDER_LOGIN_ID,
label: "Custom Provider",
description: "OpenAI-compatible API root plus optional API-key environment variable name",
},
]
}
pub(crate) fn logout_providers() -> impl Iterator<Item = LoginProvider> {
providers().into_iter().filter(|provider| {
!matches!(
provider.id,
CUSTOM_PROVIDER_LOGIN_ID | OPENAI_PRESET_LOGIN_ID | CLAUDE_SUBSCRIPTION_PROVIDER
)
})
}
pub(crate) fn provider_status(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<LoginProviderStatus> {
if provider_id == CLAUDE_SUBSCRIPTION_PROVIDER {
return Ok(
if crate::providers::claude_subscription::cli_subscription_ready() {
LoginProviderStatus::Configured
} else {
LoginProviderStatus::Missing
},
);
}
if provider_id == ANTHROPIC_PROVIDER
&& std::env::var("ANTHROPIC_API_KEY")
.ok()
.is_some_and(|value| !value.is_empty())
{
return Ok(LoginProviderStatus::Configured);
}
let auth = auth::read_auth(paths)
.map_err(|error| anyhow::anyhow!("could not read auth store: {error}"))?;
Ok(
match auth::classify_provider_auth_record(
provider_id,
auth.providers.get(provider_id),
Utc::now().timestamp(),
) {
CredentialReadiness::Missing => LoginProviderStatus::Missing,
CredentialReadiness::Ready | CredentialReadiness::Refreshable => {
LoginProviderStatus::Configured
}
CredentialReadiness::Invalid => LoginProviderStatus::NeedsRelogin,
},
)
}
pub(crate) fn login_provider_status_label(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<&'static str> {
if provider_id == CUSTOM_PROVIDER_LOGIN_ID {
return Ok(CUSTOM_PROVIDER_SETUP_STATUS);
}
if provider_id == OPENAI_PRESET_LOGIN_ID {
return Ok(
if config::read_settings(paths)?
.custom_providers
.contains_key(provider_id)
{
"configured (custom provider)"
} else {
CUSTOM_PROVIDER_SETUP_STATUS
},
);
}
let status = provider_status(paths, provider_id)?;
if provider_id == ANTHROPIC_PROVIDER {
return Ok(match status {
LoginProviderStatus::Configured => ANTHROPIC_READY_STATUS,
LoginProviderStatus::Missing | LoginProviderStatus::NeedsRelogin => {
ANTHROPIC_NEEDS_API_KEY_STATUS
}
});
}
Ok(status.label())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LogoutProviderStatus {
Missing,
Configured,
NeedsRelogin,
NeedsApiKey,
}
impl LogoutProviderStatus {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Missing => "not configured",
Self::Configured => "configured",
Self::NeedsRelogin => "needs re-login",
Self::NeedsApiKey => "needs API key",
}
}
}
pub(crate) fn logout_provider_status(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<LogoutProviderStatus> {
let auth = auth::read_auth(paths)?;
if provider_id == ANTHROPIC_PROVIDER {
return Ok(match auth.providers.get(provider_id) {
Some(AuthProviderRecord::ApiKey { key }) if !key.is_empty() => {
LogoutProviderStatus::Configured
}
Some(_) => LogoutProviderStatus::NeedsApiKey,
None => LogoutProviderStatus::Missing,
});
}
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 logout_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, /logout anthropic, or /logout <custom-provider-id>");
Ok(out)
}
pub(crate) fn validate_logout_provider(provider_id: &str) -> anyhow::Result<LoginProvider> {
logout_providers()
.find(|provider| provider.id == provider_id)
.ok_or_else(|| {
anyhow::anyhow!(
"unsupported logout provider '{provider_id}'; supported providers: openai-codex, anthropic, and configured custom provider ids; usage: /logout anthropic"
)
})
}
#[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(Clone, PartialEq, Eq)]
pub(crate) struct LoginInstructions {
pub(crate) url: String,
pub(crate) message: String,
}
impl fmt::Debug for LoginInstructions {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LoginInstructions")
.field("url", &"<redacted>")
.field("message", &"<redacted>")
.finish()
}
}
pub(crate) fn configure_custom_provider(
paths: &McPaths,
id: &str,
label: &str,
base_url: &str,
api_key_env_var: &str,
models_dev_provider: &str,
) -> anyhow::Result<LoginResult> {
let id = config::validate_custom_provider_id(id)?;
let mut config = config::make_custom_provider_config(label, base_url, api_key_env_var)?;
let models_dev_provider = models_dev_provider.trim();
config.models_dev_provider =
(!models_dev_provider.is_empty()).then(|| models_dev_provider.to_string());
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: use /settings → Models → Refresh catalog, then /model {id}/<model-name>."
),
})
}
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 = auth::exchange_codex_code(&attempt.verifier, &code)?;
if cancel.load(Ordering::Relaxed) {
anyhow::bail!("OpenAI Codex login cancelled; credentials unchanged")
}
auth::persist_codex_token(paths, token)?;
Ok(LoginResult {
message: "OpenAI Codex configured".to_string(),
})
}
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"),
};
wait_for_callback(listener, expected_state, timeout, cancel, manual_rx)
}
fn wait_for_callback(
listener: Option<TcpListener>,
expected_state: &str,
timeout: Duration,
cancel: &AtomicBool,
manual_rx: Option<&Receiver<String>>,
) -> anyhow::Result<String> {
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 Instant::now() >= deadline {
anyhow::bail!(
"OAuth callback timed out; retry /login openai-codex or use manual paste fallback"
)
}
if let Some(listener) = &listener {
match listener.accept() {
Ok((mut stream, _)) => {
let result =
handle_callback_stream(&mut stream, expected_state, deadline, cancel);
if cancel.load(Ordering::Relaxed) || Instant::now() >= deadline {
continue;
}
match result {
Ok(code) => return Ok(code),
Err(error) if error.is::<OAuthProviderRejection>() => return Err(error),
Err(_) => {}
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Err(error) => return Err(error.into()),
}
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn handle_callback_stream(
stream: &mut TcpStream,
expected_state: &str,
deadline: Instant,
cancel: &AtomicBool,
) -> anyhow::Result<String> {
stream.set_nonblocking(false)?;
let deadline = deadline.min(Instant::now() + CALLBACK_STREAM_TIMEOUT);
let request = read_callback_request_line(stream, deadline, cancel)?;
let result = parse_callback_request_line(&request, expected_state);
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() || cancel.load(Ordering::Relaxed) {
anyhow::bail!("OAuth callback timed out or was cancelled")
}
stream.set_write_timeout(Some(remaining.min(Duration::from_millis(50))))?;
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 read_callback_request_line(
stream: &mut TcpStream,
deadline: Instant,
cancel: &AtomicBool,
) -> anyhow::Result<String> {
const MAX_REQUEST_LINE_BYTES: usize = 4096;
let mut request = Vec::new();
loop {
if cancel.load(Ordering::Relaxed) {
anyhow::bail!("OpenAI Codex login cancelled; credentials unchanged")
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
anyhow::bail!("OAuth callback read timed out")
}
stream.set_read_timeout(Some(remaining.min(Duration::from_millis(50))))?;
let mut buffer = [0; 512];
let count = match stream.read(&mut buffer) {
Ok(0) => anyhow::bail!("OAuth callback closed before request line"),
Ok(count) => count,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::Interrupted
) =>
{
continue;
}
Err(error) => return Err(error.into()),
};
let end = buffer[..count].iter().position(|byte| *byte == b'\n');
request.extend_from_slice(&buffer[..end.map_or(count, |end| end + 1)]);
if request.len() > MAX_REQUEST_LINE_BYTES {
anyhow::bail!("OAuth callback request line is too large")
}
if end.is_some() {
return String::from_utf8(request)
.map_err(|_| anyhow::anyhow!("OAuth callback was malformed"));
}
}
}
fn parse_callback_request_line(line: &str, expected_state: &str) -> anyhow::Result<String> {
let parts: Vec<_> = line.split_whitespace().collect();
if parts.len() != 3 || parts[0] != "GET" || !matches!(parts[2], "HTTP/1.0" | "HTTP/1.1") {
anyhow::bail!("OAuth callback was malformed")
}
let target = parts[1];
if target.split('?').next() != Some("/auth/callback") {
anyhow::bail!("OAuth callback used an unexpected path")
}
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())
}
#[derive(Debug, thiserror::Error)]
#[error("OAuth provider rejected login: {0}")]
struct OAuthProviderRejection(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)?;
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")
}
if let Some(error) = params.iter().find(|(k, _)| k == "error").map(|(_, v)| v) {
return Err(OAuthProviderRejection(redact_oauth_text(error)).into());
}
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"))
}
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,
}
}