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:
daniel-oai authored and GitHub committed 2026-02-20 11:35:28 -08:00
1 parent 097620218d
commit f08cf8d65f
4 files changed
+450 -24

No files matched your search

@@ -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(()));