mirror of
https://github.com/openai/codex.git
synced 2026-10-21 03:32:33 +00:00
[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:
@@ -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": {
|
||||
|
||||
+21
@@ -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",
|
||||
|
||||
+21
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user