From 1fd0858e865d213106bba14251d06a9b9343ca37 Mon Sep 17 00:00:00 2001 From: rafael-jac Date: Tue, 7 Jul 2026 15:48:05 -0400 Subject: [PATCH] [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. --- .../schema/json/ClientRequest.json | 21 ++ .../codex_app_server_protocol.schemas.json | 21 ++ .../codex_app_server_protocol.v2.schemas.json | 21 ++ .../schema/json/v2/LoginAccountParams.json | 23 ++ .../typescript/v2/LoginAccountParams.ts | 3 +- .../schema/typescript/v2/LoginAppBrand.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 34 ++- .../src/protocol/v2/account.rs | 15 ++ codex-rs/app-server-test-client/src/lib.rs | 2 + codex-rs/app-server/README.md | 5 + codex-rs/app-server/src/request_processors.rs | 4 + .../request_processors/account_processor.rs | 47 +++- .../tests/common/test_app_server.rs | 18 +- codex-rs/app-server/tests/suite/v2/account.rs | 76 ++++++- codex-rs/login/src/lib.rs | 4 + codex-rs/login/src/server.rs | 201 ++++++------------ codex-rs/login/src/success_page.rs | 143 +++++++++++++ codex-rs/login/src/success_page_tests.rs | 120 +++++++++++ .../login/tests/suite/login_server_e2e.rs | 69 +++++- codex-rs/tui/src/onboarding/auth.rs | 2 + 21 files changed, 680 insertions(+), 155 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts create mode 100644 codex-rs/login/src/success_page.rs create mode 100644 codex-rs/login/src/success_page_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 2f39f821b6..d391cac165 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 53bd7cef4e..1102135a71 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 11540d40c7..29dfe78034 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json index ab7b852c91..e6c82237d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json @@ -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": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts index e6f1e2ed43..1606f167fa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts @@ -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. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts new file mode 100644 index 0000000000..c06d12ffff --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 00d39b31e3..070d21eab8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c4b94d8b3f..e693e8d451 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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 { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 788c8f7aaf..44551e400d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -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, }, #[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")] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index a70945c468..0e32365d12 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -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, }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d52f1bd168..6ee6baf2dc 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1995,6 +1995,11 @@ Field notes: { "id": 3, "result": { "type": "chatgpt", "loginId": "", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A%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": "", "success": true, "error": null } } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f03351468f..896a166d84 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -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; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 9592563bec..2b2c018520 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -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 { 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 { - 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 { 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 diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 0fc0594baa..8605bc1a38 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -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 { + 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. diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 3cd98e2e52..f0a7b1d087 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -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)] diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 8a08ddeadb..c5a3c1e28b 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -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; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 607e47fefe..01671bf0b1 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -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, pub forced_chatgpt_workspace_id: Option>, 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, @@ -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 { 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 { enum HandledRequest { Response(Response>>), RedirectWithHeader(Header), + RedirectAndExit(Header), ResponseAndExit { headers: Vec
, body: Vec, @@ -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
, body: Vec, ) -> 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::>() - .join("&"); - format!("http://localhost:{port}/success?{qs}") -} - -fn jwt_auth_claims(jwt: &str) -> serde_json::Map { - 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::(&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( diff --git a/codex-rs/login/src/success_page.rs b/codex-rs/login/src/success_page.rs new file mode 100644 index 0000000000..4c7983e63c --- /dev/null +++ b/codex-rs/login/src/success_page.rs @@ -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::>() + .join("&"); + LoginSuccessRedirect::Local(format!("http://localhost:{port}/success?{query}")) +} + +pub(crate) fn jwt_auth_claims(jwt: &str) -> serde_json::Map { + 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::(&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; diff --git a/codex-rs/login/src/success_page_tests.rs b/codex-rs/login/src/success_page_tests.rs new file mode 100644 index 0000000000..5ccfc9e563 --- /dev/null +++ b/codex-rs/login/src/success_page_tests.rs @@ -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()) + ); +} diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 6f7b4e4f83..dbcd396ef1 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -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)?; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index f9392eb2e8..c5c4ef442d 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -874,7 +874,9 @@ impl AuthModeWidget { .request_typed::(ClientRequest::LoginAccount { request_id: onboarding_request_id(), params: LoginAccountParams::Chatgpt { + app_brand: None, codex_streamlined_login: false, + use_hosted_login_success_page: false, }, }) .await