mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add ChatGPT device-code login to app server (#15525)
## Problem
App-server clients could only initiate ChatGPT login through the browser
callback flow, even though the shared login crate already supports
device-code auth. That left VS Code, Codex App, and other app-server
clients without a first-class way to use the existing device-code
backend when browser redirects are brittle or when the client UX wants
to own the login ceremony.
## Mental model
This change adds a second ChatGPT login start path to app-server:
clients can now call `account/login/start` with `type:
"chatgptDeviceCode"`. App-server immediately returns a `loginId` plus
the device-code UX payload (`verificationUrl` and `userCode`), then
completes the login asynchronously in the background using the existing
`codex_login` polling flow. Successful device-code login still resolves
to ordinary `chatgpt` auth, and completion continues to flow through the
existing `account/login/completed` and `account/updated` notifications.
## Non-goals
This does not introduce a new auth mode, a new account shape, or a
device-code eligibility discovery API. It also does not add automatic
fallback to browser login in core; clients remain responsible for
choosing when to request device code and whether to retry with a
different UX if the backend/admin policy rejects it.
## Tradeoffs
We intentionally keep `login_chatgpt_common` as a local validation
helper instead of turning it into a capability probe. Device-code
eligibility is checked by actually calling `request_device_code`, which
means policy-disabled cases surface as an immediate request error rather
than an async completion event. We also keep the active-login state
machine minimal: browser and device-code logins share the same public
cancel contract, but device-code cancellation is implemented with a
local cancel token rather than a larger cross-crate refactor.
## Architecture
The protocol grows a new `chatgptDeviceCode` request/response variant in
app-server v2. On the server side, the new handler reuses the existing
ChatGPT login precondition checks, calls `request_device_code`, returns
the device-code payload, and then spawns a background task that waits on
either cancellation or `complete_device_code_login`. On success, it
reuses the existing auth reload and cloud-requirements refresh path
before emitting `account/login/completed` success and `account/updated`.
On failure or cancellation, it emits only `account/login/completed`
failure. The existing `account/login/cancel { loginId }` contract
remains unchanged and now works for both browser and device-code
attempts.
## Tests
Added protocol serialization coverage for the new request/response
variant, plus app-server tests for device-code success, failure, cancel,
and start-time rejection behavior. Existing browser ChatGPT login
coverage remains in place to show that the callback-based flow is
unchanged.
This commit is contained in:
committed by
GitHub
Unverified
parent
dd30c8eedd
commit
47a9e2e084
@@ -1309,14 +1309,14 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
|
||||
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`.
|
||||
|
||||
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
|
||||
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"`; Codex persists tokens to disk and refreshes them automatically.
|
||||
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically.
|
||||
|
||||
### API Overview
|
||||
|
||||
- `account/read` — fetch current account info; optionally refresh tokens.
|
||||
- `account/login/start` — begin login (`apiKey`, `chatgpt`).
|
||||
- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`).
|
||||
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
|
||||
- `account/login/cancel` — cancel a pending ChatGPT login by `loginId`.
|
||||
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
|
||||
- `account/logout` — sign out; triggers `account/updated`.
|
||||
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
|
||||
@@ -1380,26 +1380,40 @@ Field notes:
|
||||
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
|
||||
```
|
||||
|
||||
### 4) Cancel a ChatGPT login
|
||||
### 4) Log in with ChatGPT (device code flow)
|
||||
|
||||
1. Start:
|
||||
```json
|
||||
{ "method": "account/login/start", "id": 4, "params": { "type": "chatgptDeviceCode" } }
|
||||
{ "id": 4, "result": { "type": "chatgptDeviceCode", "loginId": "<uuid>", "verificationUrl": "https://auth.openai.com/codex/device", "userCode": "ABCD-1234" } }
|
||||
```
|
||||
2. Show `verificationUrl` and `userCode` to the user; the frontend owns the UX.
|
||||
3. Wait for notifications:
|
||||
```json
|
||||
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
|
||||
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
|
||||
```
|
||||
|
||||
### 5) Cancel a ChatGPT login
|
||||
|
||||
```json
|
||||
{ "method": "account/login/cancel", "id": 4, "params": { "loginId": "<uuid>" } }
|
||||
{ "method": "account/login/cancel", "id": 5, "params": { "loginId": "<uuid>" } }
|
||||
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": false, "error": "…" } }
|
||||
```
|
||||
|
||||
### 5) Logout
|
||||
### 6) Logout
|
||||
|
||||
```json
|
||||
{ "method": "account/logout", "id": 5 }
|
||||
{ "id": 5, "result": {} }
|
||||
{ "method": "account/logout", "id": 6 }
|
||||
{ "id": 6, "result": {} }
|
||||
{ "method": "account/updated", "params": { "authMode": null, "planType": null } }
|
||||
```
|
||||
|
||||
### 6) Rate limits (ChatGPT)
|
||||
### 7) Rate limits (ChatGPT)
|
||||
|
||||
```json
|
||||
{ "method": "account/rateLimits/read", "id": 6 }
|
||||
{ "id": 6, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } }
|
||||
{ "method": "account/rateLimits/read", "id": 7 }
|
||||
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } }
|
||||
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
|
||||
```
|
||||
|
||||
|
||||
@@ -249,6 +249,8 @@ use codex_git_utils::git_diff_to_remote;
|
||||
use codex_login::ServerOptions as LoginServerOptions;
|
||||
use codex_login::ShutdownHandle;
|
||||
use codex_login::auth::login_with_chatgpt_auth_tokens;
|
||||
use codex_login::complete_device_code_login;
|
||||
use codex_login::request_device_code;
|
||||
use codex_login::run_login_server;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
@@ -339,12 +341,39 @@ struct ThreadListFilters {
|
||||
search_term: Option<String>,
|
||||
}
|
||||
|
||||
// Duration before a ChatGPT login attempt is abandoned.
|
||||
// Duration before a browser ChatGPT login attempt is abandoned.
|
||||
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
|
||||
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
struct ActiveLogin {
|
||||
shutdown_handle: ShutdownHandle,
|
||||
login_id: Uuid,
|
||||
|
||||
enum ActiveLogin {
|
||||
Browser {
|
||||
shutdown_handle: ShutdownHandle,
|
||||
login_id: Uuid,
|
||||
},
|
||||
DeviceCode {
|
||||
cancel: CancellationToken,
|
||||
login_id: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
impl ActiveLogin {
|
||||
fn login_id(&self) -> Uuid {
|
||||
match self {
|
||||
ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => {
|
||||
*login_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
match self {
|
||||
ActiveLogin::Browser {
|
||||
shutdown_handle, ..
|
||||
} => shutdown_handle.shutdown(),
|
||||
ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -365,7 +394,7 @@ enum ThreadShutdownResult {
|
||||
|
||||
impl Drop for ActiveLogin {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown_handle.shutdown();
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -954,6 +983,9 @@ impl CodexMessageProcessor {
|
||||
LoginAccountParams::Chatgpt => {
|
||||
self.login_chatgpt_v2(request_id).await;
|
||||
}
|
||||
LoginAccountParams::ChatgptDeviceCode => {
|
||||
self.login_chatgpt_device_code_v2(request_id).await;
|
||||
}
|
||||
LoginAccountParams::ChatgptAuthTokens {
|
||||
access_token,
|
||||
chatgpt_account_id,
|
||||
@@ -1074,7 +1106,7 @@ impl CodexMessageProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
Ok(LoginServerOptions {
|
||||
let mut opts = LoginServerOptions {
|
||||
open_browser: false,
|
||||
..LoginServerOptions::new(
|
||||
config.codex_home.clone(),
|
||||
@@ -1082,7 +1114,32 @@ impl CodexMessageProcessor {
|
||||
config.forced_chatgpt_workspace_id.clone(),
|
||||
config.cli_auth_credentials_store_mode,
|
||||
)
|
||||
})
|
||||
};
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR)
|
||||
&& !issuer.trim().is_empty()
|
||||
{
|
||||
opts.issuer = issuer;
|
||||
}
|
||||
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError {
|
||||
let is_not_found = err.kind() == std::io::ErrorKind::NotFound;
|
||||
JSONRPCErrorError {
|
||||
code: if is_not_found {
|
||||
INVALID_REQUEST_ERROR_CODE
|
||||
} else {
|
||||
INTERNAL_ERROR_CODE
|
||||
},
|
||||
message: if is_not_found {
|
||||
err.to_string()
|
||||
} else {
|
||||
format!("failed to request device code: {err}")
|
||||
},
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn login_chatgpt_v2(&mut self, request_id: ConnectionRequestId) {
|
||||
@@ -1098,7 +1155,7 @@ impl CodexMessageProcessor {
|
||||
if let Some(existing) = guard.take() {
|
||||
drop(existing);
|
||||
}
|
||||
*guard = Some(ActiveLogin {
|
||||
*guard = Some(ActiveLogin::Browser {
|
||||
shutdown_handle: shutdown_handle.clone(),
|
||||
login_id,
|
||||
});
|
||||
@@ -1168,7 +1225,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
|
||||
let mut guard = active_login.lock().await;
|
||||
if guard.as_ref().map(|l| l.login_id) == Some(login_id) {
|
||||
if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) {
|
||||
*guard = None;
|
||||
}
|
||||
});
|
||||
@@ -1194,12 +1251,114 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn login_chatgpt_device_code_v2(&mut self, request_id: ConnectionRequestId) {
|
||||
match self.login_chatgpt_common().await {
|
||||
Ok(opts) => match request_device_code(&opts).await {
|
||||
Ok(device_code) => {
|
||||
let login_id = Uuid::new_v4();
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
{
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if let Some(existing) = guard.take() {
|
||||
drop(existing);
|
||||
}
|
||||
*guard = Some(ActiveLogin::DeviceCode {
|
||||
cancel: cancel.clone(),
|
||||
login_id,
|
||||
});
|
||||
}
|
||||
|
||||
let verification_url = device_code.verification_url.clone();
|
||||
let user_code = device_code.user_code.clone();
|
||||
let response =
|
||||
codex_app_server_protocol::LoginAccountResponse::ChatgptDeviceCode {
|
||||
login_id: login_id.to_string(),
|
||||
verification_url,
|
||||
user_code,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
|
||||
let outgoing_clone = self.outgoing.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let cloud_requirements = self.cloud_requirements.clone();
|
||||
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
(false, Some("Login was not completed".to_string()))
|
||||
}
|
||||
r = complete_device_code_login(opts, device_code) => {
|
||||
match r {
|
||||
Ok(()) => (true, None),
|
||||
Err(err) => (false, Some(err.to_string())),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let payload_v2 = AccountLoginCompletedNotification {
|
||||
login_id: Some(login_id.to_string()),
|
||||
success,
|
||||
error: error_msg,
|
||||
};
|
||||
outgoing_clone
|
||||
.send_server_notification(ServerNotification::AccountLoginCompleted(
|
||||
payload_v2,
|
||||
))
|
||||
.await;
|
||||
|
||||
if success {
|
||||
auth_manager.reload();
|
||||
replace_cloud_requirements_loader(
|
||||
cloud_requirements.as_ref(),
|
||||
auth_manager.clone(),
|
||||
chatgpt_base_url,
|
||||
codex_home,
|
||||
);
|
||||
sync_default_client_residency_requirement(
|
||||
&cli_overrides,
|
||||
cloud_requirements.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth = auth_manager.auth_cached();
|
||||
let payload_v2 = AccountUpdatedNotification {
|
||||
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
|
||||
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
|
||||
};
|
||||
outgoing_clone
|
||||
.send_server_notification(ServerNotification::AccountUpdated(
|
||||
payload_v2,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut guard = active_login.lock().await;
|
||||
if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) {
|
||||
*guard = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let error = Self::login_chatgpt_device_code_start_error(err);
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel_login_chatgpt_common(
|
||||
&mut self,
|
||||
login_id: Uuid,
|
||||
) -> std::result::Result<(), CancelLoginError> {
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if guard.as_ref().map(|l| l.login_id) == Some(login_id) {
|
||||
if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) {
|
||||
if let Some(active) = guard.take() {
|
||||
drop(active);
|
||||
}
|
||||
|
||||
@@ -840,6 +840,14 @@ impl McpProcess {
|
||||
self.send_request("account/login/start", Some(params)).await
|
||||
}
|
||||
|
||||
/// Send an `account/login/start` JSON-RPC request for ChatGPT device code login.
|
||||
pub async fn send_login_account_chatgpt_device_code_request(&mut self) -> anyhow::Result<i64> {
|
||||
let params = serde_json::json!({
|
||||
"type": "chatgptDeviceCode"
|
||||
});
|
||||
self.send_request("account/login/start", Some(params)).await
|
||||
}
|
||||
|
||||
/// Send an `account/login/cancel` JSON-RPC request.
|
||||
pub async fn send_cancel_login_account_request(
|
||||
&mut self,
|
||||
|
||||
@@ -39,10 +39,14 @@ use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
|
||||
|
||||
// Helper to create a minimal config.toml for the app server
|
||||
#[derive(Default)]
|
||||
@@ -98,6 +102,58 @@ stream_max_retries = 0
|
||||
std::fs::write(config_toml, contents)
|
||||
}
|
||||
|
||||
async fn mock_device_code_usercode(server: &MockServer, interval_seconds: u64) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/accounts/deviceauth/usercode"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"device_auth_id": "device-auth-123",
|
||||
"user_code": "CODE-12345",
|
||||
"interval": interval_seconds.to_string(),
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mock_device_code_usercode_failure(server: &MockServer, status: u16) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/accounts/deviceauth/usercode"))
|
||||
.respond_with(ResponseTemplate::new(status))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mock_device_code_token_success(server: &MockServer) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/accounts/deviceauth/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"authorization_code": "poll-code-321",
|
||||
"code_challenge": "code-challenge-321",
|
||||
"code_verifier": "code-verifier-321",
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mock_device_code_token_failure(server: &MockServer, status: u16) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/accounts/deviceauth/token"))
|
||||
.respond_with(ResponseTemplate::new(status))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id_token": id_token,
|
||||
"access_token": "access-token-123",
|
||||
"refresh_token": "refresh-token-123",
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_account_removes_auth_and_notifies() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -912,6 +968,305 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
mock_device_code_usercode_failure(&mock_server, 404).await;
|
||||
|
||||
let issuer = mock_server.uri();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
|
||||
let err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("device code login is not enabled"),
|
||||
"unexpected error: {:?}",
|
||||
err.error.message
|
||||
);
|
||||
|
||||
let maybe_completed = timeout(
|
||||
Duration::from_millis(500),
|
||||
mcp.read_stream_until_notification_message("account/login/completed"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
maybe_completed.is_err(),
|
||||
"account/login/completed should not be emitted when device code start fails"
|
||||
);
|
||||
assert!(
|
||||
!codex_home.path().join("auth.json").exists(),
|
||||
"auth.json should not be created when device code start fails"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
mock_device_code_usercode(&mock_server, 0).await;
|
||||
mock_device_code_token_success(&mock_server).await;
|
||||
let id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("device@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-device"),
|
||||
)?;
|
||||
mock_device_code_oauth_token(&mock_server, &id_token).await;
|
||||
|
||||
let issuer = mock_server.uri();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let login: LoginAccountResponse = to_response(resp)?;
|
||||
let LoginAccountResponse::ChatgptDeviceCode {
|
||||
login_id,
|
||||
verification_url,
|
||||
user_code,
|
||||
} = login
|
||||
else {
|
||||
bail!("unexpected login response: {login:?}");
|
||||
};
|
||||
assert_eq!(verification_url, format!("{issuer}/codex/device"));
|
||||
assert_eq!(user_code, "CODE-12345");
|
||||
|
||||
let note = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/login/completed"),
|
||||
)
|
||||
.await??;
|
||||
let parsed: ServerNotification = note.try_into()?;
|
||||
let ServerNotification::AccountLoginCompleted(payload) = parsed else {
|
||||
bail!("unexpected notification: {parsed:?}");
|
||||
};
|
||||
assert_eq!(payload.login_id, Some(login_id));
|
||||
assert_eq!(payload.success, true);
|
||||
assert_eq!(payload.error, None);
|
||||
|
||||
let note = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
let parsed: ServerNotification = note.try_into()?;
|
||||
let ServerNotification::AccountUpdated(payload) = parsed else {
|
||||
bail!("unexpected notification: {parsed:?}");
|
||||
};
|
||||
assert_eq!(payload.auth_mode, Some(AuthMode::Chatgpt));
|
||||
assert_eq!(payload.plan_type, Some(AccountPlanType::Pro));
|
||||
assert!(
|
||||
codex_home.path().join("auth.json").exists(),
|
||||
"auth.json should be created when device code login succeeds"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_chatgpt_device_code_failure_notifies_without_account_update() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
mock_device_code_usercode(&mock_server, 0).await;
|
||||
mock_device_code_token_failure(&mock_server, 500).await;
|
||||
|
||||
let issuer = mock_server.uri();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let login: LoginAccountResponse = to_response(resp)?;
|
||||
let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else {
|
||||
bail!("unexpected login response: {login:?}");
|
||||
};
|
||||
|
||||
let note = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/login/completed"),
|
||||
)
|
||||
.await??;
|
||||
let parsed: ServerNotification = note.try_into()?;
|
||||
let ServerNotification::AccountLoginCompleted(payload) = parsed else {
|
||||
bail!("unexpected notification: {parsed:?}");
|
||||
};
|
||||
assert_eq!(payload.login_id, Some(login_id));
|
||||
assert_eq!(payload.success, false);
|
||||
assert!(
|
||||
payload
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|error| error.contains("device auth failed with status")),
|
||||
"unexpected error: {:?}",
|
||||
payload.error
|
||||
);
|
||||
|
||||
let maybe_updated = timeout(
|
||||
Duration::from_millis(500),
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
maybe_updated.is_err(),
|
||||
"account/updated should not be emitted when device code login fails"
|
||||
);
|
||||
assert!(
|
||||
!codex_home.path().join("auth.json").exists(),
|
||||
"auth.json should not be created when device code login fails"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
mock_device_code_usercode(&mock_server, 1).await;
|
||||
mock_device_code_token_failure(&mock_server, 404).await;
|
||||
|
||||
let issuer = mock_server.uri();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let login: LoginAccountResponse = to_response(resp)?;
|
||||
let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else {
|
||||
bail!("unexpected login response: {login:?}");
|
||||
};
|
||||
|
||||
let cancel_id = mcp
|
||||
.send_cancel_login_account_request(CancelLoginAccountParams {
|
||||
login_id: login_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let cancel_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),
|
||||
)
|
||||
.await??;
|
||||
let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?;
|
||||
assert_eq!(cancel.status, CancelLoginAccountStatus::Canceled);
|
||||
|
||||
let note = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/login/completed"),
|
||||
)
|
||||
.await??;
|
||||
let parsed: ServerNotification = note.try_into()?;
|
||||
let ServerNotification::AccountLoginCompleted(payload) = parsed else {
|
||||
bail!("unexpected notification: {parsed:?}");
|
||||
};
|
||||
assert_eq!(payload.login_id, Some(login_id));
|
||||
assert_eq!(payload.success, false);
|
||||
assert!(
|
||||
payload.error.is_some(),
|
||||
"expected a non-empty error on device code cancel"
|
||||
);
|
||||
|
||||
let maybe_updated = timeout(
|
||||
Duration::from_millis(500),
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
maybe_updated.is_err(),
|
||||
"account/updated should not be emitted when device code login is cancelled"
|
||||
);
|
||||
assert!(
|
||||
!codex_home.path().join("auth.json").exists(),
|
||||
"auth.json should not be created when device code login is cancelled"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
// Serialize tests that launch the login server since it binds to a fixed port.
|
||||
#[serial(login_port)]
|
||||
|
||||
Reference in New Issue
Block a user