[login] support hosted success redirects (#28745)

## What

Adds an optional hosted login-success redirect path for app-server login
requests.

- Keeps the existing localhost success page as the default.
- Lets app-server callers opt into a hosted success page with an
optional protocol field.
- Persists credentials before redirecting to the hosted success page.
- Keeps org setup and existing CLI/device-code login flows on the local
success page.
- Accepts an optional typed `appBrand` value and forwards it to the
hosted page as `app_brand` so web can select the correct asset.
- Generates the app-server protocol schema updates for the new optional
fields.

## Why

This supports the hosted Codex login success page rollout without
changing existing login behavior by default. The Codex Apps frontend can
gate the opt-in with Statsig after the hosted web page.

## Rollout safety

- Old callers omit the new field and continue using localhost.
- New callers talking to old app-server builds remain safe because the
Codex Apps side treats the field as optional and defaults the flag off.
- Missing brand values default to Codex.
- The hosted redirect always uses the app-login source so the hosted
page can reopen Codex; the existing streamlined-login visual flag
remains separate.

## Validation

- `just fmt`
- `just fix -p codex-login -p codex-app-server-protocol -p
codex-app-server -p codex-app-server-test-client -p codex-tui`
- `just test -p codex-login`
- `just test -p codex-app-server-protocol`
- `just write-app-server-schema`
- `git diff HEAD --check`

The focused login and protocol run passed all 380 tests.

I also started the broader `just test -p codex-app-server`; it compiled
successfully, then many tests failed on this machine because spawned
test servers tried to use the ambient `/Users/rafaelj/.codex/sqlite`
state DB, which is read-only in this sandbox. I stopped that run after
confirming the failures shared that environment issue.
This commit is contained in:
rafael-jac authored and GitHub committed 2026-07-07 19:48:05 +00:00
1 parent ff06ab7172
commit 1fd0858e86
21 files changed
+680 -155

No files matched your search

+4
View File
@@ -6,6 +6,7 @@ mod device_code_auth;
mod outbound_proxy;
mod pkce;
mod server;
mod success_page;
pub use codex_config::types::AuthCredentialsStoreMode;
pub use codex_http_client::BuildCustomCaTransportError as BuildLoginHttpClientError;
@@ -17,6 +18,9 @@ pub use server::LoginServer;
pub use server::ServerOptions;
pub use server::ShutdownHandle;
pub use server::run_login_server;
pub use success_page::CODEX_OPEN_APP_URL;
pub use success_page::LoginSuccessPage;
pub use success_page::LoginSuccessPageBrand;
pub use auth::AgentIdentityAuthPolicy;
pub use auth::AuthConfig;
+63 -138
View File
@@ -32,6 +32,10 @@ use crate::default_client::originator;
use crate::outbound_proxy::AuthRouteConfig;
use crate::pkce::PkceCodes;
use crate::pkce::generate_pkce;
use crate::success_page::LoginSuccessPage;
use crate::success_page::LoginSuccessRedirect;
use crate::success_page::compose_success_url;
use crate::success_page::jwt_auth_claims;
use crate::token_data::TokenData;
use crate::token_data::parse_chatgpt_jwt_claims;
use base64::Engine;
@@ -50,7 +54,7 @@ use tracing::error;
use tracing::info;
use tracing::warn;
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
pub(super) const DEFAULT_ISSUER: &str = "https://auth.openai.com";
const DEFAULT_PORT: u16 = 1455;
// Keep in sync with the Codex CLI Hydra redirect URI allow-list.
const FALLBACK_PORT: u16 = 1457;
@@ -70,6 +74,7 @@ pub struct ServerOptions {
pub force_state: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
pub codex_streamlined_login: bool,
pub login_success_page: LoginSuccessPage,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub auth_keyring_backend_kind: AuthKeyringBackendKind,
pub auth_route_config: Option<AuthRouteConfig>,
@@ -94,6 +99,7 @@ impl ServerOptions {
force_state: None,
forced_chatgpt_workspace_id,
codex_streamlined_login: false,
login_success_page: LoginSuccessPage::default(),
cli_auth_credentials_store_mode,
auth_keyring_backend_kind,
auth_route_config,
@@ -207,28 +213,62 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let url_raw = req.url().to_string();
let response =
process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await;
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::RedirectWithHeader(header) => {
let redirect = Response::empty(302).with_header(header);
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
None
}
HandledRequest::ResponseAndExit {
headers,
body,
result,
} => {
let _ = tokio::task::spawn_blocking(move || {
send_response_with_disconnect(req, headers, body)
send_response_with_disconnect(
req,
StatusCode(200),
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
HandledRequest::RedirectAndExit(header) => {
match tokio::task::spawn_blocking(move || {
send_response_with_disconnect(
req,
StatusCode(302),
vec![header],
Vec::new(),
)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!("failed to send hosted login redirect: {err}");
}
Err(err) => {
warn!("hosted login redirect task failed: {err}");
}
}
Some(Ok(()))
}
};
@@ -258,6 +298,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
enum HandledRequest {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
RedirectAndExit(Header),
ResponseAndExit {
headers: Vec<Header>,
body: Vec<u8>,
@@ -392,15 +433,26 @@ async fn process_request(
);
}
let success_url = compose_success_url(
let redirect = compose_success_url(
actual_port,
&opts.issuer,
&tokens.id_token,
&tokens.access_token,
opts.codex_streamlined_login,
&opts.login_success_page,
);
match tiny_http::Header::from_bytes(&b"Location"[..], success_url.as_bytes()) {
Ok(header) => HandledRequest::RedirectWithHeader(header),
let url = match &redirect {
LoginSuccessRedirect::Local(url) | LoginSuccessRedirect::Hosted(url) => url,
};
match tiny_http::Header::from_bytes(&b"Location"[..], url.as_bytes()) {
Ok(header) => match redirect {
LoginSuccessRedirect::Local(_) => {
HandledRequest::RedirectWithHeader(header)
}
LoginSuccessRedirect::Hosted(_) => {
HandledRequest::RedirectAndExit(header)
}
},
Err(_) => login_error_response(
"Sign-in completed but redirecting back to Codex failed.",
io::ErrorKind::Other,
@@ -465,10 +517,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)?;
@@ -850,94 +902,6 @@ pub(crate) async fn persist_tokens_async(
.map_err(|e| io::Error::other(format!("persist task failed: {e}")))?
}
fn compose_success_url(
port: u16,
issuer: &str,
id_token: &str,
access_token: &str,
codex_streamlined_login: bool,
) -> String {
let token_claims = jwt_auth_claims(id_token);
let access_claims = jwt_auth_claims(access_token);
let org_id = token_claims
.get("organization_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let project_id = token_claims
.get("project_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let completed_onboarding = token_claims
.get("completed_platform_onboarding")
.and_then(JsonValue::as_bool)
.unwrap_or(false);
let is_org_owner = token_claims
.get("is_org_owner")
.and_then(JsonValue::as_bool)
.unwrap_or(false);
let needs_setup = (!completed_onboarding) && is_org_owner;
let plan_type = access_claims
.get("chatgpt_plan_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let platform_url = if issuer == DEFAULT_ISSUER {
"https://platform.openai.com"
} else {
"https://platform.api.openai.org"
};
let mut params = vec![
("id_token", id_token.to_string()),
("needs_setup", needs_setup.to_string()),
("org_id", org_id.to_string()),
("project_id", project_id.to_string()),
("plan_type", plan_type.to_string()),
("platform_url", platform_url.to_string()),
];
if codex_streamlined_login {
params.push(("codex_streamlined_login", "true".to_string()));
}
let qs = params
.drain(..)
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
.collect::<Vec<_>>()
.join("&");
format!("http://localhost:{port}/success?{qs}")
}
fn jwt_auth_claims(jwt: &str) -> serde_json::Map<String, serde_json::Value> {
let mut parts = jwt.split('.');
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => {
eprintln!("Invalid JWT format while extracting claims");
return serde_json::Map::new();
}
};
match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) {
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(mut v) => {
if let Some(obj) = v
.get_mut("https://api.openai.com/auth")
.and_then(|x| x.as_object_mut())
{
return obj.clone();
}
eprintln!("JWT payload missing expected 'https://api.openai.com/auth' object");
}
Err(e) => {
eprintln!("Failed to parse JWT JSON payload: {e}");
}
},
Err(e) => {
eprintln!("Failed to base64url-decode JWT payload: {e}");
}
}
serde_json::Map::new()
}
/// Validates the ID token against an optional workspace restriction.
pub(crate) fn ensure_workspace_allowed(
expected: Option<&[String]>,
@@ -1184,9 +1148,7 @@ pub(crate) async fn obtain_api_key(
mod tests {
use pretty_assertions::assert_eq;
use super::DEFAULT_ISSUER;
use super::TokenEndpointErrorDetail;
use super::compose_success_url;
use super::html_escape;
use super::is_missing_codex_entitlement_error;
use super::parse_token_endpoint_error;
@@ -1293,43 +1255,6 @@ mod tests {
);
}
#[test]
fn compose_success_url_omits_streamlined_success_by_default() {
let url = url::Url::parse(&compose_success_url(
/*port*/ 1455,
DEFAULT_ISSUER,
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
/*codex_streamlined_login*/ false,
))
.expect("success url should parse");
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "codex_streamlined_login"),
None
);
}
#[test]
fn compose_success_url_includes_streamlined_success_when_requested() {
let url = url::Url::parse(&compose_success_url(
/*port*/ 1455,
DEFAULT_ISSUER,
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
/*codex_streamlined_login*/ true,
))
.expect("success url should parse");
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "codex_streamlined_login")
.map(|(_, value)| value.into_owned()),
Some("true".to_string())
);
}
#[test]
fn render_login_error_page_escapes_dynamic_fields() {
let body = String::from_utf8(render_login_error_page(
+143
View File
@@ -0,0 +1,143 @@
use base64::Engine;
use serde_json::Value as JsonValue;
use url::Url;
use crate::server::DEFAULT_ISSUER;
pub const CODEX_OPEN_APP_URL: &str = "https://chatgpt.com/codex/open-app";
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub enum LoginSuccessPage {
#[default]
Local,
Hosted {
url: Url,
app_brand: LoginSuccessPageBrand,
},
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum LoginSuccessPageBrand {
Codex,
Chatgpt,
}
impl LoginSuccessPageBrand {
fn as_str(self) -> &'static str {
match self {
Self::Codex => "codex",
Self::Chatgpt => "chatgpt",
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum LoginSuccessRedirect {
Local(String),
Hosted(String),
}
pub(crate) fn compose_success_url(
port: u16,
issuer: &str,
id_token: &str,
access_token: &str,
codex_streamlined_login: bool,
login_success_page: &LoginSuccessPage,
) -> LoginSuccessRedirect {
let token_claims = jwt_auth_claims(id_token);
let org_id = token_claims
.get("organization_id")
.and_then(|value| value.as_str())
.unwrap_or("");
let project_id = token_claims
.get("project_id")
.and_then(|value| value.as_str())
.unwrap_or("");
let completed_onboarding = token_claims
.get("completed_platform_onboarding")
.and_then(JsonValue::as_bool)
.unwrap_or(false);
let is_org_owner = token_claims
.get("is_org_owner")
.and_then(JsonValue::as_bool)
.unwrap_or(false);
let needs_setup = !completed_onboarding && is_org_owner;
if !needs_setup && let LoginSuccessPage::Hosted { url, app_brand } = login_success_page {
let mut success_url = url.clone();
success_url.set_query(None);
success_url
.query_pairs_mut()
.append_pair("source", "login")
.append_pair("app_brand", app_brand.as_str());
return LoginSuccessRedirect::Hosted(success_url.into());
}
let access_claims = jwt_auth_claims(access_token);
let plan_type = access_claims
.get("chatgpt_plan_type")
.and_then(|value| value.as_str())
.unwrap_or("");
let platform_url = if issuer == DEFAULT_ISSUER {
"https://platform.openai.com"
} else {
"https://platform.api.openai.org"
};
let mut params = vec![
("id_token", id_token.to_string()),
("needs_setup", needs_setup.to_string()),
("org_id", org_id.to_string()),
("project_id", project_id.to_string()),
("plan_type", plan_type.to_string()),
("platform_url", platform_url.to_string()),
];
if codex_streamlined_login {
params.push(("codex_streamlined_login", "true".to_string()));
}
let query = params
.into_iter()
.map(|(key, value)| format!("{key}={}", urlencoding::encode(&value)))
.collect::<Vec<_>>()
.join("&");
LoginSuccessRedirect::Local(format!("http://localhost:{port}/success?{query}"))
}
pub(crate) fn jwt_auth_claims(jwt: &str) -> serde_json::Map<String, serde_json::Value> {
let mut parts = jwt.split('.');
let (_header, payload, _signature) = match (parts.next(), parts.next(), parts.next()) {
(Some(header), Some(payload), Some(signature))
if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
{
(header, payload, signature)
}
_ => {
eprintln!("Invalid JWT format while extracting claims");
return serde_json::Map::new();
}
};
match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) {
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(mut value) => {
if let Some(claims) = value
.get_mut("https://api.openai.com/auth")
.and_then(JsonValue::as_object_mut)
{
return claims.clone();
}
eprintln!("JWT payload missing expected 'https://api.openai.com/auth' object");
}
Err(error) => {
eprintln!("Failed to parse JWT JSON payload: {error}");
}
},
Err(error) => {
eprintln!("Failed to base64url-decode JWT payload: {error}");
}
}
serde_json::Map::new()
}
#[cfg(test)]
#[path = "success_page_tests.rs"]
mod tests;
+120
View File
@@ -0,0 +1,120 @@
use base64::Engine;
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn compose_success_url_uses_local_page_by_default() {
let LoginSuccessRedirect::Local(url) = compose_success_url(
/*port*/ 1455,
DEFAULT_ISSUER,
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
/*codex_streamlined_login*/ false,
&LoginSuccessPage::default(),
) else {
panic!("expected local success redirect");
};
let url = Url::parse(&url).expect("success URL should parse");
assert_eq!(url.host_str(), Some("localhost"));
assert_eq!(url.path(), "/success");
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "codex_streamlined_login"),
None
);
}
#[test]
fn compose_success_url_uses_streamlined_local_page_when_requested() {
let LoginSuccessRedirect::Local(url) = compose_success_url(
/*port*/ 1455,
DEFAULT_ISSUER,
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
/*codex_streamlined_login*/ true,
&LoginSuccessPage::default(),
) else {
panic!("expected local success redirect");
};
let url = Url::parse(&url).expect("success URL should parse");
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "codex_streamlined_login")
.map(|(_, value)| value.into_owned()),
Some("true".to_string())
);
}
#[test]
fn compose_success_url_uses_hosted_page_when_requested() {
assert_eq!(
compose_success_url(
/*port*/ 1455,
DEFAULT_ISSUER,
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
/*codex_streamlined_login*/ false,
&LoginSuccessPage::Hosted {
url: Url::parse(CODEX_OPEN_APP_URL).expect("open app URL should parse"),
app_brand: LoginSuccessPageBrand::Chatgpt,
},
),
LoginSuccessRedirect::Hosted(
"https://chatgpt.com/codex/open-app?source=login&app_brand=chatgpt".to_string()
)
);
}
#[test]
fn compose_success_url_keeps_setup_on_local_page() {
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let payload = encode(
serde_json::to_string(&json!({
"https://api.openai.com/auth": {
"completed_platform_onboarding": false,
"is_org_owner": true,
"organization_id": "org_123",
"project_id": "proj_123",
}
}))
.expect("payload should serialize")
.as_bytes(),
);
let access_payload = encode(
serde_json::to_string(&json!({
"https://api.openai.com/auth": {
"chatgpt_plan_type": "team",
}
}))
.expect("payload should serialize")
.as_bytes(),
);
let id_token = format!("e30.{payload}.sig");
let LoginSuccessRedirect::Local(url) = compose_success_url(
/*port*/ 1455,
DEFAULT_ISSUER,
&id_token,
&format!("e30.{access_payload}.sig"),
/*codex_streamlined_login*/ true,
&LoginSuccessPage::Hosted {
url: Url::parse(CODEX_OPEN_APP_URL).expect("open app URL should parse"),
app_brand: LoginSuccessPageBrand::Codex,
},
) else {
panic!("expected local success redirect");
};
let url = Url::parse(&url).expect("success URL should parse");
assert_eq!(url.host_str(), Some("localhost"));
assert_eq!(url.path(), "/success");
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "needs_setup")
.map(|(_, value)| value.into_owned()),
Some("true".to_string())
);
}