mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +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:
1 parent
ff06ab7172
commit
1fd0858e86
21 files changed
+680
-155
No files matched your search
@@ -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)?;
|
||||
|
||||
Reference in new issue
Block a user