Skip to content

feat(tui): native ChatGPT PKCE sign-in for openai-codex (#5778) - #5784

Open
Hmbown wants to merge 2 commits into
mainfrom
feat/native-chatgpt-pkce-5778-20260831
Open

feat(tui): native ChatGPT PKCE sign-in for openai-codex (#5778)#5784
Hmbown wants to merge 2 commits into
mainfrom
feat/native-chatgpt-pkce-5778-20260831

Conversation

@Hmbown

@Hmbown Hmbown commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

Native Sign in with ChatGPT for the first-class openai-codex route. Connecting a ChatGPT/Codex subscription no longer requires Codex CLI or ~/.codex/auth.json. Browser PKCE with a localhost callback stores refreshable tokens in Codewhale-owned storage, matching the xAI device-login pattern.

Closes #5778.

Terms research (ambiguous — implemented behind a documented boundary)

Public sources checked 2026-08-31:

  • learn.chatgpt.com/docs/auth (OpenAI Codex authentication): Sign in with ChatGPT is documented for the ChatGPT desktop app, Codex CLI, and IDE extension. Browser PKCE with a localhost callback (default localhost:1455) is the official Codex client flow. Device-code is documented as a Codex CLI beta (codex login --device-auth), not as a third-party API.
  • Issuer OIDC discovery (https://auth.openai.com/.well-known/openid-configuration): advertises authorization_code + PKCE S256, refresh_token, scopes openid profile email offline_access, and revocation_endpoint. Does not advertise device_authorization_endpoint.
  • OpenAI has not published a third-party client-registration path for this public Codex OAuth client (app_EMoamEEZ73f0CkXaXp7hrann, Apache-2.0 Codex CLI).
  • OpenAI maintainer comment on openai/codex#8338: ToS/code license described as permissive; OSS projects like OpenCode doing similar login are acknowledged. Not a legal opinion.
  • CI/CD auth docs explicitly exclude “generic OAuth clients outside Codex.”
  • Unlike Anthropic, there is no explicit ban.

Conclusion: ambiguous. This PR implements the published authorization-code + PKCE S256 adapter and does not smuggle unpublished endpoints:

  • honest originator=codewhale (never codex_cli_rs)
  • loopback ports 1455 / 1457 (the ports that public client registers)
  • /oauth/authorize and /oauth/token on the published issuer
  • discovery revocation_endpoint for remote revoke
  • no /api/accounts/deviceauth/* (unpublished; issuer does not advertise device authorization)
  • tokens stay in Codewhale-owned $CODEWHALE_HOME/credentials/chatgpt-auth-<hex>.json; Codex CLI files are never written or refreshed
  • no session cookies

If OpenAI later allocates a Codewhale-specific client id, swap CODEWHALE_CHATGPT_OAUTH_CLIENT_ID.

Behavior

  • /provider setup openai-codex (and missing-auth handoff) offers Sign in with ChatGPT first (subscription / ChatGPT billing) vs Import Codex CLI credentials (explicit read-only consent, Codex CLI remains owner). Copy distinguishes this from the openai API-key billing owner before any run.
  • codewhale auth chatgpt / /auth chatgpt runs PKCE login.
  • codewhale auth chatgpt-revoke / /auth chatgpt-revoke deletes Codewhale-owned tokens (best-effort remote revoke) and does not touch Codex CLI consent.
  • Credential lookup: env token → Codewhale-owned ChatGPT store (refresh under lock, rotating refresh tokens fail closed) → consent-gated Codex CLI file.
  • External CLI import remains an explicit alternative, not a prerequisite.

Testing

  • cargo fmt --all (clean)
  • cargo clippy -p codewhale-config --tests -- -D warnings
  • cargo clippy -p codewhale-tui --tests -- -D warnings -A clippy::too_many_arguments (the allow is pre-existing on runtime_threads / underwater, not introduced here)
  • cargo clippy -p codewhale-cli --tests -- -D warnings -A clippy::too_many_arguments
  • cargo test -p codewhale-config --lib xai_credentials
  • cargo test -p codewhale-tui --lib chatgpt_oauth (10 tests: PKCE S256, callback success/error/state, mock token exchange, persist/refresh/revoke, no live OpenAI)
  • cargo test -p codewhale-tui --lib -- provider_picker::tests (126)
  • cargo test -p codewhale-cli --lib -- parses_auth_subcommand_matrix auth_status
  • python3 scripts/check-tui-locale-parity.py
  • Full cargo test --workspace --all-features --locked not run locally (TUI crate compile is large; CI will cover)
  • TUI browser login not live-tested against OpenAI in this environment

Unit tests use a mock issuer. They do not hit live OpenAI.

Checklist

  • This PR adds a provider adapter (chatgpt_oauth.rs), not a product fork; it reuses the existing Codewhale-owned credentials directory
  • Updated docs (docs/PROVIDERS.md, docs/CONFIGURATION.md)
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes (picker copy + slash/CLI wiring covered by tests; live PKCE not run)
  • Harvested/co-authored credit uses a GitHub numeric noreply address (N/A — CodeWhale Bot)

Add a Codewhale-owned Sign in with ChatGPT adapter so the openai-codex
route no longer requires Codex CLI. Browser PKCE uses the published
auth.openai.com issuer, stores rotating refresh tokens in the existing
credentials directory, and keeps external CLI import as an explicit
alternative. Device-code is omitted because the issuer does not
advertise device_authorization_endpoint.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Reviewing PR #5784 — native ChatGPT PKCE sign-in for openai-codex

Todo list

  • Gather context (PR diff, changed files, CLAUDE.md/AGENTS.md rules)
  • Understand request (code review requested)
  • Review crates/tui/src/chatgpt_oauth.rs (new PKCE/OAuth logic)
  • Review crates/config/src/xai_credentials.rs changes (shared credential storage)
  • Review provider/route wiring (config.rs, credential_resolve.rs, provider_picker.rs, route_runtime.rs)
  • Review CLI/command wiring (cli/src/lib.rs, commands/*)
  • Check for provider/model-prefix inference violations (EPIC v0.8.65 EPIC: Separate provider facts, model facts, offerings, and route resolution #2608 invariant)
  • Check tests and locale parity
  • Post final review

View job run

@Hmbown
Hmbown enabled auto-merge (squash) August 31, 2026 20:43
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 3m 42s —— View job


Reviewing PR #5784 — native ChatGPT PKCE sign-in for openai-codex

Todo list

  • Gather context (PR diff, changed files, CLAUDE.md/AGENTS.md rules)
  • Understand request (code review requested)
  • Review crates/tui/src/chatgpt_oauth.rs (new PKCE/OAuth logic)
  • Review crates/config/src/xai_credentials.rs changes (shared credential storage)
  • Review provider/route wiring (config.rs, credential_resolve.rs, provider_picker.rs, route_runtime.rs)
  • Review CLI/command wiring (cli/src/lib.rs, commands/*)
  • Check for provider/model-prefix inference violations (EPIC v0.8.65 EPIC: Separate provider facts, model facts, offerings, and route resolution #2608 invariant)
  • Check tests and locale parity
  • Post final review

Summary

Solid, well-scoped adapter that closely mirrors the existing xAI device-login pattern (shared XaiOAuthCredentialStore/lifecycle lock, explicit-choice picker stage, redacted Debug impls, credential precedence env → Codewhale-owned store → consent-gated Codex CLI). No EPIC #2608 provider-inference violations found — the ChatGPT route is only entered via explicit picker/CLI/slash-command choice, never inferred from a model string prefix. Secrets are consistently redacted in Debug impls and error messages (token_exchange_error_does_not_echo_body_secrets test confirms this). Locale parity is intact across all 15 non-English locales.

Findings

1. Blocking network I/O runs synchronously inside the async TUI event loopcrates/tui/src/commands/groups/config/mod.rs:191-198
/auth chatgpt-revoke calls crate::chatgpt_oauth::revoke_owned_login(None, None) directly and synchronously. That function does a blocking reqwest POST per token (OAUTH_REQUEST_TIMEOUT = 20s) via revoke_remote_token. commands::execute is invoked without spawning from execute_command_input (crates/tui/src/tui/ui.rs:943), an async fn on the shared tokio runtime — unlike the sign-in path, which correctly does pause_terminal + tokio::task::spawn_blocking (run_chatgpt_pkce_login_from_tui, crates/tui/src/tui/ui/event_loop.rs:6249). A slow/hanging revoke endpoint will visibly freeze the TUI's render/input loop for up to the request timeout. There's no prior /auth *-revoke slash command to have copied this from — worth routing this through an AppAction/spawn_blocking like the login flow.
Fix this →

2. Doc claims OIDC-discovery-based revoke, but the endpoint is hardcodedcrates/tui/src/chatgpt_oauth.rs:13-14,24,284-286
The module doc and the PR body both state the adapter "uses the discovery revocation_endpoint for remote revoke," but revoke_endpoint() just formats a hardcoded /api/accounts/oauth/revoke path — there's no code anywhere in this diff that fetches https://auth.openai.com/.well-known/openid-configuration or reads a revocation_endpoint field. Given this PR's terms-boundary argument leans heavily on "only publishes documented, discoverable endpoints," the doc comment overstates what the code does. Either implement actual discovery or correct the comment to say the revoke path is a fixed convention, not one read from discovery.

3. Minor: unused localized messagecrates/tui/src/localization.rs:1664 (+15 locale files)
MessageId::ProviderExternalHintChatgptReview is declared, added to ALL_MESSAGE_IDS, and translated into all 15 locales, but nothing in provider_picker.rs (or elsewhere) references it — only ProviderExternalActionReuseCodex is used for the "E" hint in render_chatgpt_auth_choice. Either wire it into the UI or drop it to avoid carrying translator effort for unreachable copy.

Good choices worth calling out

  • Explicit two-step picker (ChatgptAuthChoice::SignInWithChatgpt vs ImportCodexCli) keeps the two credential owners (Codewhale-owned vs Codex CLI) from being conflated, consistent with the xAI pattern.
  • state is checked on both success and (when present) error callbacks (accept_callback), and PKCE verifier/challenge are S256 per RFC 7636, covered by pkce_verifier_and_challenge_are_s256.
  • parse_token_response fails closed and tells the user to re-auth on invalid_grant/refresh_token_reused/refresh_token_expired/refresh_token_invalidated/401, matching the "rotating refresh tokens fail closed" claim in the PR body.
  • Callback error bodies never echo error_description-adjacent secrets back into logs/errors (token_exchange_error_does_not_echo_body_secrets).

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

Adds native Sign in with ChatGPT PKCE loopback flow for openai-codex, including config storage, CLI/TUI wiring, localization, tests, and docs. Overall solid but has several correctness concerns around loopback host resolution, hardcoded revocation endpoint versus the discovery claim, and opaque token expiry handling.

Findings

  • [WARNING] Loopback callback uses localhost redirect while listener binds 127.0.0.1 (crates/tui/src/chatgpt_oauth.rs)
    bind_loopback_callback() binds only to 127.0.0.1, but redirect_uri_for_port() returns http://localhost:/auth/callback. On systems where localhost resolves to ::1 first (common on Linux with IPv6 enabled), the browser may connect to ::1 and be refused, breaking PKCE login. Bind to localhost (both v4/v6) or align the redirect URI to the bound address, and verify against OpenAI's registered redirect URIs.
  • [WARNING] Revocation endpoint hardcoded, not read from OIDC discovery (crates/tui/src/chatgpt_oauth.rs)
    PR claims to use the discovery revocation_endpoint, but revoke_endpoint() hardcodes /api/accounts/oauth/revoke and pkce_login() never fetches https://auth.openai.com/.well-known/openid-configuration. If OpenAI rotates endpoints, revoke will fail while the rest of the flow may still work. Fetch and use the advertised revocation_endpoint, or update docs to say it is hardcoded.
  • [INFO] select_entry prefers built-in client id even when custom client id is configured (crates/tui/src/chatgpt_oauth.rs)
    select_entry always prefers keys ending with ::app_EMo... (CHATGPT_OAUTH_CLIENT_ID). When CODEWHALE_CHATGPT_OAUTH_CLIENT_ID is set to a different client id, the current entry won't be preferred, and fallback may pick an older entry. Consider deriving the preferred suffix from the entry/config client id.
  • [WARNING] Opaque access tokens without expires_in are treated as expired and can fail closed incorrectly (crates/tui/src/chatgpt_oauth.rs)
    entry_access_token_is_fresh returns false when neither expires_at nor a JWT exp is present. For valid opaque tokens issued without expires_in (or with no JWT), get_owned_credentials_locked will attempt refresh and, if no refresh token is stored, error 'access token expired and no refresh_token is stored' even though the access token may be valid. Consider treating missing expiry as fresh until a 401 indicates otherwise, or require and document that the issuer always returns expires_in.
  • [WARNING] Local credential file removal errors are silently ignored during revoke (crates/tui/src/chatgpt_oauth.rs)
    In revoke_owned_login_locked, store.remove(&name) uses let _ = store.remove(&name);, so a failed deletion (permissions, I/O) leaves the token file on disk while the command reports success and the config pointer is unset. The user may believe credentials are revoked. Propagate or at least log the error.
  • [INFO] Logout clears only valid generation pointer, not legacy chatgpt pointer (crates/cli/src/lib.rs)
    run_logout_command_with_secrets_unlocked sets oauth_credential_generation to None only if is_valid_chatgpt_oauth_generation. If the config pointer somehow holds the legacy name chatgpt-oauth.json, logout will not clear it, then clear_all_chatgpt_oauth_credentials deletes that file, leaving a dangling config pointer. Use is_valid_chatgpt_oauth_generation or also check LEGACY_CHATGPT_OAUTH_FILE_NAME.

Suggestions

  • crates/tui/src/chatgpt_oauth.rs — Bind the callback listener to localhost as well as 127.0.0.1, or change redirect_uri_for_port to use http://127.0.0.1:<port> so the browser connects to the bound interface. Verify that the chosen URI is among the public client's registered redirect URIs.
  • crates/tui/src/chatgpt_oauth.rs — Fetch the OIDC discovery document on first use and cache the advertised revocation_endpoint instead of hardcoding it, so remote revoke stays aligned with issuer configuration. If hardcoding is intentional, update docs and PR text accordingly.
  • crates/tui/src/chatgpt_oauth.rs — Replace let _ = store.remove(&name); with logging or propagate the error so users are not misled when local credential deletion fails.

Assessment

The PR is well-structured with good tests and documentation, but a few correctness issues should be addressed before merge: the localhost/IPv4 callback mismatch can break login on many systems, revocation endpoint is hardcoded despite the discovery claim, and opaque-token expiry handling may fail closed incorrectly. The code otherwise reuses patterns well and keeps token material redacted.


Advisory review by Codewhale (codewhale review --pr 5784 --post, head c65d0ea1bacfa54d0dc419985d082dadc3a2c9b8). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

})
.unwrap()
.unwrap();
assert!(stored.contains("refresh-2"), "{stored}");
.unwrap()
.unwrap();
assert!(stored.contains("refresh-2"), "{stored}");
assert!(!stored.contains("refresh-old"), "{stored}");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native ChatGPT/Codex subscription sign-in without the Codex CLI installed

2 participants