mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
Compare commits
2 Commits
codex-cli-
...
nornagon/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81453589da | ||
|
|
80b4b7c77a |
55
codex-rs/Cargo.lock
generated
55
codex-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
686
codex-rs/login/src/server.rs
Normal file
686
codex-rs/login/src/server.rs
Normal 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(¶ms);
|
||||
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");
|
||||
185
codex-rs/login/src/static/success.html
Normal file
185
codex-rs/login/src/static/success.html
Normal 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>
|
||||
@@ -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")?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user