Compare commits

...

2 Commits

Author SHA1 Message Date
Jeremy Rose
81453589da lint 2025-07-29 09:28:20 -07:00
Jeremy Rose
80b4b7c77a port login server to rust 2025-07-28 17:27:13 -07:00
8 changed files with 944 additions and 891 deletions

55
codex-rs/Cargo.lock generated
View File

@@ -787,11 +787,19 @@ dependencies = [
name = "codex-login"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"chrono",
"http-body-util",
"hyper",
"hyper-util",
"open",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"sha2",
"tokio",
"url",
]
[[package]]
@@ -2317,6 +2325,15 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.16"
@@ -2328,6 +2345,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -2921,6 +2948,17 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "open"
version = "5.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.73"
@@ -3052,6 +3090,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -4064,6 +4108,17 @@ dependencies = [
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"

View File

@@ -21,8 +21,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
}
};
let capture_output = false;
match login_with_chatgpt(&config.codex_home, capture_output).await {
match login_with_chatgpt(&config.codex_home).await {
Ok(_) => {
eprintln!("Successfully logged in");
std::process::exit(0);

View File

@@ -18,3 +18,11 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
hyper = { version = "1", features = ["http1", "server"] }
hyper-util = { version = "0.1", features = ["tokio"] }
open = "5"
url = "2"
base64 = "0.22"
sha2 = "0.10"
rand = "0.8"
http-body-util = "0.1"

View File

@@ -8,53 +8,19 @@ use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
mod server;
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
/// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME
/// environment variable set to the provided `codex_home` path. If the
/// subprocess exits 0, read the OPENAI_API_KEY property out of
/// CODEX_HOME/auth.json and return Ok(OPENAI_API_KEY). Otherwise, return Err
/// with any information from the subprocess.
///
/// If `capture_output` is true, the subprocess's output will be captured and
/// recorded in memory. Otherwise, the subprocess's output will be sent to the
/// current process's stdout/stderr.
pub async fn login_with_chatgpt(
codex_home: &Path,
capture_output: bool,
) -> std::io::Result<String> {
let child = Command::new("python3")
.arg("-c")
.arg(SOURCE_FOR_PYTHON_SERVER)
.env("CODEX_HOME", codex_home)
.stdin(Stdio::null())
.stdout(if capture_output {
Stdio::piped()
} else {
Stdio::inherit()
})
.stderr(if capture_output {
Stdio::piped()
} else {
Stdio::inherit()
})
.spawn()?;
let output = child.wait_with_output().await?;
if output.status.success() {
try_read_openai_api_key(codex_home).await
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(std::io::Error::other(format!(
"login_with_chatgpt subprocess failed: {stderr}"
)))
}
/// Spawn a local OAuth callback server and run the ChatGPT login flow.
/// On success, reads the OPENAI_API_KEY from CODEX_HOME/auth.json and returns it.
pub async fn login_with_chatgpt(codex_home: &Path) -> std::io::Result<String> {
// Run the Rust implementation of the login server (raw hyper).
// This replicates the behavior of login_with_chatgpt.py.
server::run_login_server(codex_home).await?;
try_read_openai_api_key(codex_home).await
}
/// Attempt to read the `OPENAI_API_KEY` from the `auth.json` file in the given

View File

@@ -1,846 +0,0 @@
"""Script that spawns a local webserver for retrieving an OpenAI API key.
- Listens on 127.0.0.1:1455
- Opens http://localhost:1455/auth/callback in the browser
- If the user successfully navigates the auth flow,
$CODEX_HOME/auth.json will be written with the API key.
- User will be redirected to http://localhost:1455/success upon success.
The script should exit with a non-zero code if the user fails to navigate the
auth flow.
To test this script locally without overwriting your existing auth.json file:
```
rm -rf /tmp/codex_home && mkdir /tmp/codex_home
CODEX_HOME=/tmp/codex_home python3 codex-rs/login/src/login_with_chatgpt.py
```
"""
from __future__ import annotations
import argparse
import base64
import datetime
import errno
import hashlib
import http.server
import json
import os
import secrets
import sys
import threading
import time
import urllib.parse
import urllib.request
import webbrowser
from dataclasses import dataclass
from typing import Any, Dict # for type hints
# Required port for OAuth client.
REQUIRED_PORT = 1455
URL_BASE = f"http://localhost:{REQUIRED_PORT}"
DEFAULT_ISSUER = "https://auth.openai.com"
DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE = 13
@dataclass
class TokenData:
id_token: str
access_token: str
refresh_token: str
account_id: str
@dataclass
class AuthBundle:
"""Aggregates authentication data produced after successful OAuth flow."""
api_key: str
token_data: TokenData
last_refresh: str
def main() -> None:
parser = argparse.ArgumentParser(description="Retrieve API key via local HTTP flow")
parser.add_argument(
"--no-browser",
action="store_true",
help="Do not automatically open the browser",
)
parser.add_argument("--verbose", action="store_true", help="Enable request logging")
args = parser.parse_args()
codex_home = os.environ.get("CODEX_HOME")
if not codex_home:
eprint("ERROR: CODEX_HOME environment variable is not set")
sys.exit(1)
# Spawn server.
try:
httpd = _ApiKeyHTTPServer(
("127.0.0.1", REQUIRED_PORT),
_ApiKeyHTTPHandler,
codex_home=codex_home,
verbose=args.verbose,
)
except OSError as e:
eprint(f"ERROR: {e}")
if e.errno == errno.EADDRINUSE:
# Caller might want to handle this case specially.
sys.exit(EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE)
else:
sys.exit(1)
auth_url = httpd.auth_url()
with httpd:
eprint(f"Starting local login server on {URL_BASE}")
if not args.no_browser:
try:
webbrowser.open(auth_url, new=1, autoraise=True)
except Exception as e:
eprint(f"Failed to open browser: {e}")
eprint(
f"If your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}"
)
# Run the server in the main thread until `shutdown()` is called by the
# request handler.
try:
httpd.serve_forever()
except KeyboardInterrupt:
eprint("\nKeyboard interrupt received, exiting.")
# Server has been shut down by the request handler. Exit with the code
# it set (0 on success, non-zero on failure).
sys.exit(httpd.exit_code)
class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler):
"""A minimal request handler that captures an *api key* from query/post."""
# We store the result in the server instance itself.
server: "_ApiKeyHTTPServer" # type: ignore[override] - helpful annotation
def do_GET(self) -> None: # noqa: N802 required by BaseHTTPRequestHandler
path = urllib.parse.urlparse(self.path).path
if path == "/success":
# Serve confirmation page then gracefully shut down the server so
# the main thread can exit with the previously captured exit code.
self._send_html(LOGIN_SUCCESS_HTML)
# Ensure the data is flushed to the client before we stop.
try:
self.wfile.flush()
except Exception as e:
eprint(f"Failed to flush response: {e}")
self.request_shutdown()
elif path == "/auth/callback":
query = urllib.parse.urlparse(self.path).query
params = urllib.parse.parse_qs(query)
# Validate state -------------------------------------------------
if params.get("state", [None])[0] != self.server.state:
self.send_error(400, "State parameter mismatch")
return
# Standard OAuth flow -----------------------------------------
code = params.get("code", [None])[0]
if not code:
self.send_error(400, "Missing authorization code")
return
try:
auth_bundle, success_url = self._exchange_code_for_api_key(code)
except Exception as exc: # noqa: BLE001 propagate to client
self.send_error(500, f"Token exchange failed: {exc}")
return
# Persist API key along with additional token metadata.
if _write_auth_file(
auth=auth_bundle,
codex_home=self.server.codex_home,
):
self.server.exit_code = 0
self._send_redirect(success_url)
else:
self.send_error(500, "Unable to persist auth file")
else:
self.send_error(404, "Endpoint not supported")
def do_POST(self) -> None: # noqa: N802 required by BaseHTTPRequestHandler
self.send_error(404, "Endpoint not supported")
def send_error(self, code, message=None, explain=None) -> None:
"""Send an error response and stop the server.
We avoid calling `sys.exit()` directly from the request-handling thread
so that the response has a chance to be written to the socket. Instead
we shut the server down; the main thread will then exit with the
appropriate status code.
"""
super().send_error(code, message, explain)
try:
self.wfile.flush()
except Exception as e:
eprint(f"Failed to flush response: {e}")
self.request_shutdown()
def _send_redirect(self, url: str) -> None:
self.send_response(302)
self.send_header("Location", url)
self.end_headers()
def _send_html(self, body: str) -> None:
encoded = body.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
# Silence logging for cleanliness unless --verbose flag is used.
def log_message(self, fmt: str, *args): # type: ignore[override]
if getattr(self.server, "verbose", False): # type: ignore[attr-defined]
super().log_message(fmt, *args)
def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
"""Perform token + token-exchange to obtain an OpenAI API key.
Returns (AuthBundle, success_url).
"""
token_endpoint = f"{self.server.issuer}/oauth/token"
# 1. Authorization-code -> (id_token, access_token, refresh_token)
data = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.server.redirect_uri,
"client_id": self.server.client_id,
"code_verifier": self.server.pkce.code_verifier,
}
).encode()
token_data: TokenData
with urllib.request.urlopen(
urllib.request.Request(
token_endpoint,
data=data,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
) as resp:
payload = json.loads(resp.read().decode())
# Extract chatgpt_account_id from id_token
id_token_parts = payload["id_token"].split(".")
if len(id_token_parts) != 3:
raise ValueError("Invalid ID token")
id_token_claims = _decode_jwt_segment(id_token_parts[1])
auth_claims = id_token_claims.get("https://api.openai.com/auth", {})
chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
token_data = TokenData(
id_token=payload["id_token"],
access_token=payload["access_token"],
refresh_token=payload["refresh_token"],
account_id=chatgpt_account_id,
)
access_token_parts = token_data.access_token.split(".")
if len(access_token_parts) != 3:
raise ValueError("Invalid access token")
access_token_claims = _decode_jwt_segment(access_token_parts[1])
token_claims = id_token_claims.get("https://api.openai.com/auth", {})
access_claims = access_token_claims.get("https://api.openai.com/auth", {})
org_id = token_claims.get("organization_id")
if not org_id:
raise ValueError("Missing organization in id_token claims")
project_id = token_claims.get("project_id")
if not project_id:
raise ValueError("Missing project in id_token claims")
random_id = secrets.token_hex(6)
# 2. Token exchange to obtain API key
today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
exchange_data = urllib.parse.urlencode(
{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": self.server.client_id,
"requested_token": "openai-api-key",
"subject_token": token_data.id_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"name": f"Codex CLI [auto-generated] ({today}) [{random_id}]",
}
).encode()
exchanged_access_token: str
with urllib.request.urlopen(
urllib.request.Request(
token_endpoint,
data=exchange_data,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
) as resp:
exchange_payload = json.loads(resp.read().decode())
exchanged_access_token = exchange_payload["access_token"]
# Determine whether the organization still requires additional
# setup (e.g., adding a payment method) based on the ID-token
# claim provided by the auth service.
completed_onboarding = token_claims.get("completed_platform_onboarding") == True
chatgpt_plan_type = access_claims.get("chatgpt_plan_type")
is_org_owner = token_claims.get("is_org_owner") == True
needs_setup = not completed_onboarding and is_org_owner
# Build the success URL on the same host/port as the callback and
# include the required query parameters for the front-end page.
success_url_query = {
"id_token": token_data.id_token,
"needs_setup": "true" if needs_setup else "false",
"org_id": org_id,
"project_id": project_id,
"plan_type": chatgpt_plan_type,
"platform_url": (
"https://platform.openai.com"
if self.server.issuer == "https://auth.openai.com"
else "https://platform.api.openai.org"
),
}
success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}"
# Attempt to redeem complimentary API credits for eligible ChatGPT
# Plus / Pro subscribers. Any errors are logged but do not interrupt
# the login flow.
try:
maybe_redeem_credits(
issuer=self.server.issuer,
client_id=self.server.client_id,
id_token=token_data.id_token,
refresh_token=token_data.refresh_token,
codex_home=self.server.codex_home,
)
except Exception as exc: # pragma: no cover best-effort only
eprint(f"Unable to redeem ChatGPT subscriber API credits: {exc}")
# Persist refresh_token/id_token for future use (redeem credits etc.)
last_refresh_str = (
datetime.datetime.now(datetime.timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
auth_bundle = AuthBundle(
api_key=exchanged_access_token,
token_data=token_data,
last_refresh=last_refresh_str,
)
return (auth_bundle, success_url)
def request_shutdown(self) -> None:
# shutdown() must be invoked from another thread to avoid
# deadlocking the serve_forever() loop, which is running in this
# same thread. A short-lived helper thread does the trick.
threading.Thread(target=self.server.shutdown, daemon=True).start()
def _write_auth_file(*, auth: AuthBundle, codex_home: str) -> bool:
"""Persist *api_key* to $CODEX_HOME/auth.json.
Returns True on success, False otherwise. Any error is printed to
*stderr* so that the Rust layer can surface the problem.
"""
if not os.path.isdir(codex_home):
try:
os.makedirs(codex_home, exist_ok=True)
except Exception as exc: # pragma: no cover unlikely
eprint(f"ERROR: unable to create CODEX_HOME directory: {exc}")
return False
auth_path = os.path.join(codex_home, "auth.json")
auth_json_contents = {
"OPENAI_API_KEY": auth.api_key,
"tokens": {
"id_token": auth.token_data.id_token,
"access_token": auth.token_data.access_token,
"refresh_token": auth.token_data.refresh_token,
"account_id": auth.token_data.account_id,
},
"last_refresh": auth.last_refresh,
}
try:
with open(auth_path, "w", encoding="utf-8") as fp:
if hasattr(os, "fchmod"): # POSIX-safe
os.fchmod(fp.fileno(), 0o600)
json.dump(auth_json_contents, fp, indent=2)
except Exception as exc: # pragma: no cover permissions/filesystem
eprint(f"ERROR: unable to write auth file: {exc}")
return False
return True
@dataclass
class PkceCodes:
code_verifier: str
code_challenge: str
class _ApiKeyHTTPServer(http.server.HTTPServer):
"""HTTPServer with shutdown helper & self-contained OAuth configuration."""
def __init__(
self,
server_address: tuple[str, int],
request_handler_class: type[http.server.BaseHTTPRequestHandler],
*,
codex_home: str,
verbose: bool = False,
) -> None:
super().__init__(server_address, request_handler_class, bind_and_activate=True)
self.exit_code = 1
self.codex_home = codex_home
self.verbose: bool = verbose
self.issuer: str = DEFAULT_ISSUER
self.client_id: str = DEFAULT_CLIENT_ID
port = server_address[1]
self.redirect_uri: str = f"http://localhost:{port}/auth/callback"
self.pkce: PkceCodes = _generate_pkce()
self.state: str = secrets.token_hex(32)
def auth_url(self) -> str:
"""Return fully-formed OpenID authorization URL."""
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": "openid profile email offline_access",
"code_challenge": self.pkce.code_challenge,
"code_challenge_method": "S256",
"id_token_add_organizations": "true",
"state": self.state,
}
return f"{self.issuer}/oauth/authorize?" + urllib.parse.urlencode(params)
def maybe_redeem_credits(
*,
issuer: str,
client_id: str,
id_token: str | None,
refresh_token: str,
codex_home: str,
) -> None:
"""Attempt to redeem complimentary API credits for ChatGPT subscribers.
The operation is best-effort: any error results in a warning being printed
and the function returning early without raising.
"""
id_claims: Dict[str, Any] | None = parse_id_token_claims(id_token or "")
# Refresh expired ID token, if possible
token_expired = True
if id_claims and isinstance(id_claims.get("exp"), int):
token_expired = _current_timestamp_ms() >= int(id_claims["exp"]) * 1000
if token_expired:
eprint("Refreshing credentials...")
new_refresh_token: str | None = None
new_id_token: str | None = None
try:
payload = json.dumps(
{
"client_id": client_id,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "openid profile email",
}
).encode()
req = urllib.request.Request(
url="https://auth.openai.com/oauth/token",
data=payload,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
refresh_data = json.loads(resp.read().decode())
new_id_token = refresh_data.get("id_token")
new_id_claims = parse_id_token_claims(new_id_token or "")
new_refresh_token = refresh_data.get("refresh_token")
except Exception as err:
eprint("Unable to refresh ID token via token-exchange:", err)
return
if not new_id_token or not new_refresh_token:
return
# Update auth.json with new tokens.
try:
auth_dir = codex_home
auth_path = os.path.join(auth_dir, "auth.json")
with open(auth_path, "r", encoding="utf-8") as fp:
existing = json.load(fp)
tokens = existing.setdefault("tokens", {})
tokens["id_token"] = new_id_token
# Note this does not touch the access_token?
tokens["refresh_token"] = new_refresh_token
tokens["last_refresh"] = (
datetime.datetime.now(datetime.timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
with open(auth_path, "w", encoding="utf-8") as fp:
if hasattr(os, "fchmod"):
os.fchmod(fp.fileno(), 0o600)
json.dump(existing, fp, indent=2)
except Exception as err:
eprint("Unable to update refresh token in auth file:", err)
if not new_id_claims:
# Still couldn't parse claims.
return
id_token = new_id_token
id_claims = new_id_claims
# Done refreshing credentials: now try to redeem credits.
if not id_token:
eprint("No ID token available, cannot redeem credits.")
return
auth_claims = id_claims.get("https://api.openai.com/auth", {})
# Subscription eligibility check (Plus or Pro, >7 days active)
sub_start_str = auth_claims.get("chatgpt_subscription_active_start")
if isinstance(sub_start_str, str):
try:
sub_start_ts = datetime.datetime.fromisoformat(sub_start_str.rstrip("Z"))
if datetime.datetime.now(
datetime.timezone.utc
) - sub_start_ts < datetime.timedelta(days=7):
eprint(
"Sorry, your subscription must be active for more than 7 days to redeem credits."
)
return
except ValueError:
# Malformed; ignore
pass
completed_onboarding = bool(auth_claims.get("completed_platform_onboarding"))
is_org_owner = bool(auth_claims.get("is_org_owner"))
needs_setup = not completed_onboarding and is_org_owner
plan_type = auth_claims.get("chatgpt_plan_type")
if needs_setup or plan_type not in {"plus", "pro"}:
eprint("Only users with Plus or Pro subscriptions can redeem free API credits.")
return
api_host = (
"https://api.openai.com"
if issuer == "https://auth.openai.com"
else "https://api.openai.org"
)
try:
redeem_payload = json.dumps({"id_token": id_token}).encode()
req = urllib.request.Request(
url=f"{api_host}/v1/billing/redeem_credits",
data=redeem_payload,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
redeem_data = json.loads(resp.read().decode())
granted = redeem_data.get("granted_chatgpt_subscriber_api_credits", 0)
if granted and granted > 0:
eprint(
f"""Thanks for being a ChatGPT {'Plus' if plan_type=='plus' else 'Pro'} subscriber!
If you haven't already redeemed, you should receive {'$5' if plan_type=='plus' else '$50'} in API credits.
Credits: https://platform.openai.com/settings/organization/billing/credit-grants
More info: https://help.openai.com/en/articles/11381614""",
)
else:
eprint(
f"""It looks like no credits were granted:
{json.dumps(redeem_data, indent=2)}
Credits: https://platform.openai.com/settings/organization/billing/credit-grants
More info: https://help.openai.com/en/articles/11381614"""
)
except Exception as err:
eprint("Credit redemption request failed:", err)
def _generate_pkce() -> PkceCodes:
"""Generate PKCE *code_verifier* and *code_challenge* (S256)."""
code_verifier = secrets.token_hex(64)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return PkceCodes(code_verifier, code_challenge)
def eprint(*args, **kwargs) -> None:
print(*args, file=sys.stderr, **kwargs)
# Parse ID-token claims (if provided)
#
# interface IDTokenClaims {
# "exp": number; // specifically, an int
# "https://api.openai.com/auth": {
# organization_id: string;
# project_id: string;
# completed_platform_onboarding: boolean;
# is_org_owner: boolean;
# chatgpt_subscription_active_start: string;
# chatgpt_subscription_active_until: string;
# chatgpt_plan_type: string;
# };
# }
def parse_id_token_claims(id_token: str) -> Dict[str, Any] | None:
if id_token:
parts = id_token.split(".")
if len(parts) == 3:
return _decode_jwt_segment(parts[1])
return None
def _decode_jwt_segment(segment: str) -> Dict[str, Any]:
"""Return the decoded JSON payload from a JWT segment.
Adds required padding for urlsafe_b64decode.
"""
padded = segment + "=" * (-len(segment) % 4)
try:
data = base64.urlsafe_b64decode(padded.encode())
return json.loads(data.decode())
except Exception:
return {}
def _current_timestamp_ms() -> int:
return int(time.time() * 1000)
LOGIN_SUCCESS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign into Codex CLI</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>
.container {
margin: auto;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: white;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.inner-container {
width: 400px;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: inline-flex;
}
.content {
align-self: stretch;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: flex;
}
.svg-wrapper {
position: relative;
}
.title {
text-align: center;
color: var(--text-primary, #0D0D0D);
font-size: 28px;
font-weight: 400;
line-height: 36.40px;
word-wrap: break-word;
}
.setup-box {
width: 600px;
padding: 16px 20px;
background: var(--bg-primary, white);
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
border-radius: 16px;
outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
outline-offset: -1px;
justify-content: flex-start;
align-items: center;
gap: 16px;
display: inline-flex;
}
.setup-content {
flex: 1 1 0;
justify-content: flex-start;
align-items: center;
gap: 24px;
display: flex;
}
.setup-text {
flex: 1 1 0;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
display: inline-flex;
}
.setup-title {
align-self: stretch;
color: var(--text-primary, #0D0D0D);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
}
.setup-description {
align-self: stretch;
color: var(--text-secondary, #5D5D5D);
font-size: 14px;
font-weight: 400;
line-height: 20px;
word-wrap: break-word;
}
.redirect-box {
justify-content: flex-start;
align-items: center;
gap: 8px;
display: flex;
}
.close-button,
.redirect-button {
height: 28px;
padding: 8px 16px;
background: var(--interactive-bg-primary-default, #0D0D0D);
border-radius: 999px;
justify-content: center;
align-items: center;
gap: 4px;
display: flex;
}
.close-button,
.redirect-text {
color: var(--interactive-label-primary-default, white);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="inner-container">
<div class="content">
<div data-svg-wrapper class="svg-wrapper">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.6665 28.0003C4.6665 15.1137 15.1132 4.66699 27.9998 4.66699C40.8865 4.66699 51.3332 15.1137 51.3332 28.0003C51.3332 40.887 40.8865 51.3337 27.9998 51.3337C15.1132 51.3337 4.6665 40.887 4.6665 28.0003ZM37.5093 18.5088C36.4554 17.7672 34.9999 18.0203 34.2583 19.0742L24.8508 32.4427L20.9764 28.1808C20.1095 27.2272 18.6338 27.1569 17.6803 28.0238C16.7267 28.8906 16.6565 30.3664 17.5233 31.3199L23.3566 37.7366C23.833 38.2606 24.5216 38.5399 25.2284 38.4958C25.9353 38.4517 26.5838 38.089 26.9914 37.5098L38.0747 21.7598C38.8163 20.7059 38.5632 19.2504 37.5093 18.5088Z" fill="var(--green-400, #04B84C)"/>
</svg>
</div>
<div class="title">Signed in to Codex CLI</div>
</div>
<div class="close-box" style="display: none;">
<div class="setup-description">You may now close this page</div>
</div>
<div class="setup-box" style="display: none;">
<div class="setup-content">
<div class="setup-text">
<div class="setup-title">Finish setting up your API organization</div>
<div class="setup-description">Add a payment method to use your organization.</div>
</div>
<div class="redirect-box">
<div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
<div class="redirect-text">Redirecting in 3s...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const needsSetup = params.get('needs_setup') === 'true';
const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
const orgId = params.get('org_id');
const projectId = params.get('project_id');
const planType = params.get('plan_type');
const idToken = params.get('id_token');
// Show different message and optional redirect when setup is required
if (needsSetup) {
const setupBox = document.querySelector('.setup-box');
setupBox.style.display = 'flex';
const redirectUrlObj = new URL('/org-setup', platformUrl);
redirectUrlObj.searchParams.set('p', planType);
redirectUrlObj.searchParams.set('t', idToken);
redirectUrlObj.searchParams.set('with_org', orgId);
redirectUrlObj.searchParams.set('project_id', projectId);
const redirectUrl = redirectUrlObj.toString();
const message = document.querySelector('.redirect-text');
let countdown = 3;
function tick() {
message.textContent =
'Redirecting in ' + countdown + 's…';
if (countdown === 0) {
window.location.replace(redirectUrl);
} else {
countdown -= 1;
setTimeout(tick, 1000);
}
}
tick();
} else {
const closeBox = document.querySelector('.close-box');
closeBox.style.display = 'flex';
}
})();
</script>
</body>
</html>"""
# Unconditionally call `main()` instead of gating it behind
# `if __name__ == "__main__"` because this script is either:
#
# - invoked as a string passed to `python3 -c`
# - run via `python3 login_with_chatgpt.py` for testing as part of local
# development
main()

View File

@@ -0,0 +1,686 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::DateTime;
use chrono::Utc;
use http_body_util::Full as BodyFull;
use hyper::Method;
use hyper::Request;
use hyper::Response;
use hyper::StatusCode;
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
use rand::RngCore;
use serde_json::json;
use sha2::Digest;
use sha2::Sha256;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use crate::CLIENT_ID;
use crate::TokenData;
const REQUIRED_PORT: u16 = 1455;
const URL_BASE: &str = "http://localhost:1455";
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
#[derive(Clone)]
struct PkceCodes {
code_verifier: String,
code_challenge: String,
}
impl PkceCodes {
fn generate() -> Self {
let code_verifier = random_hex(64);
let digest = Sha256::digest(code_verifier.as_bytes());
let code_challenge = URL_SAFE_NO_PAD.encode(digest);
Self {
code_verifier,
code_challenge,
}
}
}
#[derive(Clone)]
struct ServerState {
codex_home: PathBuf,
issuer: String,
client_id: String,
redirect_uri: String,
pkce: PkceCodes,
state: String,
shutdown_tx: Arc<Mutex<Option<oneshot::Sender<()>>>>,
}
impl ServerState {
fn auth_url(&self) -> String {
let params: Vec<(String, String)> = vec![
("response_type".into(), "code".into()),
("client_id".into(), self.client_id.clone()),
("redirect_uri".into(), self.redirect_uri.clone()),
("scope".into(), "openid profile email offline_access".into()),
("code_challenge".into(), self.pkce.code_challenge.clone()),
("code_challenge_method".into(), "S256".into()),
("id_token_add_organizations".into(), "true".into()),
("state".into(), self.state.clone()),
];
let query = serde_urlencode(&params);
format!("{}/oauth/authorize?{}", self.issuer, query)
}
}
fn serde_urlencode(params: &[(String, String)]) -> String {
let mut s = url::form_urlencoded::Serializer::new(String::new());
for (k, v) in params.iter() {
s.append_pair(k, v);
}
s.finish()
}
fn random_hex(len: usize) -> String {
let mut bytes = vec![0u8; len];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()
}
// Public entry point used by lib.rs
pub async fn run_login_server(codex_home: &Path) -> std::io::Result<()> {
let addr = SocketAddr::from(([127, 0, 0, 1], REQUIRED_PORT));
let listener = TcpListener::bind(addr).await?;
let pkce = PkceCodes::generate();
let state = random_hex(32);
let redirect_uri = format!("{URL_BASE}/auth/callback");
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let server_state = ServerState {
codex_home: codex_home.to_path_buf(),
issuer: DEFAULT_ISSUER.to_string(),
client_id: CLIENT_ID.to_string(),
redirect_uri,
pkce,
state,
shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))),
};
let auth_url = server_state.auth_url();
// Try to open a browser, but don't fail if we can't.
if let Err(err) = open::that_detached(&auth_url) {
eprintln!("Failed to open browser: {err}");
}
eprintln!("If your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}");
let state_arc = Arc::new(server_state);
let accept_task = tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(p) => p,
Err(e) => {
eprintln!("Accept error: {e}");
continue;
}
};
let io = TokioIo::new(stream);
let state_inner = state_arc.clone();
tokio::spawn(async move {
if let Err(err) = http1::Builder::new()
.serve_connection(
io,
service_fn(|req| handle_request(req, state_inner.clone())),
)
.await
{
eprintln!("server connection error: {err}");
}
});
}
});
// Wait for shutdown signal
let _ = shutdown_rx.await;
accept_task.abort();
let _ = accept_task.await;
Ok(())
}
#[allow(clippy::unwrap_used)]
async fn handle_request(
req: Request<hyper::body::Incoming>,
state: Arc<ServerState>,
) -> Result<Response<BodyFull<Bytes>>, Infallible> {
let method = req.method().clone();
let path = req.uri().path().to_string();
let resp = match (method, path.as_str()) {
(Method::GET, "/success") => Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/html; charset=utf-8")
.body(BodyFull::from(Bytes::from(LOGIN_SUCCESS_HTML)))
.unwrap(),
(Method::GET, "/auth/callback") => match handle_auth_callback(req, state.clone()).await {
Ok(resp) => resp,
Err((status, msg)) => {
let builder = Response::builder().status(status);
if let Ok(resp) = builder.body(BodyFull::from(Bytes::from(msg.clone()))) {
// On error, shut down the server after responding
send_shutdown(&state);
resp
} else {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(BodyFull::from(Bytes::from_static(b"Internal Server Error")))
.unwrap()
}
}
},
_ => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(BodyFull::from(Bytes::from_static(b"Not Found")))
.unwrap(),
};
Ok(resp)
}
fn send_shutdown(state: &ServerState) {
if let Ok(mut guard) = state.shutdown_tx.lock() {
if let Some(tx) = guard.take() {
let _ = tx.send(());
}
}
}
fn decode_jwt_segment(segment: &str) -> serde_json::Value {
let data = URL_SAFE_NO_PAD
.decode(segment)
.map_err(|_| ())
.and_then(|bytes| String::from_utf8(bytes).map_err(|_| ()))
.ok();
if let Some(s) = data {
serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
}
}
#[allow(clippy::unwrap_used)]
async fn handle_auth_callback(
req: Request<hyper::body::Incoming>,
state: Arc<ServerState>,
) -> Result<Response<BodyFull<Bytes>>, (StatusCode, String)> {
let query_str = req.uri().query().unwrap_or("");
let query: HashMap<String, String> = url::form_urlencoded::parse(query_str.as_bytes())
.into_owned()
.collect();
// Validate state
if query.get("state").map(String::as_str) != Some(&state.state) {
return Err((StatusCode::BAD_REQUEST, "State parameter mismatch".into()));
}
let code = match query.get("code") {
Some(c) if !c.is_empty() => c.clone(),
_ => return Err((StatusCode::BAD_REQUEST, "Missing authorization code".into())),
};
// 1. Authorization-code -> (id_token, access_token, refresh_token)
let token_endpoint = format!("{}/oauth/token", state.issuer);
let client = reqwest::Client::new();
let form = [
("grant_type", "authorization_code"),
("code", code.as_str()),
("redirect_uri", state.redirect_uri.as_str()),
("client_id", state.client_id.as_str()),
("code_verifier", state.pkce.code_verifier.as_str()),
];
let resp = client
.post(&token_endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&form)
.send()
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Token request failed: {e}"),
)
})?;
if !resp.status().is_success() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Token request failed: {}", resp.status()),
));
}
let payload: serde_json::Value = resp.json().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid token response: {e}"),
)
})?;
let id_token = payload
.get("id_token")
.and_then(|v| v.as_str())
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Missing id_token".into()))?
.to_string();
let access_token = payload
.get("access_token")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Missing access_token".into(),
))?
.to_string();
let refresh_token = payload
.get("refresh_token")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Missing refresh_token".into(),
))?
.to_string();
// Extract chatgpt_account_id from id_token claims
let id_token_parts: Vec<&str> = id_token.split('.').collect();
if id_token_parts.len() != 3 {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid ID token".into()));
}
let id_token_claims = decode_jwt_segment(id_token_parts[1]);
let auth_claims = id_token_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or_else(|| json!({}));
let account_id = auth_claims
.get("chatgpt_account_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let token_data = TokenData {
id_token: id_token.clone(),
access_token: access_token.clone(),
refresh_token: refresh_token.clone(),
account_id,
};
// Parse access_token claims
let access_token_parts: Vec<&str> = access_token.split('.').collect();
if access_token_parts.len() != 3 {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid access token".into(),
));
}
let access_token_claims = decode_jwt_segment(access_token_parts[1]);
let token_claims = id_token_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or_else(|| json!({}));
let access_claims = access_token_claims
.get("https://api.openai.com/auth")
.cloned()
.unwrap_or_else(|| json!({}));
let org_id = token_claims
.get("organization_id")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Missing organization in id_token claims".into(),
))?
.to_string();
let project_id = token_claims
.get("project_id")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Missing project in id_token claims".into(),
))?
.to_string();
// 2. Token exchange to obtain API key
let today = Utc::now().format("%Y-%m-%d").to_string();
let rand_id = random_hex(12);
let exchange_name = format!("Codex CLI [auto-generated] ({today}) [{rand_id}]");
let exchange_form: Vec<(String, String)> = vec![
(
"grant_type".into(),
"urn:ietf:params:oauth:grant-type:token-exchange".into(),
),
("client_id".into(), state.client_id.clone()),
("requested_token".into(), "openai-api-key".into()),
("subject_token".into(), id_token.clone()),
(
"subject_token_type".into(),
"urn:ietf:params:oauth:token-type:id_token".into(),
),
("name".into(), exchange_name),
];
let exchange_resp = reqwest::Client::new()
.post(&token_endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&exchange_form)
.send()
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Exchange request failed: {e}"),
)
})?;
if !exchange_resp.status().is_success() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Exchange request failed: {}", exchange_resp.status()),
));
}
let exchange_payload: serde_json::Value = exchange_resp.json().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid exchange response: {e}"),
)
})?;
let exchanged_access_token = exchange_payload
.get("access_token")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Missing access_token in exchange".into(),
))?
.to_string();
let completed_onboarding = token_claims
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_org_owner = token_claims
.get("is_org_owner")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let needs_setup = !completed_onboarding && is_org_owner;
let chatgpt_plan_type = access_claims
.get("chatgpt_plan_type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let platform_url = if state.issuer == "https://auth.openai.com" {
"https://platform.openai.com"
} else {
"https://platform.api.openai.org"
};
let success_params: Vec<(String, String)> = vec![
("id_token".into(), id_token.clone()),
(
"needs_setup".into(),
if needs_setup { "true" } else { "false" }.into(),
),
("org_id".into(), org_id.clone()),
("project_id".into(), project_id.clone()),
("plan_type".into(), chatgpt_plan_type.clone()),
("platform_url".into(), platform_url.into()),
];
let success_url = format!("{}/success?{}", URL_BASE, serde_urlencode(&success_params));
// Best-effort credit redemption; errors are logged but do not interrupt flow.
if let Err(err) = maybe_redeem_credits(
&state.issuer,
&state.client_id,
Some(&id_token),
&refresh_token,
&state.codex_home,
&access_claims,
&token_claims,
)
.await
{
eprintln!("Unable to redeem ChatGPT subscriber API credits: {err}");
}
// Persist auth.json
let last_refresh: DateTime<Utc> = Utc::now();
let auth_json_value = json!({
"OPENAI_API_KEY": exchanged_access_token,
"tokens": {
"id_token": token_data.id_token,
"access_token": token_data.access_token,
"refresh_token": token_data.refresh_token,
"account_id": token_data.account_id,
},
"last_refresh": last_refresh.to_rfc3339().replace("+00:00", "Z"),
});
if let Err(err) = write_auth_file(&state.codex_home, auth_json_value).await {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unable to persist auth file: {err}"),
));
}
// Redirect to success URL
let resp = Response::builder()
.status(StatusCode::FOUND)
.header("Location", success_url)
.body(BodyFull::from(Bytes::new()))
.unwrap();
// Signal shutdown afterwards
send_shutdown(&state);
Ok(resp)
}
async fn write_auth_file(codex_home: &Path, contents: serde_json::Value) -> std::io::Result<()> {
if !codex_home.is_dir() {
std::fs::create_dir_all(codex_home)?;
}
let auth_path = codex_home.join("auth.json");
let mut options = std::fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&auth_path)?;
let data = serde_json::to_vec_pretty(&contents)?;
use std::io::Write as _;
file.write_all(&data)?;
file.flush()?;
Ok(())
}
async fn maybe_redeem_credits(
issuer: &str,
client_id: &str,
id_token_opt: Option<&str>,
refresh_token: &str,
codex_home: &Path,
access_claims: &serde_json::Value,
token_claims: &serde_json::Value,
) -> Result<(), String> {
let mut id_token = id_token_opt.unwrap_or("").to_string();
let mut id_claims = parse_id_token_claims(&id_token);
let token_expired = match id_claims
.as_ref()
.and_then(|c| c.get("exp").and_then(|v| v.as_i64()))
{
Some(exp) => Utc::now().timestamp_millis() >= exp * 1000,
None => true,
};
if token_expired {
eprintln!("Refreshing credentials...");
let payload = json!({
"client_id": client_id,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "openid profile email",
});
let resp = reqwest::Client::new()
.post("https://auth.openai.com/oauth/token")
.header("Content-Type", "application/json")
.body(payload.to_string())
.send()
.await
.map_err(|e| format!("Unable to refresh ID token via token-exchange: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
"Unable to refresh ID token via token-exchange: {}",
resp.status()
));
}
let refresh_data: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid refresh response: {e}"))?;
let new_id_token = refresh_data
.get("id_token")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let new_refresh_token = refresh_data
.get("refresh_token")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if !new_id_token.is_empty() && !new_refresh_token.is_empty() {
id_token = new_id_token.clone();
id_claims = parse_id_token_claims(&new_id_token);
// Update auth.json tokens
let auth_path = codex_home.join("auth.json");
if let Ok(mut file) = std::fs::File::open(&auth_path) {
let mut s = String::new();
use std::io::Read as _;
let _ = file.read_to_string(&mut s);
if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&s) {
if existing.get("tokens").and_then(|t| t.as_object()).is_none() {
existing["tokens"] = json!({});
}
let tokens = existing["tokens"]
.as_object_mut()
.ok_or_else(|| format!("Invalid auth.json: {s}"))?;
tokens.insert("id_token".into(), json!(new_id_token));
tokens.insert("refresh_token".into(), json!(new_refresh_token));
existing["last_refresh"] =
json!(Utc::now().to_rfc3339().replace("+00:00", "Z"));
// write back
let _ = write_auth_file(codex_home, existing).await;
}
}
} else {
// Couldn't refresh; proceed without redeeming
return Ok(());
}
}
if id_token.is_empty() {
eprintln!("No ID token available, cannot redeem credits.");
return Ok(());
}
let auth_claims = id_claims
.as_ref()
.and_then(|v| v.get("https://api.openai.com/auth"))
.cloned()
.unwrap_or_else(|| json!({}));
// Subscription eligibility check (Plus or Pro, >7 days active)
if let Some(sub_start_str) = auth_claims
.get("chatgpt_subscription_active_start")
.and_then(|v| v.as_str())
{
if let Ok(sub_start_ts) = chrono::DateTime::parse_from_rfc3339(sub_start_str) {
if Utc::now() - sub_start_ts.with_timezone(&Utc) < chrono::Duration::days(7) {
eprintln!(
"Sorry, your subscription must be active for more than 7 days to redeem credits."
);
return Ok(());
}
}
}
let completed_onboarding = token_claims
.get("completed_platform_onboarding")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_org_owner = token_claims
.get("is_org_owner")
.and_then(|v| v.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("");
if needs_setup || (plan_type != "plus" && plan_type != "pro") {
eprintln!("Only users with Plus or Pro subscriptions can redeem free API credits.");
return Ok(());
}
let api_host = if issuer == "https://auth.openai.com" {
"https://api.openai.com"
} else {
"https://api.openai.org"
};
let redeem_payload = json!({"id_token": id_token});
let resp = reqwest::Client::new()
.post(format!("{api_host}/v1/billing/redeem_credits"))
.header("Content-Type", "application/json")
.body(redeem_payload.to_string())
.send()
.await
.map_err(|e| format!("Credit redemption request failed: {e}"))?;
let redeem_data: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid redeem response: {e}"))?;
let granted = redeem_data
.get("granted_chatgpt_subscriber_api_credits")
.and_then(|v| v.as_i64())
.unwrap_or(0);
if granted > 0 {
eprintln!(
"Thanks for being a ChatGPT {} subscriber!\nIf you haven't already redeemed, you should receive {} in API credits.\n\nCredits: https://platform.openai.com/settings/organization/billing/credit-grants\nMore info: https://help.openai.com/en/articles/11381614",
if plan_type == "plus" { "Plus" } else { "Pro" },
if plan_type == "plus" { "$5" } else { "$50" }
);
} else {
eprintln!(
"It looks like no credits were granted:\n\n{}\n\nCredits: https://platform.openai.com/settings/organization/billing/credit-grants\nMore info: https://help.openai.com/en/articles/11381614",
serde_json::to_string_pretty(&redeem_data).unwrap_or_default()
);
}
Ok(())
}
fn parse_id_token_claims(id_token: &str) -> Option<serde_json::Value> {
if !id_token.is_empty() {
let parts: Vec<&str> = id_token.split('.').collect();
if parts.len() == 3 {
return Some(decode_jwt_segment(parts[1]));
}
}
None
}
const LOGIN_SUCCESS_HTML: &str = include_str!("static/success.html");

View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign into Codex CLI</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>
.container {
margin: auto;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: white;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.inner-container {
width: 400px;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: inline-flex;
}
.content {
align-self: stretch;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: flex;
}
.svg-wrapper {
position: relative;
}
.title {
text-align: center;
color: var(--text-primary, #0D0D0D);
font-size: 28px;
font-weight: 400;
line-height: 36.40px;
word-wrap: break-word;
}
.setup-box {
width: 600px;
padding: 16px 20px;
background: var(--bg-primary, white);
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
border-radius: 16px;
outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
outline-offset: -1px;
justify-content: flex-start;
align-items: center;
gap: 16px;
display: inline-flex;
}
.setup-content {
flex: 1 1 0;
justify-content: flex-start;
align-items: center;
gap: 24px;
display: flex;
}
.setup-text {
flex: 1 1 0;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
display: inline-flex;
}
.setup-title {
align-self: stretch;
color: var(--text-primary, #0D0D0D);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
}
.setup-description {
align-self: stretch;
color: var(--text-secondary, #5D5D5D);
font-size: 14px;
font-weight: 400;
line-height: 20px;
word-wrap: break-word;
}
.redirect-box {
justify-content: flex-start;
align-items: center;
gap: 8px;
display: flex;
}
.close-button,
.redirect-button {
height: 28px;
padding: 8px 16px;
background: var(--interactive-bg-primary-default, #0D0D0D);
border-radius: 999px;
justify-content: center;
align-items: center;
gap: 4px;
display: flex;
}
.close-button,
.redirect-text {
color: var(--interactive-label-primary-default, white);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="inner-container">
<div class="content">
<div data-svg-wrapper class="svg-wrapper">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.6665 28.0003C4.6665 15.1137 15.1132 4.66699 27.9998 4.66699C40.8865 4.66699 51.3332 15.1137 51.3332 28.0003C51.3332 40.887 40.8865 51.3337 27.9998 51.3337C15.1132 51.3337 4.6665 40.887 4.6665 28.0003ZM37.5093 18.5088C36.4554 17.7672 34.9999 18.0203 34.2583 19.0742L24.8508 32.4427L20.9764 28.1808C20.1095 27.2272 18.6338 27.1569 17.6803 28.0238C16.7267 28.8906 16.6565 30.3664 17.5233 31.3199L23.3566 37.7366C23.833 38.2606 24.5216 38.5399 25.2284 38.4958C25.9353 38.4517 26.5838 38.089 26.9914 37.5098L38.0747 21.7598C38.8163 20.7059 38.5632 19.2504 37.5093 18.5088Z" fill="var(--green-400, #04B84C)"/>
</svg>
</div>
<div class="title">Signed in to Codex CLI</div>
</div>
<div class="close-box" style="display: none;">
<div class="setup-description">You may now close this page</div>
</div>
<div class="setup-box" style="display: none;">
<div class="setup-content">
<div class="setup-text">
<div class="setup-title">Finish setting up your API organization</div>
<div class="setup-description">Add a payment method to use your organization.</div>
</div>
<div class="redirect-box">
<div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
<div class="redirect-text">Redirecting in 3s...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const needsSetup = params.get('needs_setup') === 'true';
const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
const orgId = params.get('org_id');
const projectId = params.get('project_id');
const planType = params.get('plan_type');
const idToken = params.get('id_token');
// Show different message and optional redirect when setup is required
if (needsSetup) {
const setupBox = document.querySelector('.setup-box');
setupBox.style.display = 'flex';
const redirectUrlObj = new URL('/org-setup', platformUrl);
redirectUrlObj.searchParams.set('p', planType);
redirectUrlObj.searchParams.set('t', idToken);
redirectUrlObj.searchParams.set('with_org', orgId);
redirectUrlObj.searchParams.set('project_id', projectId);
const redirectUrl = redirectUrlObj.toString();
const message = document.querySelector('.redirect-text');
let countdown = 3;
function tick() {
message.textContent =
'Redirecting in ' + countdown + 's…';
if (countdown === 0) {
window.location.replace(redirectUrl);
} else {
countdown -= 1;
setTimeout(tick, 1000);
}
}
tick();
} else {
const closeBox = document.querySelector('.close-box');
closeBox.style.display = 'flex';
}
})();
</script>
</body>
</html>

View File

@@ -157,7 +157,7 @@ pub async fn run_main(
}
// Spawn a task to run the login command.
// Block until the login command is finished.
let new_key = codex_login::login_with_chatgpt(&config.codex_home, false).await?;
let new_key = codex_login::login_with_chatgpt(&config.codex_home).await?;
set_openai_api_key(new_key);
std::io::stdout().write_all(b"Excellent, looks like that worked. Let's get started!\n")?;
}