[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
2026-07-07 19:48:05 +00:00
committed by GitHub
parent ff06ab7172
commit 1fd0858e86
21 changed files with 680 additions and 155 deletions
@@ -1463,6 +1463,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -1472,6 +1483,9 @@
],
"title": "ChatgptLoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -1532,6 +1546,13 @@
}
]
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"MarketplaceAddParams": {
"properties": {
"refName": {
@@ -11504,6 +11504,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/v2/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -11513,6 +11524,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -11667,6 +11681,13 @@
],
"title": "LoginAccountResponse"
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"LogoutAccountResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LogoutAccountResponse",
@@ -7908,6 +7908,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -7917,6 +7928,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -8071,6 +8085,13 @@
],
"title": "LoginAccountResponse"
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"LogoutAccountResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LogoutAccountResponse",
@@ -1,5 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
}
},
"oneOf": [
{
"properties": {
@@ -23,6 +32,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -32,6 +52,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -1,8 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LoginAppBrand } from "./LoginAppBrand";
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, useHostedLoginSuccessPage?: boolean, appBrand?: LoginAppBrand | null, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
/**
* Access token (JWT) supplied by the client.
* This token is used for backend API requests and email extraction.
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LoginAppBrand = "codex" | "chatgpt";
@@ -194,6 +194,7 @@ export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams";
export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse";
export type { LoginAccountParams } from "./LoginAccountParams";
export type { LoginAccountResponse } from "./LoginAccountResponse";
export type { LoginAppBrand } from "./LoginAppBrand";
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
export type { ManagedHooksRequirements } from "./ManagedHooksRequirements";
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
@@ -2721,7 +2721,9 @@ mod tests {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
};
assert_eq!(
@@ -2729,7 +2731,8 @@ mod tests {
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt"
"type": "chatgpt",
"appBrand": null
}
}),
serde_json::to_value(&request)?,
@@ -2742,7 +2745,9 @@ mod tests {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: true,
use_hosted_login_success_page: false,
},
};
assert_eq!(
@@ -2751,6 +2756,7 @@ mod tests {
"id": 3,
"params": {
"type": "chatgpt",
"appBrand": null,
"codexStreamlinedLogin": true
}
}),
@@ -2759,6 +2765,32 @@ mod tests {
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_with_hosted_success_page() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: Some(v2::LoginAppBrand::Chatgpt),
codex_streamlined_login: true,
use_hosted_login_success_page: true,
},
};
assert_eq!(
json!({
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt",
"appBrand": "chatgpt",
"codexStreamlinedLogin": true,
"useHostedLoginSuccessPage": true
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_device_code() -> Result<()> {
let request = ClientRequest::LoginAccount {
@@ -78,6 +78,11 @@ pub enum LoginAccountParams {
Chatgpt {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
codex_streamlined_login: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
use_hosted_login_success_page: bool,
#[serde(default)]
#[ts(optional = nullable)]
app_brand: Option<LoginAppBrand>,
},
#[serde(rename = "chatgptDeviceCode")]
#[ts(rename = "chatgptDeviceCode")]
@@ -102,6 +107,16 @@ pub enum LoginAccountParams {
},
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum LoginAppBrand {
#[default]
Codex,
Chatgpt,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
@@ -1722,7 +1722,9 @@ impl CodexClient {
let request = ClientRequest::LoginAccount {
request_id: request_id.clone(),
params: codex_app_server_protocol::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
};
+5
View File
@@ -1995,6 +1995,11 @@ Field notes:
{ "id": 3, "result": { "type": "chatgpt", "loginId": "<uuid>", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A<port>%2Fauth%2Fcallback" } }
```
2. Open `authUrl` in a browser; the app-server hosts the local callback.
By default, a successful callback redirects to the local success page. Clients may set
`useHostedLoginSuccessPage: true` to redirect successful callbacks that do not require
organization setup to the hosted Codex success page instead. When hosted login success is
enabled, clients may set `appBrand` to `"codex"` or `"chatgpt"` to select the matching hosted
page artwork; omitted or `null` values default to `"codex"`.
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
@@ -94,6 +94,7 @@ use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::LoginAppBrand;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
@@ -379,7 +380,10 @@ use codex_feedback::FeedbackUploadOptions;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManager;
use codex_login::CODEX_OPEN_APP_URL;
use codex_login::CodexAuth;
use codex_login::LoginSuccessPage;
use codex_login::LoginSuccessPageBrand;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
@@ -9,9 +9,11 @@ const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
Duration::from_millis(/*millis*/ 1000);
// The override is intentionally available only in debug builds, matching the login path below.
// Login overrides are intentionally available only in debug builds.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
#[cfg(debug_assertions)]
const LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL";
enum ActiveLogin {
Browser {
@@ -253,9 +255,25 @@ impl AccountRequestProcessor {
.await;
}
LoginAccountParams::Chatgpt {
app_brand,
codex_streamlined_login,
use_hosted_login_success_page,
} => {
self.login_chatgpt_v2(request_id, codex_streamlined_login)
let login_success_page = if use_hosted_login_success_page {
let app_brand = match app_brand.unwrap_or_default() {
LoginAppBrand::Codex => LoginSuccessPageBrand::Codex,
LoginAppBrand::Chatgpt => LoginSuccessPageBrand::Chatgpt,
};
LoginSuccessPage::Hosted {
url: CODEX_OPEN_APP_URL.parse().map_err(|err| {
internal_error(format!("invalid Codex open app URL: {err}"))
})?,
app_brand,
}
} else {
LoginSuccessPage::default()
};
self.login_chatgpt_v2(request_id, codex_streamlined_login, login_success_page)
.await;
}
LoginAccountParams::ChatgptDeviceCode => {
@@ -341,6 +359,7 @@ impl AccountRequestProcessor {
async fn login_chatgpt_common(
&self,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
let config = self.config.as_ref();
@@ -357,6 +376,7 @@ impl AccountRequestProcessor {
let opts = LoginServerOptions {
open_browser: false,
codex_streamlined_login,
login_success_page,
..LoginServerOptions::new(
config.codex_home.to_path_buf(),
oauth_client_id(),
@@ -374,6 +394,14 @@ impl AccountRequestProcessor {
{
opts.issuer = issuer;
}
if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page
&& let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR)
&& !open_app_url.trim().is_empty()
{
*url = open_app_url
.parse()
.map_err(|err| internal_error(format!("invalid Codex open app URL: {err}")))?;
}
opts
};
@@ -393,16 +421,22 @@ impl AccountRequestProcessor {
&self,
request_id: ConnectionRequestId,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) {
let result = self.login_chatgpt_response(codex_streamlined_login).await;
let result = self
.login_chatgpt_response(codex_streamlined_login, login_success_page)
.await;
self.outgoing.send_result(request_id, result).await;
}
async fn login_chatgpt_response(
&self,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common(codex_streamlined_login).await?;
let opts = self
.login_chatgpt_common(codex_streamlined_login, login_success_page)
.await?;
let server = run_login_server(opts)
.map_err(|err| internal_error(format!("failed to start login server: {err}")))?;
let login_id = Uuid::new_v4();
@@ -474,7 +508,10 @@ impl AccountRequestProcessor {
&self,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self
.login_chatgpt_common(/*codex_streamlined_login*/ false)
.login_chatgpt_common(
/*codex_streamlined_login*/ false,
LoginSuccessPage::default(),
)
.await?;
let device_code = request_device_code(&opts)
.await
@@ -553,8 +553,8 @@ impl TestAppServer {
chatgpt_account_id,
chatgpt_plan_type,
};
let params = Some(serde_json::to_value(params)?);
self.send_request("account/login/start", params).await
self.send_login_account_request(serde_json::to_value(params)?)
.await
}
/// Send a `feedback/upload` JSON-RPC request.
@@ -1420,6 +1420,14 @@ impl TestAppServer {
self.send_request("account/logout", /*params*/ None).await
}
/// Send an `account/login/start` JSON-RPC request.
pub async fn send_login_account_request(
&mut self,
params: serde_json::Value,
) -> anyhow::Result<i64> {
self.send_request("account/login/start", Some(params)).await
}
/// Send an `account/login/start` JSON-RPC request for API key login.
pub async fn send_login_account_api_key_request(
&mut self,
@@ -1429,7 +1437,7 @@ impl TestAppServer {
"type": "apiKey",
"apiKey": api_key,
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/start` JSON-RPC request for ChatGPT login.
@@ -1437,7 +1445,7 @@ impl TestAppServer {
let params = serde_json::json!({
"type": "chatgpt"
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/start` JSON-RPC request for ChatGPT device code login.
@@ -1445,7 +1453,7 @@ impl TestAppServer {
let params = serde_json::json!({
"type": "chatgptDeviceCode"
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/cancel` JSON-RPC request.
+74 -2
View File
@@ -57,6 +57,7 @@ use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
const LOGIN_OPEN_APP_URL_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL";
const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000";
const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001";
const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002";
@@ -181,7 +182,7 @@ async fn mock_device_code_token_failure(server: &MockServer, status: u16) {
.await;
}
async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) {
async fn mock_oauth_token(server: &MockServer, id_token: &str) {
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
@@ -1097,7 +1098,7 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()>
.plan_type("pro")
.chatgpt_account_id(WORKSPACE_ID_DEVICE),
)?;
mock_device_code_oauth_token(&mock_server, &id_token).await;
mock_oauth_token(&mock_server, &id_token).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
@@ -1436,6 +1437,77 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> {
Ok(())
}
#[tokio::test]
// Serialize tests that launch the login server since it binds to a fixed port.
#[serial(login_port)]
async fn login_account_chatgpt_redirects_to_hosted_success_page() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mock_server = MockServer::start().await;
let id_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("hosted@example.com")
.plan_type("pro")
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
)?;
mock_oauth_token(&mock_server, &id_token).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
(
LOGIN_OPEN_APP_URL_ENV_VAR,
Some("http://localhost:3000/codex/open-app"),
),
],
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_login_account_request(json!({
"type": "chatgpt",
"appBrand": "chatgpt",
"useHostedLoginSuccessPage": true,
}))
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let login: LoginAccountResponse = to_response(resp)?;
let LoginAccountResponse::Chatgpt { auth_url, .. } = login else {
bail!("unexpected login response: {login:?}");
};
let auth_url = Url::parse(&auth_url)?;
let callback_url = auth_url
.query_pairs()
.find_map(|(key, value)| (key == "redirect_uri").then(|| value.into_owned()))
.ok_or_else(|| anyhow::anyhow!("missing redirect_uri"))?;
let state = auth_url
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.ok_or_else(|| anyhow::anyhow!("missing state"))?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let response = client
.get(format!("{callback_url}?code=test-code&state={state}"))
.send()
.await?;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str()?,
"http://localhost:3000/codex/open-app?source=login&app_brand=chatgpt"
);
Ok(())
}
#[tokio::test]
// Serialize tests that launch the login server since it binds to a fixed port.
#[serial(login_port)]
+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())
);
}
+66 -3
View File
@@ -10,6 +10,8 @@ use anyhow::Result;
use base64::Engine;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::LoginSuccessPage;
use codex_login::LoginSuccessPageBrand;
use codex_login::ServerOptions;
use codex_login::run_login_server;
use core_test_support::skip_if_no_network;
@@ -131,6 +133,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]),
codex_streamlined_login: false,
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
login_success_page: LoginSuccessPage::Local,
};
let server = run_login_server(opts)?;
assert!(
@@ -141,13 +144,20 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
);
let login_port = server.actual_port;
// Simulate browser callback, and follow redirect to /success
// Simulate browser callback and assert the local success redirect before following it.
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(5))
.redirect(reqwest::redirect::Policy::none())
.build()?;
let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=test_state_123");
let resp = client.get(&url).send().await?;
assert!(resp.status().is_success());
assert_eq!(resp.status(), 302);
let success_url = resp.headers()["location"].to_str()?;
let success_url = Url::parse(success_url)?;
assert_eq!(success_url.host_str(), Some("localhost"));
assert_eq!(success_url.path(), "/success");
let success_resp = client.get(success_url).send().await?;
assert!(success_resp.status().is_success());
// Wait for server shutdown
server.block_until_done().await?;
@@ -169,6 +179,52 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn hosted_login_redirects_to_configured_open_app_url() -> Result<()> {
skip_if_no_network!(Ok(()));
let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED);
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let tmp = tempdir()?;
let server = run_login_server(ServerOptions {
codex_home: tmp.path().to_path_buf(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
open_browser: false,
force_state: Some("streamlined_state".to_string()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
login_success_page: LoginSuccessPage::Hosted {
url: Url::parse("http://localhost:3000/codex/open-app?source=old")?,
app_brand: LoginSuccessPageBrand::Chatgpt,
},
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
})?;
let login_port = server.actual_port;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let response = client
.get(format!(
"http://127.0.0.1:{login_port}/auth/callback?code=abc&state=streamlined_state"
))
.send()
.await?;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str()?,
"http://localhost:3000/codex/open-app?source=login&app_brand=chatgpt"
);
tokio::time::timeout(Duration::from_secs(1), server.block_until_done()).await??;
Ok(())
}
#[tokio::test]
async fn creates_missing_codex_home_dir() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -195,6 +251,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> {
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
login_success_page: LoginSuccessPage::Local,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -240,6 +297,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result<
]),
codex_streamlined_login: false,
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
login_success_page: LoginSuccessPage::Local,
};
let server = run_login_server(opts)?;
let auth_url = Url::parse(&server.auth_url)?;
@@ -280,6 +338,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
codex_streamlined_login: false,
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
login_success_page: LoginSuccessPage::Local,
};
let server = run_login_server(opts)?;
assert!(
@@ -342,6 +401,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error()
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
login_success_page: LoginSuccessPage::Local,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -412,6 +472,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
login_success_page: LoginSuccessPage::Local,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -561,6 +622,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
login_success_page: LoginSuccessPage::Local,
};
let first_server = run_login_server(first_opts)?;
@@ -584,6 +646,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
auth_keyring_backend_kind: AuthKeyringBackendKind::Direct,
login_success_page: LoginSuccessPage::Local,
};
let second_server = run_login_server(second_opts)?;
+2
View File
@@ -874,7 +874,9 @@ impl AuthModeWidget {
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
})
.await