Compare commits

...

9 Commits

Author SHA1 Message Date
Michael Fan
26779cd160 Fix clippy warning in auth code server
Remove the redundant Arc clone in the shared authorization-code server loop.

Validation:
- cargo check -p codex-login --lib

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 01:00:50 +00:00
Michael Fan
fde3495c53 Format api-provision rebase merge
Apply rustfmt after resolving the api_provision rebase conflict.

Validation:
- cargo fmt --all --manifest-path /home/dev-user/code/codex/codex-rs/Cargo.toml

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 00:56:56 +00:00
Michael Fan
f681d22672 Refactor api-provision browser auth reuse
Move the reusable authorization-code callback server into codex-login::server, switch api-provision over to the shared PKCE/state/callback flow, and keep the TUI browser path alive even when auto-open fails.

Validation:
- cargo check -p codex-login --lib
- cargo fmt --all --manifest-path /home/dev-user/code/codex/codex-rs/Cargo.toml
- git diff --check

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 00:54:34 +00:00
Michael Fan
31c3dd85a8 changes 2026-03-24 20:28:34 -04:00
Michael Fan
d8e18f6b87 codex: fix remaining CI failures on PR #15561
Skip redundant cargo-home cache saves in Windows test jobs to avoid post-test timeouts, and add the required argument comments in the login OAuth helper.

Co-authored-by: Codex <noreply@openai.com>
2026-03-24 18:52:06 -04:00
Michael Fan
c7b44e07d4 codex: fix CI failure on PR #15561 2026-03-24 18:11:39 -04:00
Michael Fan
1cddd263a9 .env -> .env.local 2026-03-24 16:08:10 -04:00
Michael Fan
3004f40703 Use OPENAI_API_KEY for api provisioning
Skip /api-provision when the current Codex process already inherited
OPENAI_API_KEY, and otherwise persist the provisioned key to .env under
OPENAI_API_KEY instead of CODEX_API_KEY.

Validation:
- cargo test -p codex-login
- cargo test -p codex-tui
- just fix -p codex-login
- just fix -p codex-tui
- just fmt

Co-authored-by: Codex <noreply@openai.com>
2026-03-24 16:08:10 -04:00
mifan-oai
60a44baf4d Add CLI api-provision slash command
Extract the browser-based provisioning flow from codex-login so the plain TUI can
reuse it. Add /api-provision to the CLI, persist CODEX_API_KEY to .env, and
hot-apply the key via ephemeral auth without touching auth.json.

Validation:
- cargo test -p codex-login
- cargo test -p codex-tui
- just fix -p codex-login
- just fix -p codex-tui
- just fmt

Co-authored-by: Codex <noreply@openai.com>
2026-03-24 16:08:09 -04:00
17 changed files with 1859 additions and 31 deletions

View File

@@ -494,8 +494,10 @@ jobs:
# Save caches explicitly; make non-fatal so cache packaging
# never fails the overall job. Only save when key wasn't hit.
# Lint/build jobs already save this cache key. Skipping the redundant
# Windows test-job save keeps the test jobs within their timeout budget.
- name: Save cargo home cache
if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
if: always() && !cancelled() && !startsWith(matrix.runner, 'windows') && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:

1
codex-rs/Cargo.lock generated
View File

@@ -2222,6 +2222,7 @@ dependencies = [
"codex-keyring-store",
"codex-protocol",
"codex-terminal-detection",
"codex-utils-home-dir",
"core_test_support",
"keyring",
"once_cell",

View File

@@ -17,6 +17,7 @@ codex-config = { workspace = true }
codex-keyring-store = { workspace = true }
codex-protocol = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-utils-home-dir = { workspace = true }
once_cell = { workspace = true }
os_info = { workspace = true }
rand = { workspace = true }

View File

@@ -0,0 +1,12 @@
#[tokio::main]
async fn main() {
if let Err(err) = codex_login::run_onboard_oauth_helper_from_env().await {
eprintln!("{err}");
if let Some(body) = err.body()
&& !body.is_empty()
{
eprintln!("{body}");
}
std::process::exit(1);
}
}

View File

@@ -0,0 +1,141 @@
use std::fs::OpenOptions;
use std::io;
use std::io::ErrorKind;
use std::path::Path;
use crate::auth::OPENAI_API_KEY_ENV_VAR;
pub fn validate_dotenv_target(path: &Path) -> io::Result<()> {
ensure_parent_dir(path)?;
if path.exists() {
OpenOptions::new().append(true).open(path)?;
return Ok(());
}
OpenOptions::new().write(true).create_new(true).open(path)?;
std::fs::remove_file(path)
}
pub fn upsert_dotenv_api_key(path: &Path, api_key: &str) -> io::Result<()> {
if api_key.contains(['\n', '\r']) {
return Err(io::Error::new(
ErrorKind::InvalidInput,
"OPENAI_API_KEY must not contain newlines",
));
}
ensure_parent_dir(path)?;
let existing = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == ErrorKind::NotFound => String::new(),
Err(err) => return Err(err),
};
let mut next = String::new();
let mut wrote_api_key = false;
for segment in split_lines_preserving_terminators(&existing) {
if is_active_assignment_for(segment, OPENAI_API_KEY_ENV_VAR) {
if !wrote_api_key {
next.push_str(&format!("{OPENAI_API_KEY_ENV_VAR}={api_key}\n"));
wrote_api_key = true;
}
continue;
}
next.push_str(segment);
}
if !wrote_api_key {
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
next.push_str(&format!("{OPENAI_API_KEY_ENV_VAR}={api_key}\n"));
}
std::fs::write(path, next)
}
fn ensure_parent_dir(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(())
}
fn split_lines_preserving_terminators(contents: &str) -> Vec<&str> {
if contents.is_empty() {
return Vec::new();
}
contents.split_inclusive('\n').collect()
}
fn is_active_assignment_for(line: &str, key: &str) -> bool {
let mut rest = line.trim_start();
if rest.starts_with('#') {
return false;
}
if let Some(stripped) = rest.strip_prefix("export") {
rest = stripped.trim_start();
}
let Some(rest) = rest.strip_prefix(key) else {
return false;
};
rest.trim_start().starts_with('=')
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn upsert_creates_dotenv_file_when_missing() {
let temp_dir = tempdir().expect("tempdir");
let dotenv_path = temp_dir.path().join(".env");
upsert_dotenv_api_key(&dotenv_path, "sk-test-key").expect("write dotenv");
let written = std::fs::read_to_string(&dotenv_path).expect("read dotenv");
assert_eq!(written, "OPENAI_API_KEY=sk-test-key\n");
}
#[test]
fn upsert_replaces_existing_api_key_and_collapses_duplicates() {
let temp_dir = tempdir().expect("tempdir");
let dotenv_path = temp_dir.path().join(".env");
std::fs::write(
&dotenv_path,
"# comment\nOPENAI_API_KEY=sk-old-1\nOTHER=value\nexport OPENAI_API_KEY = sk-old-2\n",
)
.expect("seed dotenv");
upsert_dotenv_api_key(&dotenv_path, "sk-new-key").expect("update dotenv");
let written = std::fs::read_to_string(&dotenv_path).expect("read dotenv");
assert_eq!(
written,
"# comment\nOPENAI_API_KEY=sk-new-key\nOTHER=value\n"
);
}
#[test]
fn validate_dotenv_target_succeeds_for_missing_file() {
let temp_dir = tempdir().expect("tempdir");
let dotenv_path = temp_dir.path().join(".env");
validate_dotenv_target(&dotenv_path).expect("validate dotenv");
assert!(
!dotenv_path.exists(),
"validation should not leave behind a new file"
);
}
}

View File

@@ -2,6 +2,8 @@ pub mod auth;
pub mod token_data;
mod device_code_auth;
mod dotenv_api_key;
mod onboard_oauth_helper;
mod pkce;
mod server;
@@ -34,4 +36,12 @@ pub use auth::logout;
pub use auth::read_openai_api_key_from_env;
pub use auth::save_auth;
pub use codex_app_server_protocol::AuthMode;
pub use dotenv_api_key::upsert_dotenv_api_key;
pub use dotenv_api_key::validate_dotenv_target;
pub use onboard_oauth_helper::ApiProvisionOptions;
pub use onboard_oauth_helper::HelperError as OnboardOauthHelperError;
pub use onboard_oauth_helper::PendingApiProvisioning;
pub use onboard_oauth_helper::ProvisionedApiKey;
pub use onboard_oauth_helper::run_from_env as run_onboard_oauth_helper_from_env;
pub use onboard_oauth_helper::start_api_provisioning;
pub use token_data::TokenData;

View File

@@ -0,0 +1,855 @@
//! Browser-based helper for onboarding login and Codex auth provisioning.
use std::fs::OpenOptions;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use codex_app_server_protocol::AuthMode;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_utils_home_dir::find_codex_home;
use reqwest::Client;
use reqwest::Method;
use serde::Deserialize;
use serde::Serialize;
use url::Url;
use crate::auth::AuthDotJson;
use crate::pkce::PkceCodes;
use crate::server::AuthorizationCodeServer;
use crate::server::start_authorization_code_server;
const AUTH_ISSUER: &str = "https://auth.openai.com";
const PLATFORM_HYDRA_CLIENT_ID: &str = "app_2SKx67EdpoN0G6j64rFvigXD";
const PLATFORM_AUDIENCE: &str = "https://api.openai.com/v1";
const DEFAULT_API_BASE: &str = "https://api.openai.com";
const DEFAULT_CALLBACK_PORT: u16 = 5000;
const DEFAULT_CALLBACK_PATH: &str = "/auth/callback";
const DEFAULT_SCOPE: &str = "openid email profile offline_access";
const DEFAULT_APP: &str = "api";
const DEFAULT_USER_AGENT: &str = "OpenAI-Onboard-Auth-Script/1.0";
const DEFAULT_PROJECT_API_KEY_NAME: &str = "Codex CLI";
const DEFAULT_PROJECT_POLL_INTERVAL_SECONDS: u64 = 10;
const DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS: u64 = 60;
const OAUTH_TIMEOUT_SECONDS: u64 = 15 * 60;
const HTTP_TIMEOUT_SECONDS: u64 = 30;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiProvisionOptions {
pub issuer: String,
pub client_id: String,
pub audience: String,
pub api_base: String,
pub app: String,
pub callback_port: u16,
pub scope: String,
pub api_key_name: String,
pub project_poll_interval_seconds: u64,
pub project_poll_timeout_seconds: u64,
}
impl Default for ApiProvisionOptions {
fn default() -> Self {
Self {
issuer: AUTH_ISSUER.to_string(),
client_id: PLATFORM_HYDRA_CLIENT_ID.to_string(),
audience: PLATFORM_AUDIENCE.to_string(),
api_base: DEFAULT_API_BASE.to_string(),
app: DEFAULT_APP.to_string(),
callback_port: DEFAULT_CALLBACK_PORT,
scope: DEFAULT_SCOPE.to_string(),
api_key_name: DEFAULT_PROJECT_API_KEY_NAME.to_string(),
project_poll_interval_seconds: DEFAULT_PROJECT_POLL_INTERVAL_SECONDS,
project_poll_timeout_seconds: DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS,
}
}
}
pub struct PendingApiProvisioning {
client: Client,
options: ApiProvisionOptions,
redirect_uri: String,
code_verifier: String,
callback_server: AuthorizationCodeServer,
}
impl PendingApiProvisioning {
pub fn auth_url(&self) -> &str {
&self.callback_server.auth_url
}
pub fn callback_port(&self) -> u16 {
self.callback_server.actual_port
}
pub fn open_browser(&self) -> bool {
self.callback_server.open_browser()
}
pub fn open_browser_or_print(&self) -> bool {
self.callback_server.open_browser_or_print()
}
pub async fn finish(self) -> Result<ProvisionedApiKey, HelperError> {
let code = self
.callback_server
.wait_for_code(Duration::from_secs(OAUTH_TIMEOUT_SECONDS))
.await
.map_err(|err| HelperError::message(err.to_string()))?;
provision_from_authorization_code(
&self.client,
&self.options,
&self.redirect_uri,
&self.code_verifier,
&code,
)
.await
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvisionedApiKey {
pub sensitive_id: String,
pub organization_id: String,
pub organization_title: Option<String>,
pub default_project_id: String,
pub default_project_title: Option<String>,
pub project_api_key: String,
pub access_token: String,
}
pub fn start_api_provisioning(
options: ApiProvisionOptions,
) -> Result<PendingApiProvisioning, HelperError> {
validate_api_provision_options(&options)?;
let client = build_http_client()?;
let callback_server = start_authorization_code_server(
options.callback_port,
DEFAULT_CALLBACK_PATH,
/*force_state*/ None,
|redirect_uri, pkce, state| {
build_authorize_url(&options, redirect_uri, pkce, state)
.map_err(|err| std::io::Error::other(err.to_string()))
},
)
.map_err(|err| HelperError::message(err.to_string()))?;
let redirect_uri = callback_server.redirect_uri.clone();
Ok(PendingApiProvisioning {
client,
options,
redirect_uri,
code_verifier: callback_server.code_verifier().to_string(),
callback_server,
})
}
pub async fn run_from_env() -> Result<(), HelperError> {
match parse_args(std::env::args())? {
ParseOutcome::Help(help) => {
println!("{help}");
Ok(())
}
ParseOutcome::Run(options) => {
let auth_path = resolve_codex_auth_path(options.codex_auth_path.as_deref())?;
let session = start_api_provisioning(options.api_provision_options())?;
session.open_browser_or_print();
let provisioned = session.finish().await?;
let codex_auth_synced = !options.skip_codex_auth_sync;
if codex_auth_synced {
sync_codex_api_key(&provisioned.project_api_key, &auth_path)?;
eprintln!("Synced project API key to {}.", auth_path.display());
} else {
eprintln!("Skipping Codex auth sync.");
}
let output = ScriptOutput {
sensitive_id: provisioned.sensitive_id,
organization_id: provisioned.organization_id,
organization_title: provisioned.organization_title,
default_project_id: provisioned.default_project_id,
default_project_title: provisioned.default_project_title,
project_api_key: provisioned.project_api_key,
codex_auth_path: auth_path.display().to_string(),
codex_auth_synced,
access_token: options
.include_access_token
.then_some(provisioned.access_token),
};
print_output(&output, options.output)?;
Ok(())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CliOptions {
issuer: String,
client_id: String,
audience: String,
api_base: String,
app: String,
callback_port: u16,
scope: String,
api_key_name: String,
project_poll_interval_seconds: u64,
project_poll_timeout_seconds: u64,
codex_auth_path: Option<PathBuf>,
skip_codex_auth_sync: bool,
include_access_token: bool,
output: OutputFormat,
}
impl Default for CliOptions {
fn default() -> Self {
Self {
issuer: AUTH_ISSUER.to_string(),
client_id: PLATFORM_HYDRA_CLIENT_ID.to_string(),
audience: PLATFORM_AUDIENCE.to_string(),
api_base: DEFAULT_API_BASE.to_string(),
app: DEFAULT_APP.to_string(),
callback_port: DEFAULT_CALLBACK_PORT,
scope: DEFAULT_SCOPE.to_string(),
api_key_name: DEFAULT_PROJECT_API_KEY_NAME.to_string(),
project_poll_interval_seconds: DEFAULT_PROJECT_POLL_INTERVAL_SECONDS,
project_poll_timeout_seconds: DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS,
codex_auth_path: None,
skip_codex_auth_sync: false,
include_access_token: false,
output: OutputFormat::Json,
}
}
}
impl CliOptions {
fn api_provision_options(&self) -> ApiProvisionOptions {
ApiProvisionOptions {
issuer: self.issuer.clone(),
client_id: self.client_id.clone(),
audience: self.audience.clone(),
api_base: self.api_base.clone(),
app: self.app.clone(),
callback_port: self.callback_port,
scope: self.scope.clone(),
api_key_name: self.api_key_name.clone(),
project_poll_interval_seconds: self.project_poll_interval_seconds,
project_poll_timeout_seconds: self.project_poll_timeout_seconds,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputFormat {
Json,
SensitiveId,
ApiKey,
}
impl OutputFormat {
fn parse(raw: &str) -> Result<Self, HelperError> {
match raw {
"json" => Ok(Self::Json),
"sensitive_id" => Ok(Self::SensitiveId),
"api_key" => Ok(Self::ApiKey),
_ => Err(HelperError::message(format!(
"invalid value for `--output`: `{raw}`"
))),
}
}
}
enum ParseOutcome {
Help(String),
Run(CliOptions),
}
fn parse_args<I>(args: I) -> Result<ParseOutcome, HelperError>
where
I: IntoIterator<Item = String>,
{
let mut args = args.into_iter();
let program = args
.next()
.unwrap_or_else(|| "get_sensitive_id_via_codex_oauth".to_string());
let mut options = CliOptions::default();
let mut rest = args.peekable();
while let Some(arg) = rest.next() {
match arg.as_str() {
"-h" | "--help" => return Ok(ParseOutcome::Help(usage(&program))),
"--issuer" => options.issuer = take_value(&mut rest, "--issuer")?,
"--client-id" => options.client_id = take_value(&mut rest, "--client-id")?,
"--audience" => options.audience = take_value(&mut rest, "--audience")?,
"--api-base" => options.api_base = take_value(&mut rest, "--api-base")?,
"--app" => options.app = take_value(&mut rest, "--app")?,
"--callback-port" => {
options.callback_port =
parse_u16(take_value(&mut rest, "--callback-port")?, "--callback-port")?
}
"--scope" => options.scope = take_value(&mut rest, "--scope")?,
"--api-key-name" => options.api_key_name = take_value(&mut rest, "--api-key-name")?,
"--project-poll-interval-seconds" => {
options.project_poll_interval_seconds = parse_u64(
take_value(&mut rest, "--project-poll-interval-seconds")?,
"--project-poll-interval-seconds",
)?
}
"--project-poll-timeout-seconds" => {
options.project_poll_timeout_seconds = parse_u64(
take_value(&mut rest, "--project-poll-timeout-seconds")?,
"--project-poll-timeout-seconds",
)?
}
"--codex-auth-path" => {
options.codex_auth_path =
Some(PathBuf::from(take_value(&mut rest, "--codex-auth-path")?))
}
"--skip-codex-auth-sync" => options.skip_codex_auth_sync = true,
"--include-access-token" => options.include_access_token = true,
"--output" => {
options.output = OutputFormat::parse(&take_value(&mut rest, "--output")?)?
}
_ => {
return Err(HelperError::message(format!(
"unknown argument `{arg}`\n\n{}",
usage(&program)
)));
}
}
}
validate_api_provision_options(&options.api_provision_options())?;
Ok(ParseOutcome::Run(options))
}
fn validate_api_provision_options(options: &ApiProvisionOptions) -> Result<(), HelperError> {
if options.project_poll_interval_seconds == 0 {
return Err(HelperError::message(
"--project-poll-interval-seconds must be greater than 0.".to_string(),
));
}
if options.project_poll_timeout_seconds == 0 {
return Err(HelperError::message(
"--project-poll-timeout-seconds must be greater than 0.".to_string(),
));
}
if options.api_key_name.trim().is_empty() {
return Err(HelperError::message(
"--api-key-name must not be empty.".to_string(),
));
}
Ok(())
}
fn usage(program: &str) -> String {
format!(
"Usage: {program} [OPTIONS]\n\n\
Options:\n\
--issuer URL OAuth issuer base URL\n\
--client-id ID Hydra client id to use\n\
--audience URL OAuth audience\n\
--api-base URL Base API URL for onboarding exchange\n\
--app NAME `app` value for /dashboard/onboarding/login\n\
--callback-port PORT Local callback port (default: {DEFAULT_CALLBACK_PORT})\n\
--scope SCOPE OAuth scope string\n\
--api-key-name NAME Provisioned project API key name\n\
--project-poll-interval-seconds SEC Delay between default-project checks\n\
--project-poll-timeout-seconds SEC Maximum wait for organization/project readiness\n\
--codex-auth-path PATH Explicit auth.json path\n\
--skip-codex-auth-sync Do not write the provisioned API key to auth.json\n\
--include-access-token Include the OAuth access token in JSON output\n\
--output FORMAT One of: json, sensitive_id, api_key\n\
-h, --help Show this help message"
)
}
fn take_value<I>(args: &mut std::iter::Peekable<I>, flag: &str) -> Result<String, HelperError>
where
I: Iterator<Item = String>,
{
args.next()
.ok_or_else(|| HelperError::message(format!("missing value for `{flag}`")))
}
fn parse_u16(raw: String, flag: &str) -> Result<u16, HelperError> {
raw.parse::<u16>()
.map_err(|err| HelperError::message(format!("invalid value for `{flag}`: {err}")))
}
fn parse_u64(raw: String, flag: &str) -> Result<u64, HelperError> {
raw.parse::<u64>()
.map_err(|err| HelperError::message(format!("invalid value for `{flag}`: {err}")))
}
fn build_authorize_url(
options: &ApiProvisionOptions,
redirect_uri: &str,
pkce: &PkceCodes,
state: &str,
) -> Result<String, HelperError> {
let mut url = Url::parse(&format!(
"{}/oauth/authorize",
options.issuer.trim_end_matches('/')
))
.map_err(|err| HelperError::message(format!("invalid issuer URL: {err}")))?;
url.query_pairs_mut()
.append_pair("audience", &options.audience)
.append_pair("client_id", &options.client_id)
.append_pair("code_challenge_method", "S256")
.append_pair("code_challenge", &pkce.code_challenge)
.append_pair("redirect_uri", redirect_uri)
.append_pair("response_type", "code")
.append_pair("scope", &options.scope)
.append_pair("state", state);
Ok(url.to_string())
}
fn build_http_client() -> Result<Client, HelperError> {
build_reqwest_client_with_custom_ca(
reqwest::Client::builder().timeout(Duration::from_secs(HTTP_TIMEOUT_SECONDS)),
)
.map_err(|err| HelperError::message(format!("failed to build HTTP client: {err}")))
}
async fn provision_from_authorization_code(
client: &Client,
options: &ApiProvisionOptions,
redirect_uri: &str,
code_verifier: &str,
code: &str,
) -> Result<ProvisionedApiKey, HelperError> {
let tokens = exchange_authorization_code_for_tokens(
client,
&options.issuer,
&options.client_id,
redirect_uri,
code_verifier,
code,
)
.await?;
let login = onboarding_login(
client,
&options.api_base,
&options.app,
&tokens.access_token,
)
.await?;
let target = wait_for_default_project(
client,
&options.api_base,
&login.user.session.sensitive_id,
options.project_poll_interval_seconds,
options.project_poll_timeout_seconds,
)
.await?;
let api_key = create_project_api_key(
client,
&options.api_base,
&login.user.session.sensitive_id,
&target,
&options.api_key_name,
)
.await?
.key
.sensitive_id;
Ok(ProvisionedApiKey {
sensitive_id: login.user.session.sensitive_id,
organization_id: target.organization_id,
organization_title: target.organization_title,
default_project_id: target.project_id,
default_project_title: target.project_title,
project_api_key: api_key,
access_token: tokens.access_token,
})
}
async fn exchange_authorization_code_for_tokens(
client: &Client,
issuer: &str,
client_id: &str,
redirect_uri: &str,
code_verifier: &str,
code: &str,
) -> Result<OAuthTokens, HelperError> {
let url = format!("{}/oauth/token", issuer.trim_end_matches('/'));
execute_json(
client
.request(Method::POST, &url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
.json(&serde_json::json!({
"client_id": client_id,
"code_verifier": code_verifier,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
})),
"POST",
&url,
)
.await
}
async fn onboarding_login(
client: &Client,
api_base: &str,
app: &str,
access_token: &str,
) -> Result<OnboardingLoginResponse, HelperError> {
let url = format!(
"{}/dashboard/onboarding/login",
api_base.trim_end_matches('/')
);
execute_json(
client
.request(Method::POST, &url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
.bearer_auth(access_token)
.json(&serde_json::json!({ "app": app })),
"POST",
&url,
)
.await
}
async fn list_organizations(
client: &Client,
api_base: &str,
session_key: &str,
) -> Result<Vec<Organization>, HelperError> {
let url = format!("{}/v1/organizations", api_base.trim_end_matches('/'));
let response: DataList<Organization> = execute_json(
client
.request(Method::GET, &url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
.bearer_auth(session_key),
"GET",
&url,
)
.await?;
Ok(response.data)
}
async fn list_projects(
client: &Client,
api_base: &str,
session_key: &str,
organization_id: &str,
) -> Result<Vec<Project>, HelperError> {
let url = format!(
"{}/dashboard/organizations/{}/projects?detail=basic&limit=100",
api_base.trim_end_matches('/'),
urlencoding::encode(organization_id)
);
let response: DataList<Project> = execute_json(
client
.request(Method::GET, &url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
.header("openai-organization", organization_id)
.bearer_auth(session_key),
"GET",
&url,
)
.await?;
Ok(response.data)
}
async fn wait_for_default_project(
client: &Client,
api_base: &str,
session_key: &str,
poll_interval_seconds: u64,
timeout_seconds: u64,
) -> Result<ProvisioningTarget, HelperError> {
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_seconds);
loop {
let organizations = list_organizations(client, api_base, session_key).await?;
let last_state = if let Some(organization) = select_active_organization(&organizations) {
let projects = list_projects(client, api_base, session_key, &organization.id).await?;
if let Some(project) = find_default_project(&projects) {
return Ok(ProvisioningTarget {
organization_id: organization.id.clone(),
organization_title: organization.title.clone(),
project_id: project.id.clone(),
project_title: project.title.clone(),
});
}
format!(
"organization `{}` exists, but no default project is ready yet (saw {} projects).",
organization.id,
projects.len()
)
} else {
"no organization found".to_string()
};
if std::time::Instant::now() >= deadline {
return Err(HelperError::message(format!(
"Timed out waiting for an organization and default project. Last observed state: {last_state}"
)));
}
let remaining_seconds = deadline
.saturating_duration_since(std::time::Instant::now())
.as_secs();
let sleep_seconds = poll_interval_seconds.min(remaining_seconds.max(1));
std::thread::sleep(Duration::from_secs(sleep_seconds));
}
}
fn select_active_organization(organizations: &[Organization]) -> Option<&Organization> {
organizations
.iter()
.find(|organization| organization.is_default)
.or_else(|| {
organizations
.iter()
.find(|organization| organization.personal)
})
.or_else(|| organizations.first())
}
fn find_default_project(projects: &[Project]) -> Option<&Project> {
projects.iter().find(|project| project.is_initial)
}
async fn create_project_api_key(
client: &Client,
api_base: &str,
session_key: &str,
target: &ProvisioningTarget,
key_name: &str,
) -> Result<CreateApiKeyResponse, HelperError> {
let url = format!(
"{}/dashboard/organizations/{}/projects/{}/api_keys",
api_base.trim_end_matches('/'),
urlencoding::encode(&target.organization_id),
urlencoding::encode(&target.project_id)
);
execute_json(
client
.request(Method::POST, &url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
.bearer_auth(session_key)
.json(&serde_json::json!({
"action": "create",
"name": key_name,
})),
"POST",
&url,
)
.await
}
async fn execute_json<T>(
request: reqwest::RequestBuilder,
method: &str,
url: &str,
) -> Result<T, HelperError>
where
T: for<'de> Deserialize<'de>,
{
let response = request
.send()
.await
.map_err(|err| HelperError::message(format!("Network error calling {url}: {err}")))?;
let status = response.status();
let body = response.bytes().await.map_err(|err| {
HelperError::message(format!("Failed reading response from {url}: {err}"))
})?;
if !status.is_success() {
return Err(HelperError::api(
format!("{method} {url} failed with HTTP {status}"),
String::from_utf8_lossy(&body).into_owned(),
));
}
serde_json::from_slice(&body)
.map_err(|err| HelperError::message(format!("{url} returned invalid JSON: {err}")))
}
fn resolve_codex_auth_path(explicit: Option<&Path>) -> Result<PathBuf, HelperError> {
match explicit {
Some(path) => Ok(path.to_path_buf()),
None => Ok(find_codex_home()
.map_err(|err| HelperError::message(format!("failed to resolve CODEX_HOME: {err}")))?
.join("auth.json")),
}
}
fn sync_codex_api_key(api_key: &str, auth_path: &Path) -> Result<(), HelperError> {
if let Some(parent) = auth_path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
HelperError::message(format!(
"Failed to create auth directory {}: {err}",
parent.display()
))
})?;
}
let auth = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some(api_key.to_string()),
tokens: None,
last_refresh: None,
};
let json = format!(
"{}\n",
serde_json::to_string_pretty(&auth).map_err(|err| {
HelperError::message(format!("failed to serialize auth.json contents: {err}"))
})?
);
let mut options = OpenOptions::new();
options.truncate(true).write(true).create(true);
#[cfg(unix)]
{
options.mode(0o600);
}
let mut file = options.open(auth_path).map_err(|err| {
HelperError::message(format!(
"Failed to open {} for writing: {err}",
auth_path.display()
))
})?;
file.write_all(json.as_bytes()).map_err(|err| {
HelperError::message(format!("Failed to write {}: {err}", auth_path.display()))
})?;
file.flush().map_err(|err| {
HelperError::message(format!("Failed to flush {}: {err}", auth_path.display()))
})
}
fn print_output(output: &ScriptOutput, format: OutputFormat) -> Result<(), HelperError> {
match format {
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(output).map_err(|err| {
HelperError::message(format!("failed to serialize output: {err}"))
})?
);
}
OutputFormat::SensitiveId => println!("{}", output.sensitive_id),
OutputFormat::ApiKey => println!("{}", output.project_api_key),
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ScriptOutput {
sensitive_id: String,
organization_id: String,
organization_title: Option<String>,
default_project_id: String,
default_project_title: Option<String>,
project_api_key: String,
codex_auth_path: String,
codex_auth_synced: bool,
#[serde(skip_serializing_if = "Option::is_none")]
access_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OAuthTokens {
#[serde(rename = "id_token")]
_id_token: String,
access_token: String,
#[serde(rename = "refresh_token")]
_refresh_token: String,
}
#[derive(Debug, Deserialize)]
struct OnboardingLoginResponse {
user: OnboardingUser,
}
#[derive(Debug, Deserialize)]
struct OnboardingUser {
session: OnboardingSession,
}
#[derive(Debug, Deserialize)]
struct OnboardingSession {
sensitive_id: String,
}
#[derive(Debug, Deserialize)]
struct DataList<T> {
data: Vec<T>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct Organization {
id: String,
title: Option<String>,
#[serde(default)]
is_default: bool,
#[serde(default)]
personal: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct Project {
id: String,
title: Option<String>,
#[serde(default)]
is_initial: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProvisioningTarget {
organization_id: String,
organization_title: Option<String>,
project_id: String,
project_title: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreateApiKeyResponse {
key: CreatedApiKey,
}
#[derive(Debug, Deserialize)]
struct CreatedApiKey {
sensitive_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperError {
message: String,
body: Option<String>,
}
impl HelperError {
fn message(message: String) -> Self {
Self {
message,
body: None,
}
}
fn api(message: String, body: String) -> Self {
Self {
message,
body: Some(body),
}
}
pub fn body(&self) -> Option<&str> {
self.body.as_deref()
}
}
impl std::fmt::Display for HelperError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for HelperError {}
#[cfg(test)]
#[path = "onboard_oauth_helper_tests.rs"]
mod tests;

View File

@@ -0,0 +1,187 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
#[test]
fn select_active_organization_prefers_default_then_personal_then_first() {
let organizations = vec![
Organization {
id: "org-first".to_string(),
title: Some("First".to_string()),
is_default: false,
personal: false,
},
Organization {
id: "org-personal".to_string(),
title: Some("Personal".to_string()),
is_default: false,
personal: true,
},
Organization {
id: "org-default".to_string(),
title: Some("Default".to_string()),
is_default: true,
personal: false,
},
];
let selected = select_active_organization(&organizations);
assert_eq!(selected, organizations.get(2));
}
#[test]
fn find_default_project_returns_initial_project() {
let projects = vec![
Project {
id: "proj-secondary".to_string(),
title: Some("Secondary".to_string()),
is_initial: false,
},
Project {
id: "proj-default".to_string(),
title: Some("Default".to_string()),
is_initial: true,
},
];
let selected = find_default_project(&projects);
assert_eq!(selected, projects.get(1));
}
#[test]
fn sync_codex_api_key_writes_expected_auth_json() {
let temp_dir = tempdir().expect("tempdir");
let auth_path = temp_dir.path().join("codex").join("auth.json");
sync_codex_api_key("sk-test-key", &auth_path).expect("sync auth");
let written = std::fs::read_to_string(&auth_path).expect("read auth");
let parsed: AuthDotJson = serde_json::from_str(&written).expect("parse auth");
assert_eq!(
parsed,
AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
}
);
}
#[tokio::test]
async fn provision_from_authorization_code_provisions_api_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_json(json!({
"client_id": "client-123",
"code_verifier": "verifier-123",
"code": "auth-code-123",
"grant_type": "authorization_code",
"redirect_uri": "http://localhost:5000/auth/callback",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id_token": "id-token-123",
"access_token": "oauth-access-123",
"refresh_token": "oauth-refresh-123",
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/dashboard/onboarding/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"user": {
"session": {
"sensitive_id": "session-123",
}
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/organizations"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{
"id": "org-default",
"title": "Default Org",
"is_default": true,
}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/dashboard/organizations/org-default/projects"))
.and(query_param("detail", "basic"))
.and(query_param("limit", "100"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{
"id": "proj-default",
"title": "Default Project",
"is_initial": true,
}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path(
"/dashboard/organizations/org-default/projects/proj-default/api_keys",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"key": {
"sensitive_id": "sk-proj-123",
}
})))
.mount(&server)
.await;
let options = ApiProvisionOptions {
issuer: server.uri(),
client_id: "client-123".to_string(),
audience: PLATFORM_AUDIENCE.to_string(),
api_base: server.uri(),
app: DEFAULT_APP.to_string(),
callback_port: DEFAULT_CALLBACK_PORT,
scope: DEFAULT_SCOPE.to_string(),
api_key_name: DEFAULT_PROJECT_API_KEY_NAME.to_string(),
project_poll_interval_seconds: 1,
project_poll_timeout_seconds: 5,
};
let client = build_http_client().expect("client");
let output = provision_from_authorization_code(
&client,
&options,
"http://localhost:5000/auth/callback",
"verifier-123",
"auth-code-123",
)
.await
.expect("provision");
assert_eq!(
output,
ProvisionedApiKey {
sensitive_id: "session-123".to_string(),
organization_id: "org-default".to_string(),
organization_title: Some("Default Org".to_string()),
default_project_id: "proj-default".to_string(),
default_project_title: Some("Default Project".to_string()),
project_api_key: "sk-proj-123".to_string(),
access_token: "oauth-access-123".to_string(),
}
);
}

View File

@@ -110,6 +110,67 @@ impl LoginServer {
}
}
/// Handle for a running authorization-code callback server.
pub(crate) struct AuthorizationCodeServer {
pub auth_url: String,
pub actual_port: u16,
pub redirect_uri: String,
code_verifier: String,
server_handle: tokio::task::JoinHandle<io::Result<String>>,
shutdown_handle: ShutdownHandle,
}
impl AuthorizationCodeServer {
pub fn open_browser(&self) -> bool {
webbrowser::open(&self.auth_url).is_ok()
}
pub fn open_browser_or_print(&self) -> bool {
let opened = self.open_browser();
if opened {
eprintln!(
"Starting local auth callback server.\nIf your browser did not open, navigate to this URL to continue:\n\n{}",
self.auth_url
);
} else {
eprintln!(
"Starting local auth callback server.\nOpen this URL in your browser to continue:\n\n{}",
self.auth_url
);
}
opened
}
pub fn code_verifier(&self) -> &str {
&self.code_verifier
}
pub async fn wait_for_code(self, timeout: Duration) -> io::Result<String> {
let AuthorizationCodeServer {
server_handle,
shutdown_handle,
..
} = self;
let server_handle = server_handle;
tokio::pin!(server_handle);
tokio::select! {
result = &mut server_handle => {
result
.map_err(|err| io::Error::other(format!("authorization-code server thread panicked: {err:?}")))?
}
_ = tokio::time::sleep(timeout) => {
shutdown_handle.shutdown();
let _ = server_handle.await;
Err(io::Error::new(
io::ErrorKind::TimedOut,
"OAuth login timed out waiting for the browser callback.",
))
}
}
}
}
/// Handle used to signal the login server loop to exit.
#[derive(Clone, Debug)]
pub struct ShutdownHandle {
@@ -123,6 +184,91 @@ impl ShutdownHandle {
}
}
pub(crate) fn start_authorization_code_server<F>(
port: u16,
callback_path: &str,
force_state: Option<String>,
auth_url_builder: F,
) -> io::Result<AuthorizationCodeServer>
where
F: FnOnce(&str, &PkceCodes, &str) -> io::Result<String>,
{
let pkce = generate_pkce();
let state = force_state.unwrap_or_else(generate_state);
let callback_path = callback_path.to_string();
let server = bind_server(port)?;
let actual_port = match server.server_addr().to_ip() {
Some(addr) => addr.port(),
None => {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
"Unable to determine the server port",
));
}
};
let server = Arc::new(server);
let redirect_uri = format!("http://localhost:{actual_port}{callback_path}");
let auth_url = auth_url_builder(&redirect_uri, &pkce, &state)?;
let (tx, mut rx) = tokio::sync::mpsc::channel::<Request>(16);
let _server_handle = {
let server = server.clone();
thread::spawn(move || -> io::Result<()> {
while let Ok(request) = server.recv() {
match tx.blocking_send(request) {
Ok(()) => {}
Err(error) => {
eprintln!("Failed to send request to channel: {error}");
return Err(io::Error::other("Failed to send request to channel"));
}
}
}
Ok(())
})
};
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
let server_handle = {
let shutdown_notify = shutdown_notify.clone();
tokio::spawn(async move {
let result = loop {
tokio::select! {
_ = shutdown_notify.notified() => {
break Err(io::Error::other("Authentication was not completed"));
}
maybe_req = rx.recv() => {
let Some(req) = maybe_req else {
break Err(io::Error::other("Authentication was not completed"));
};
let url_raw = req.url().to_string();
let response =
process_authorization_code_request(&url_raw, &callback_path, &state);
if let Some(result) = respond_to_request(req, response).await {
break result;
}
}
}
};
server.unblock();
result
})
};
Ok(AuthorizationCodeServer {
auth_url,
actual_port,
redirect_uri,
code_verifier: pkce.code_verifier,
server_handle,
shutdown_handle: ShutdownHandle { shutdown_notify },
})
}
/// Starts a local callback server and returns the browser auth URL.
pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let pkce = generate_pkce();
@@ -191,30 +337,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let response =
process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await;
let exit_result = match response {
HandledRequest::Response(response) => {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
None
}
HandledRequest::ResponseAndExit {
headers,
body,
result,
} => {
let _ = tokio::task::spawn_blocking(move || {
send_response_with_disconnect(req, headers, body)
})
.await;
Some(result)
}
HandledRequest::RedirectWithHeader(header) => {
let redirect = Response::empty(302).with_header(header);
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
None
}
};
if let Some(result) = exit_result {
if let Some(result) = respond_to_request(req, response).await {
break result;
}
}
@@ -237,13 +360,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
}
/// Internal callback handling outcome.
enum HandledRequest {
enum HandledRequest<T> {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
ResponseAndExit {
status: StatusCode,
headers: Vec<Header>,
body: Vec<u8>,
result: io::Result<()>,
result: io::Result<T>,
},
}
@@ -254,7 +378,7 @@ async fn process_request(
pkce: &PkceCodes,
actual_port: u16,
state: &str,
) -> HandledRequest {
) -> HandledRequest<()> {
let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) {
Ok(u) => u,
Err(e) => {
@@ -392,6 +516,7 @@ async fn process_request(
"/success" => {
let body = include_str!("assets/success.html");
HandledRequest::ResponseAndExit {
status: StatusCode(200),
headers: match Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
@@ -404,6 +529,7 @@ async fn process_request(
}
}
"/cancel" => HandledRequest::ResponseAndExit {
status: StatusCode(200),
headers: Vec::new(),
body: b"Login cancelled".to_vec(),
result: Err(io::Error::new(
@@ -415,6 +541,117 @@ async fn process_request(
}
}
fn process_authorization_code_request(
url_raw: &str,
callback_path: &str,
expected_state: &str,
) -> HandledRequest<String> {
let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) {
Ok(u) => u,
Err(err) => {
return HandledRequest::Response(
Response::from_string(format!("Bad Request: {err}")).with_status_code(400),
);
}
};
match parsed_url.path() {
"/cancel" => HandledRequest::ResponseAndExit {
status: StatusCode(200),
headers: Vec::new(),
body: b"Login cancelled".to_vec(),
result: Err(io::Error::new(
io::ErrorKind::Interrupted,
"Login cancelled",
)),
},
path if path == callback_path => {
let params: std::collections::HashMap<String, String> =
parsed_url.query_pairs().into_owned().collect();
if params.get("state").map(String::as_str) != Some(expected_state) {
return HandledRequest::ResponseAndExit {
status: StatusCode(400),
headers: html_headers(),
body: b"<h1>State mismatch</h1><p>Return to your terminal and try again.</p>"
.to_vec(),
result: Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"State mismatch in OAuth callback.",
)),
};
}
if let Some(error_code) = params.get("error") {
let message = oauth_callback_error_message(
error_code,
params.get("error_description").map(String::as_str),
);
return HandledRequest::ResponseAndExit {
status: StatusCode(403),
headers: html_headers(),
body: b"<h1>Sign-in failed</h1><p>Return to your terminal.</p>".to_vec(),
result: Err(io::Error::new(io::ErrorKind::PermissionDenied, message)),
};
}
match params.get("code") {
Some(code) if !code.is_empty() => HandledRequest::ResponseAndExit {
status: StatusCode(200),
headers: html_headers(),
body: b"<h1>Sign-in complete</h1><p>You can return to your terminal.</p>"
.to_vec(),
result: Ok(code.clone()),
},
_ => HandledRequest::ResponseAndExit {
status: StatusCode(400),
headers: html_headers(),
body: b"<h1>Missing authorization code</h1><p>Return to your terminal.</p>"
.to_vec(),
result: Err(io::Error::new(
io::ErrorKind::InvalidData,
"Missing authorization code. Sign-in could not be completed.",
)),
},
}
}
_ => HandledRequest::Response(Response::from_string("Not Found").with_status_code(404)),
}
}
fn html_headers() -> Vec<Header> {
match Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
Ok(header) => vec![header],
Err(_) => Vec::new(),
}
}
async fn respond_to_request<T>(req: Request, response: HandledRequest<T>) -> Option<io::Result<T>> {
match response {
HandledRequest::Response(response) => {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
None
}
HandledRequest::RedirectWithHeader(header) => {
let redirect = Response::empty(302).with_header(header);
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
None
}
HandledRequest::ResponseAndExit {
status,
headers,
body,
result,
} => {
let _ = tokio::task::spawn_blocking(move || {
send_response_with_disconnect(req, status, headers, body)
})
.await;
Some(result)
}
}
}
/// tiny_http filters `Connection` headers out of `Response` objects, so using
/// `req.respond` never informs the client (or the library) that a keep-alive
/// socket should be closed. That leaves the per-connection worker parked in a
@@ -426,10 +663,10 @@ async fn process_request(
/// server-side connection persistence, but it does not.
fn send_response_with_disconnect(
req: Request,
status: StatusCode,
mut headers: Vec<Header>,
body: Vec<u8>,
) -> io::Result<()> {
let status = StatusCode(200);
let mut writer = req.into_writer();
let reason = status.default_reason_phrase();
write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?;
@@ -888,13 +1125,14 @@ fn login_error_response(
kind: io::ErrorKind,
error_code: Option<&str>,
error_description: Option<&str>,
) -> HandledRequest {
) -> HandledRequest<()> {
let mut headers = Vec::new();
if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
headers.push(header);
}
let body = render_login_error_page(message, error_code, error_description);
HandledRequest::ResponseAndExit {
status: StatusCode(200),
headers,
body,
result: Err(io::Error::new(kind, message.to_string())),

View File

@@ -0,0 +1,321 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::AuthManager;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::login_with_api_key;
use codex_core::auth::read_openai_api_key_from_env;
use codex_login::ApiProvisionOptions;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_login::PendingApiProvisioning;
use codex_login::ProvisionedApiKey;
use codex_login::start_api_provisioning;
use codex_login::upsert_dotenv_api_key;
use codex_login::validate_dotenv_target;
use codex_protocol::config_types::ForcedLoginMethod;
use ratatui::style::Stylize;
use ratatui::text::Line;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::history_cell;
use crate::history_cell::PlainHistoryCell;
pub(crate) fn start_command(
app_event_tx: AppEventSender,
auth_manager: Arc<AuthManager>,
codex_home: PathBuf,
cwd: PathBuf,
forced_login_method: Option<ForcedLoginMethod>,
) -> Result<PlainHistoryCell, String> {
if read_openai_api_key_from_env().is_some() {
return Ok(existing_shell_api_key_message());
}
let dotenv_path = cwd.join(".env.local");
validate_dotenv_target(&dotenv_path).map_err(|err| {
format!(
"Unable to prepare {} for {OPENAI_API_KEY_ENV_VAR}: {err}",
dotenv_path.display(),
)
})?;
let options = ApiProvisionOptions::default();
let session = start_api_provisioning(options)
.map_err(|err| format!("Failed to start API provisioning: {err}"))?;
let browser_opened = session.open_browser();
let start_message = continue_in_browser_message(
session.auth_url(),
session.callback_port(),
&dotenv_path,
browser_opened,
);
let app_event_tx_for_task = app_event_tx;
let dotenv_path_for_task = dotenv_path;
tokio::spawn(async move {
let cell = complete_command(
session,
dotenv_path_for_task,
codex_home,
forced_login_method,
auth_manager,
)
.await;
app_event_tx_for_task.send(AppEvent::InsertHistoryCell(Box::new(cell)));
});
Ok(start_message)
}
fn existing_shell_api_key_message() -> PlainHistoryCell {
history_cell::new_info_event(
format!(
"{OPENAI_API_KEY_ENV_VAR} is already set in this Codex session; skipping API provisioning."
),
Some(format!(
"This Codex session already inherited {OPENAI_API_KEY_ENV_VAR} from its shell environment. Unset it and run /api-provision again if you want Codex to provision and save a different key."
)),
)
}
fn continue_in_browser_message(
auth_url: &str,
callback_port: u16,
dotenv_path: &Path,
browser_opened: bool,
) -> PlainHistoryCell {
let mut lines = vec![
vec![
"".dim(),
"Finish API provisioning via your browser.".into(),
]
.into(),
"".into(),
];
if browser_opened {
lines.push(
" Codex tried to open this link for you."
.dark_gray()
.into(),
);
} else {
lines.push(
" Codex couldn't auto-open your browser, but the provisioning flow is still waiting."
.dark_gray()
.into(),
);
}
lines.push("".into());
lines.push(" Open the following link to authenticate:".into());
lines.push("".into());
lines.push(Line::from(vec![
" ".into(),
auth_url.to_string().cyan().underlined(),
]));
lines.push("".into());
lines.push(
format!(
" Codex will save {OPENAI_API_KEY_ENV_VAR} to {} and hot-apply it here when allowed.",
dotenv_path.display()
)
.dark_gray()
.into(),
);
lines.push("".into());
lines.push(
format!(
" On a remote or headless machine, forward localhost:{callback_port} back to this Codex host before opening the link."
)
.dark_gray()
.into(),
);
PlainHistoryCell::new(lines)
}
async fn complete_command(
session: PendingApiProvisioning,
dotenv_path: PathBuf,
codex_home: PathBuf,
forced_login_method: Option<ForcedLoginMethod>,
auth_manager: Arc<AuthManager>,
) -> PlainHistoryCell {
let provisioned = match session.finish().await {
Ok(provisioned) => provisioned,
Err(err) => {
return history_cell::new_error_event(format!("API provisioning failed: {err}"));
}
};
if let Err(err) = upsert_dotenv_api_key(&dotenv_path, &provisioned.project_api_key) {
return history_cell::new_error_event(format!(
"Provisioning completed, but Codex could not update {}: {err}",
dotenv_path.display()
));
}
success_cell(
&provisioned,
&dotenv_path,
live_apply_api_key(
forced_login_method,
&codex_home,
&provisioned.project_api_key,
auth_manager,
),
)
}
fn live_apply_api_key(
forced_login_method: Option<ForcedLoginMethod>,
codex_home: &Path,
api_key: &str,
auth_manager: Arc<AuthManager>,
) -> LiveApplyOutcome {
if matches!(forced_login_method, Some(ForcedLoginMethod::Chatgpt)) {
return LiveApplyOutcome::Skipped(format!(
"Saved {OPENAI_API_KEY_ENV_VAR} to .env.local, but left this session unchanged because ChatGPT login is required here."
));
}
match login_with_api_key(codex_home, api_key, AuthCredentialsStoreMode::Ephemeral) {
Ok(()) => {
auth_manager.reload();
LiveApplyOutcome::Applied
}
Err(err) => LiveApplyOutcome::Failed(err.to_string()),
}
}
fn success_cell(
provisioned: &ProvisionedApiKey,
dotenv_path: &Path,
live_apply_outcome: LiveApplyOutcome,
) -> PlainHistoryCell {
let organization = provisioned
.organization_title
.clone()
.unwrap_or_else(|| provisioned.organization_id.clone());
let project = provisioned
.default_project_title
.clone()
.unwrap_or_else(|| provisioned.default_project_id.clone());
let hint = match live_apply_outcome {
LiveApplyOutcome::Applied => Some(
"Updated this session to use the newly provisioned API key without touching auth.json."
.to_string(),
),
LiveApplyOutcome::Skipped(reason) => Some(reason),
LiveApplyOutcome::Failed(err) => Some(format!(
"Saved {OPENAI_API_KEY_ENV_VAR} to {}, but could not hot-apply it in this session: {err}",
dotenv_path.display(),
)),
};
history_cell::new_info_event(
format!(
"Provisioned an API key for {organization} / {project} and saved {OPENAI_API_KEY_ENV_VAR} to {}.",
dotenv_path.display()
),
hint,
)
}
enum LiveApplyOutcome {
Applied,
Skipped(String),
Failed(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::HistoryCell;
use insta::assert_snapshot;
#[test]
fn success_cell_snapshot() {
let cell = success_cell(
&ProvisionedApiKey {
sensitive_id: "session-123".to_string(),
organization_id: "org-default".to_string(),
organization_title: Some("Default Org".to_string()),
default_project_id: "proj-default".to_string(),
default_project_title: Some("Default Project".to_string()),
project_api_key: "sk-proj-123".to_string(),
access_token: "oauth-access-123".to_string(),
},
Path::new("/tmp/workspace/.env.local"),
LiveApplyOutcome::Applied,
);
assert_snapshot!(render_cell(&cell));
}
#[test]
fn success_cell_snapshot_when_live_apply_is_skipped() {
let cell = success_cell(
&ProvisionedApiKey {
sensitive_id: "session-123".to_string(),
organization_id: "org-default".to_string(),
organization_title: None,
default_project_id: "proj-default".to_string(),
default_project_title: None,
project_api_key: "sk-proj-123".to_string(),
access_token: "oauth-access-123".to_string(),
},
Path::new("/tmp/workspace/.env.local"),
LiveApplyOutcome::Skipped(
"Saved OPENAI_API_KEY to .env.local, but left this session unchanged because ChatGPT login is required here."
.to_string(),
),
);
assert_snapshot!(render_cell(&cell));
}
#[test]
fn continue_in_browser_message_snapshot() {
let cell = continue_in_browser_message(
"https://auth.openai.com/oauth/authorize?client_id=abc",
/*callback_port*/ 5000,
Path::new("/tmp/workspace/.env.local"),
/*browser_opened*/ false,
);
assert_snapshot!(render_cell(&cell));
}
#[test]
fn existing_shell_api_key_message_mentions_openai_api_key() {
let cell = existing_shell_api_key_message();
assert_eq!(
render_cell(&cell),
"• OPENAI_API_KEY is already set in this Codex session; skipping API provisioning. This Codex session already inherited OPENAI_API_KEY from its shell environment. Unset it and run /api-provision again if you want Codex to provision and save a different key."
);
}
#[test]
fn continue_in_browser_message_always_includes_the_auth_url() {
let cell = continue_in_browser_message(
"https://auth.example.com/oauth/authorize?state=abc",
5000,
Path::new("/tmp/workspace/.env.local"),
/*browser_opened*/ false,
);
assert!(render_cell(&cell).contains("https://auth.example.com/oauth/authorize?state=abc"));
}
fn render_cell(cell: &PlainHistoryCell) -> String {
cell.display_lines(120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n")
}
}

View File

@@ -4716,6 +4716,24 @@ impl ChatWidget {
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::ApiProvision => {
let cwd = self.status_line_cwd().to_path_buf();
match crate::api_provision::start_command(
self.app_event_tx.clone(),
self.auth_manager.clone(),
self.config.codex_home.clone(),
cwd,
self.config.forced_login_method,
) {
Ok(start_message) => {
self.add_boxed_history(Box::new(start_message));
self.request_redraw();
}
Err(err) => {
self.add_error_message(err);
}
}
}
SlashCommand::Copy => {
let Some(text) = self.last_copyable_output.as_deref() else {
self.add_info_message(

View File

@@ -325,7 +325,7 @@ impl ChatWidget {
})
}
fn status_line_cwd(&self) -> &Path {
pub(super) fn status_line_cwd(&self) -> &Path {
self.current_cwd.as_ref().unwrap_or(&self.config.cwd)
}

View File

@@ -61,6 +61,7 @@ use tracing_subscriber::prelude::*;
use uuid::Uuid;
mod additional_dirs;
mod api_provision;
mod app;
mod app_backtrack;
mod app_event;

View File

@@ -34,6 +34,7 @@ pub enum SlashCommand {
Agent,
// Undo,
Diff,
ApiProvision,
Copy,
Mention,
Status,
@@ -82,6 +83,7 @@ impl SlashCommand {
// SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::ApiProvision => "provision an API key and save it to .env.local",
SlashCommand::Copy => "copy the latest Codex output to your clipboard",
SlashCommand::Mention => "mention a file",
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
@@ -155,6 +157,7 @@ impl SlashCommand {
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::Plan
| SlashCommand::ApiProvision
| SlashCommand::Clear
| SlashCommand::Logout
| SlashCommand::MemoryDrop
@@ -220,4 +223,15 @@ mod tests {
fn clean_alias_parses_to_stop_command() {
assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop));
}
#[test]
fn api_provision_command_metadata() {
assert_eq!(
SlashCommand::from_str("api-provision"),
Ok(SlashCommand::ApiProvision)
);
assert_eq!(SlashCommand::ApiProvision.command(), "api-provision");
assert!(!SlashCommand::ApiProvision.supports_inline_args());
assert!(!SlashCommand::ApiProvision.available_during_task());
}
}

View File

@@ -0,0 +1,15 @@
---
source: tui/src/api_provision.rs
expression: render_cell(&cell)
---
• Finish API provisioning via your browser.
Codex couldn't auto-open your browser, but the provisioning flow is still waiting.
Open the following link to authenticate:
https://auth.openai.com/oauth/authorize?client_id=abc
Codex will save OPENAI_API_KEY to /tmp/workspace/.env.local and hot-apply it here when allowed.
On a remote or headless machine, forward localhost:5000 back to this Codex host before opening the link.

View File

@@ -0,0 +1,6 @@
---
source: tui/src/api_provision.rs
assertion_line: 192
expression: render_cell(&cell)
---
• Provisioned an API key for Default Org / Default Project and saved OPENAI_API_KEY to /tmp/workspace/.env.local. Updated this session to use the newly provisioned API key without touching auth.json.

View File

@@ -0,0 +1,6 @@
---
source: tui/src/api_provision.rs
assertion_line: 214
expression: render_cell(&cell)
---
• Provisioned an API key for org-default / proj-default and saved OPENAI_API_KEY to /tmp/workspace/.env.local. Saved OPENAI_API_KEY to .env.local, but left this session unchanged because ChatGPT login is required here.