CODEX-4927: Surface local login entitlement denials in browser (#12289)
## Problem Users without Codex access can hit a confusing local login loop. In the denial case, the callback could fall through to generic behavior (including a plain "Missing authorization code" page) instead of clearly explaining that access was denied. <img width="842" height="464" alt="Screenshot 2026-02-19 at 11 43 45 PM" src="https://github.com/user-attachments/assets/f7a25e1d-e480-4ac2-b0ff-8bfe31003e66" /> <img width="842" height="464" alt="Screenshot 2026-02-19 at 11 44 53 PM" src="https://github.com/user-attachments/assets/8a4fe6e4-b27b-483c-9f0c-60164933221d" /> ## Scope This PR improves local login error clarity only. It does not change entitlement policy, RBAC rules, or who is allowed to use Codex. ## What Changed - The local OAuth callback handler now parses `error` and `error_description` on `/auth/callback` and exits the callback loop with a real failure. - Callback failures render a branded local Codex error page instead of a generic/plain page. - `access_denied` + `missing_codex_entitlement` is now mapped to an explicit user-facing message telling the user Codex is not enabled for their workspace and to contact their workspace administrator for access. - Unknown OAuth callback errors continue to use a generic error page while preserving the OAuth error code/details for debugging. - Added the login error page template to Bazel assets so the local binary can render it in Bazel builds. ## Non-goals - No TUI onboarding/toast changes in this PR. - No backend entitlement or policy changes. ## Tests - Added an end-to-end `codex-login` test for `access_denied` + `missing_codex_entitlement` and verified the page shows the actionable admin guidance. - Added an end-to-end `codex-login` test for a generic `access_denied` reason to verify we keep a generic fallback page/message.
This commit is contained in:
parent
097620218d
commit
f08cf8d65f
4 changed files with 450 additions and 24 deletions
|
|
@ -3,5 +3,8 @@ load("//:defs.bzl", "codex_rust_crate")
|
|||
codex_rust_crate(
|
||||
name = "login",
|
||||
crate_name = "codex_login",
|
||||
compile_data = ["src/assets/success.html"],
|
||||
compile_data = [
|
||||
"src/assets/error.html",
|
||||
"src/assets/success.html",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
122
codex-rs/login/src/assets/error.html
Normal file
122
codex-rs/login/src/assets/error.html
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Codex Sign-in Error</title>
|
||||
<link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
background: radial-gradient(circle at top, #f7f8fb 0%, #ffffff 48%);
|
||||
color: #0d0d0d;
|
||||
}
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
width: min(680px, 100%);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(13, 13, 13, 0.12);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06);
|
||||
background: #ffffff;
|
||||
padding: 24px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.brand-title {
|
||||
font-size: 14px;
|
||||
color: #5d5d5d;
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.message {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.details {
|
||||
margin-top: 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(13, 13, 13, 0.1);
|
||||
background: #fafafa;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.details-row {
|
||||
display: grid;
|
||||
grid-template-columns: 136px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.details-row strong {
|
||||
color: #5d5d5d;
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
.help {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #5d5d5d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<div class="logo" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
|
||||
</div>
|
||||
<div class="brand-title">Codex login</div>
|
||||
</div>
|
||||
|
||||
<h1>__ERROR_TITLE__</h1>
|
||||
<p class="message">__ERROR_MESSAGE__</p>
|
||||
|
||||
<div class="details">
|
||||
<div class="details-row">
|
||||
<strong>Error code</strong>
|
||||
<code>__ERROR_CODE__</code>
|
||||
</div>
|
||||
<div class="details-row">
|
||||
<strong>Details</strong>
|
||||
<code>__ERROR_DESCRIPTION__</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="help">__ERROR_HELP__</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
//! Local OAuth callback server for CLI login.
|
||||
//!
|
||||
//! This module runs the short-lived localhost server used by interactive sign-in.
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
|
|
@ -32,6 +35,7 @@ use tiny_http::StatusCode;
|
|||
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
|
||||
const DEFAULT_PORT: u16 = 1455;
|
||||
|
||||
/// Options for launching the local login callback server.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerOptions {
|
||||
pub codex_home: PathBuf,
|
||||
|
|
@ -45,6 +49,7 @@ pub struct ServerOptions {
|
|||
}
|
||||
|
||||
impl ServerOptions {
|
||||
/// Creates a server configuration with the default issuer and port.
|
||||
pub fn new(
|
||||
codex_home: PathBuf,
|
||||
client_id: String,
|
||||
|
|
@ -64,6 +69,7 @@ impl ServerOptions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle for a running login callback server.
|
||||
pub struct LoginServer {
|
||||
pub auth_url: String,
|
||||
pub actual_port: u16,
|
||||
|
|
@ -72,32 +78,38 @@ pub struct LoginServer {
|
|||
}
|
||||
|
||||
impl LoginServer {
|
||||
/// Waits for the login callback loop to finish.
|
||||
pub async fn block_until_done(self) -> io::Result<()> {
|
||||
self.server_handle
|
||||
.await
|
||||
.map_err(|err| io::Error::other(format!("login server thread panicked: {err:?}")))?
|
||||
}
|
||||
|
||||
/// Requests shutdown of the callback server.
|
||||
pub fn cancel(&self) {
|
||||
self.shutdown_handle.shutdown();
|
||||
}
|
||||
|
||||
/// Returns a cloneable cancel handle for the running server.
|
||||
pub fn cancel_handle(&self) -> ShutdownHandle {
|
||||
self.shutdown_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle used to signal the login server loop to exit.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShutdownHandle {
|
||||
shutdown_notify: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl ShutdownHandle {
|
||||
/// Signals the login loop to terminate.
|
||||
pub fn shutdown(&self) {
|
||||
self.shutdown_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a local callback server and returns the browser auth URL.
|
||||
pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
let pkce = generate_pkce();
|
||||
let state = opts.force_state.clone().unwrap_or_else(generate_state);
|
||||
|
|
@ -207,6 +219,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Internal callback handling outcome.
|
||||
enum HandledRequest {
|
||||
Response(Response<Cursor<Vec<u8>>>),
|
||||
RedirectWithHeader(Header),
|
||||
|
|
@ -245,11 +258,25 @@ async fn process_request(
|
|||
Response::from_string("State mismatch").with_status_code(400),
|
||||
);
|
||||
}
|
||||
if let Some(error_code) = params.get("error") {
|
||||
let error_description = params.get("error_description").map(String::as_str);
|
||||
let message = oauth_callback_error_message(error_code, error_description);
|
||||
eprintln!("OAuth callback error: {message}");
|
||||
return login_error_response(
|
||||
&message,
|
||||
io::ErrorKind::PermissionDenied,
|
||||
Some(error_code),
|
||||
error_description,
|
||||
);
|
||||
}
|
||||
let code = match params.get("code") {
|
||||
Some(c) if !c.is_empty() => c.clone(),
|
||||
_ => {
|
||||
return HandledRequest::Response(
|
||||
Response::from_string("Missing authorization code").with_status_code(400),
|
||||
return login_error_response(
|
||||
"Missing authorization code. Sign-in could not be completed.",
|
||||
io::ErrorKind::InvalidData,
|
||||
Some("missing_authorization_code"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -263,7 +290,12 @@ async fn process_request(
|
|||
&tokens.id_token,
|
||||
) {
|
||||
eprintln!("Workspace restriction error: {message}");
|
||||
return login_error_response(&message);
|
||||
return login_error_response(
|
||||
&message,
|
||||
io::ErrorKind::PermissionDenied,
|
||||
Some("workspace_restriction"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
// Obtain API key via token-exchange and persist
|
||||
let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token)
|
||||
|
|
@ -280,9 +312,11 @@ async fn process_request(
|
|||
.await
|
||||
{
|
||||
eprintln!("Persist error: {err}");
|
||||
return HandledRequest::Response(
|
||||
Response::from_string(format!("Unable to persist auth file: {err}"))
|
||||
.with_status_code(500),
|
||||
return login_error_response(
|
||||
"Sign-in completed but credentials could not be saved locally.",
|
||||
io::ErrorKind::Other,
|
||||
Some("persist_failed"),
|
||||
Some(&err.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -294,16 +328,21 @@ async fn process_request(
|
|||
);
|
||||
match tiny_http::Header::from_bytes(&b"Location"[..], success_url.as_bytes()) {
|
||||
Ok(header) => HandledRequest::RedirectWithHeader(header),
|
||||
Err(_) => HandledRequest::Response(
|
||||
Response::from_string("Internal Server Error").with_status_code(500),
|
||||
Err(_) => login_error_response(
|
||||
"Sign-in completed but redirecting back to Codex failed.",
|
||||
io::ErrorKind::Other,
|
||||
Some("redirect_failed"),
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Token exchange error: {err}");
|
||||
HandledRequest::Response(
|
||||
Response::from_string(format!("Token exchange failed: {err}"))
|
||||
.with_status_code(500),
|
||||
login_error_response(
|
||||
&format!("Token exchange failed: {err}"),
|
||||
io::ErrorKind::Other,
|
||||
Some("token_exchange_failed"),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -486,12 +525,14 @@ fn bind_server(port: u16) -> io::Result<Server> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Tokens returned by the OAuth authorization-code exchange.
|
||||
pub(crate) struct ExchangedTokens {
|
||||
pub id_token: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Exchanges an authorization code for tokens.
|
||||
pub(crate) async fn exchange_code_for_tokens(
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
|
|
@ -521,10 +562,12 @@ pub(crate) async fn exchange_code_for_tokens(
|
|||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.map_err(io::Error::other)?;
|
||||
let detail = parse_token_endpoint_error(&body);
|
||||
return Err(io::Error::other(format!(
|
||||
"token endpoint returned status {}",
|
||||
resp.status()
|
||||
"token endpoint returned status {status}: {detail}"
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
@ -536,6 +579,7 @@ pub(crate) async fn exchange_code_for_tokens(
|
|||
})
|
||||
}
|
||||
|
||||
/// Persists exchanged credentials using the configured local auth store.
|
||||
pub(crate) async fn persist_tokens_async(
|
||||
codex_home: &Path,
|
||||
api_key: Option<String>,
|
||||
|
|
@ -650,6 +694,7 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map<String, serde_json::Value> {
|
|||
serde_json::Map::new()
|
||||
}
|
||||
|
||||
/// Validates the ID token against an optional workspace restriction.
|
||||
pub(crate) fn ensure_workspace_allowed(
|
||||
expected: Option<&str>,
|
||||
id_token: &str,
|
||||
|
|
@ -670,23 +715,133 @@ pub(crate) fn ensure_workspace_allowed(
|
|||
}
|
||||
}
|
||||
|
||||
// Respond to the oauth server with an error so the code becomes unusable by anybody else.
|
||||
fn login_error_response(message: &str) -> HandledRequest {
|
||||
/// Builds a terminal callback response for login failures.
|
||||
fn login_error_response(
|
||||
message: &str,
|
||||
kind: io::ErrorKind,
|
||||
error_code: Option<&str>,
|
||||
error_description: Option<&str>,
|
||||
) -> HandledRequest {
|
||||
let mut headers = Vec::new();
|
||||
if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
|
||||
{
|
||||
if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
|
||||
headers.push(header);
|
||||
}
|
||||
let body = render_login_error_page(message, error_code, error_description);
|
||||
HandledRequest::ResponseAndExit {
|
||||
headers,
|
||||
body: message.as_bytes().to_vec(),
|
||||
result: Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
message.to_string(),
|
||||
)),
|
||||
body,
|
||||
result: Err(io::Error::new(kind, message.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the OAuth callback represents a missing Codex entitlement.
|
||||
fn is_missing_codex_entitlement_error(error_code: &str, error_description: Option<&str>) -> bool {
|
||||
error_code == "access_denied"
|
||||
&& error_description.is_some_and(|description| {
|
||||
description
|
||||
.to_ascii_lowercase()
|
||||
.contains("missing_codex_entitlement")
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts OAuth callback errors into a user-facing message.
|
||||
fn oauth_callback_error_message(error_code: &str, error_description: Option<&str>) -> String {
|
||||
if is_missing_codex_entitlement_error(error_code, error_description) {
|
||||
return "Codex is not enabled for your workspace. Contact your workspace administrator to request access to Codex.".to_string();
|
||||
}
|
||||
|
||||
if let Some(description) = error_description
|
||||
&& !description.trim().is_empty()
|
||||
{
|
||||
return format!("Sign-in failed: {description}");
|
||||
}
|
||||
|
||||
format!("Sign-in failed: {error_code}")
|
||||
}
|
||||
|
||||
/// Extracts a readable error from token endpoint responses.
|
||||
fn parse_token_endpoint_error(body: &str) -> String {
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "unknown error".to_string();
|
||||
}
|
||||
|
||||
let parsed = serde_json::from_str::<JsonValue>(trimmed).ok();
|
||||
if let Some(json) = parsed {
|
||||
if let Some(description) = json.get("error_description").and_then(JsonValue::as_str)
|
||||
&& !description.trim().is_empty()
|
||||
{
|
||||
return description.to_string();
|
||||
}
|
||||
if let Some(error_obj) = json.get("error")
|
||||
&& let Some(message) = error_obj.get("message").and_then(JsonValue::as_str)
|
||||
&& !message.trim().is_empty()
|
||||
{
|
||||
return message.to_string();
|
||||
}
|
||||
if let Some(error_code) = json.get("error").and_then(JsonValue::as_str)
|
||||
&& !error_code.trim().is_empty()
|
||||
{
|
||||
return error_code.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Renders the branded error page used by callback failures.
|
||||
fn render_login_error_page(
|
||||
message: &str,
|
||||
error_code: Option<&str>,
|
||||
error_description: Option<&str>,
|
||||
) -> Vec<u8> {
|
||||
let template = include_str!("assets/error.html");
|
||||
let code = error_code.unwrap_or("unknown_error");
|
||||
let (title, display_message, display_description, help_text) =
|
||||
if is_missing_codex_entitlement_error(code, error_description) {
|
||||
(
|
||||
"You do not have access to Codex".to_string(),
|
||||
"This account is not currently authorized to use Codex in this workspace."
|
||||
.to_string(),
|
||||
"Contact your workspace administrator to request access to Codex.".to_string(),
|
||||
"Contact your workspace administrator to get access to Codex, then return to Codex and try again."
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Sign-in could not be completed".to_string(),
|
||||
message.to_string(),
|
||||
error_description.unwrap_or(message).to_string(),
|
||||
"Return to Codex to retry, switch accounts, or contact your workspace admin if access is restricted."
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
template
|
||||
.replace("__ERROR_TITLE__", &html_escape(&title))
|
||||
.replace("__ERROR_MESSAGE__", &html_escape(&display_message))
|
||||
.replace("__ERROR_CODE__", &html_escape(code))
|
||||
.replace("__ERROR_DESCRIPTION__", &html_escape(&display_description))
|
||||
.replace("__ERROR_HELP__", &html_escape(&help_text))
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// Escapes error strings before inserting them into HTML.
|
||||
fn html_escape(input: &str) -> String {
|
||||
let mut escaped = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
/// Exchanges an authenticated ID token for an API-key style access token.
|
||||
pub(crate) async fn obtain_api_key(
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
|
|
|
|||
|
|
@ -255,6 +255,152 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123");
|
||||
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
|
||||
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().to_path_buf();
|
||||
let state = "state-entitlement".to_string();
|
||||
|
||||
let opts = ServerOptions {
|
||||
codex_home: codex_home.clone(),
|
||||
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
client_id: codex_login::CLIENT_ID.to_string(),
|
||||
issuer,
|
||||
port: 0,
|
||||
open_browser: false,
|
||||
force_state: Some(state.clone()),
|
||||
forced_chatgpt_workspace_id: None,
|
||||
};
|
||||
let server = run_login_server(opts)?;
|
||||
let login_port = server.actual_port;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"http://127.0.0.1:{login_port}/auth/callback?state={state}&error=access_denied&error_description=missing_codex_entitlement"
|
||||
);
|
||||
let resp = client.get(&url).send().await?;
|
||||
assert!(resp.status().is_success());
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
body.contains("You do not have access to Codex"),
|
||||
"error body should clearly explain the Codex access denial"
|
||||
);
|
||||
assert!(
|
||||
body.contains("Contact your workspace administrator"),
|
||||
"error body should tell the user how to get access"
|
||||
);
|
||||
assert!(
|
||||
body.contains("access_denied"),
|
||||
"error body should still include the oauth error code"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("missing_codex_entitlement"),
|
||||
"known entitlement errors should be mapped to user-facing copy"
|
||||
);
|
||||
|
||||
let result = server.block_until_done().await;
|
||||
assert!(result.is_err(), "login should fail for access_denied");
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Contact your workspace administrator"),
|
||||
"terminal error should also tell the user what to do next"
|
||||
);
|
||||
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
assert!(
|
||||
!auth_path.exists(),
|
||||
"auth.json should not be written when oauth callback is denied"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123");
|
||||
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
|
||||
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().to_path_buf();
|
||||
let state = "state-generic-denial".to_string();
|
||||
|
||||
let opts = ServerOptions {
|
||||
codex_home: codex_home.clone(),
|
||||
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
client_id: codex_login::CLIENT_ID.to_string(),
|
||||
issuer,
|
||||
port: 0,
|
||||
open_browser: false,
|
||||
force_state: Some(state.clone()),
|
||||
forced_chatgpt_workspace_id: None,
|
||||
};
|
||||
let server = run_login_server(opts)?;
|
||||
let login_port = server.actual_port;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"http://127.0.0.1:{login_port}/auth/callback?state={state}&error=access_denied&error_description=some_other_reason"
|
||||
);
|
||||
let resp = client.get(&url).send().await?;
|
||||
assert!(resp.status().is_success());
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
body.contains("Sign-in could not be completed"),
|
||||
"generic oauth denial should use the generic error page title"
|
||||
);
|
||||
assert!(
|
||||
body.contains("Sign-in failed: some_other_reason"),
|
||||
"generic oauth denial should preserve the oauth error details"
|
||||
);
|
||||
assert!(
|
||||
body.contains("Return to Codex to retry"),
|
||||
"generic oauth denial should keep the generic help text"
|
||||
);
|
||||
assert!(
|
||||
body.contains("access_denied"),
|
||||
"generic oauth denial should include the oauth error code"
|
||||
);
|
||||
assert!(
|
||||
body.contains("some_other_reason"),
|
||||
"generic oauth denial should include the oauth error description"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("You do not have access to Codex"),
|
||||
"generic oauth denial should not show the entitlement-specific title"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("get access to Codex"),
|
||||
"generic oauth denial should not show the entitlement-specific admin guidance"
|
||||
);
|
||||
|
||||
let result = server.block_until_done().await;
|
||||
assert!(result.is_err(), "login should fail for access_denied");
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Sign-in failed: some_other_reason"),
|
||||
"terminal error should preserve generic oauth details"
|
||||
);
|
||||
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
assert!(
|
||||
!auth_path.exists(),
|
||||
"auth.json should not be written when oauth callback is denied"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue