mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
[codex-app-server] preserve active personal access token auth [ci changed_files]
This commit is contained in:
@@ -1909,7 +1909,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
|
||||
- **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"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically.
|
||||
- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`.
|
||||
- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`. While this mode is active, `account/login/start` rejects externally supplied `chatgptAuthTokens`; remove the personal access token before switching to host-managed ChatGPT auth.
|
||||
|
||||
### API Overview
|
||||
|
||||
|
||||
@@ -592,6 +592,14 @@ impl AccountRequestProcessor {
|
||||
chatgpt_account_id: String,
|
||||
chatgpt_plan_type: Option<String>,
|
||||
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
|
||||
if self.auth_manager.auth_mode()
|
||||
== Some(codex_protocol::auth::AuthMode::PersonalAccessToken)
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"External ChatGPT auth cannot replace an active personal access token. Remove the personal access token before using chatgptAuthTokens.",
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(
|
||||
self.config.forced_login_method,
|
||||
Some(ForcedLoginMethod::Api)
|
||||
|
||||
@@ -52,6 +52,7 @@ use url::Url;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
@@ -408,6 +409,185 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_access_token_rejects_external_auth_injection_and_does_not_refresh() -> 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())?;
|
||||
|
||||
let personal_access_token = "at-service-account";
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/user-auth-credential/whoami"))
|
||||
.and(header(
|
||||
"Authorization",
|
||||
format!("Bearer {personal_access_token}"),
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"email": "service-account@example.com",
|
||||
"chatgpt_user_id": "service-account-user",
|
||||
"chatgpt_account_id": "service-account-workspace",
|
||||
"chatgpt_plan_type": "team",
|
||||
"chatgpt_account_is_fedramp": false,
|
||||
})))
|
||||
.expect(1..)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "message": "unauthorized" }
|
||||
}));
|
||||
let responses_mock = responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;
|
||||
|
||||
let authapi_base_url = mock_server.uri();
|
||||
let mut mcp = TestAppServer::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
("CODEX_ACCESS_TOKEN", Some(personal_access_token)),
|
||||
("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let expected_status = GetAuthStatusResponse {
|
||||
auth_method: Some(AuthMode::PersonalAccessToken),
|
||||
auth_token: None,
|
||||
requires_openai_auth: Some(true),
|
||||
};
|
||||
let status_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(false),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await?;
|
||||
let status_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(status_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
to_response::<GetAuthStatusResponse>(status_response)?,
|
||||
expected_status
|
||||
);
|
||||
|
||||
let host_access_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("host-user@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
|
||||
)?;
|
||||
let login_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(
|
||||
host_access_token,
|
||||
WORKSPACE_ID_EMBEDDED.to_string(),
|
||||
Some("pro".to_string()),
|
||||
)
|
||||
.await?;
|
||||
let login_error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(login_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
login_error,
|
||||
JSONRPCError {
|
||||
id: RequestId::Integer(login_id),
|
||||
error: JSONRPCErrorError {
|
||||
code: -32600,
|
||||
message: "External ChatGPT auth cannot replace an active personal access token. Remove the personal access token before using chatgptAuthTokens."
|
||||
.to_string(),
|
||||
data: None,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let status_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(false),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await?;
|
||||
let status_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(status_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
to_response::<GetAuthStatusResponse>(status_response)?,
|
||||
expected_status
|
||||
);
|
||||
|
||||
let thread_id = mcp
|
||||
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_id)),
|
||||
)
|
||||
.await??;
|
||||
let thread =
|
||||
to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_response)?.thread;
|
||||
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
client_user_message_id: None,
|
||||
input: vec![codex_app_server_protocol::UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let _turn_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
let completed_notification: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: TurnCompletedNotification = serde_json::from_value(
|
||||
completed_notification
|
||||
.params
|
||||
.expect("turn/completed params must be present"),
|
||||
)?;
|
||||
assert_eq!(completed.turn.status, TurnStatus::Failed);
|
||||
|
||||
let requests = responses_mock.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].header("authorization"),
|
||||
Some(format!("Bearer {personal_access_token}"))
|
||||
);
|
||||
let refresh_request = timeout(
|
||||
Duration::from_millis(250),
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
refresh_request.is_err(),
|
||||
"personal access token auth should not emit account/chatgptAuthTokens/refresh"
|
||||
);
|
||||
|
||||
mock_server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn respond_to_refresh_request(
|
||||
mcp: &mut TestAppServer,
|
||||
access_token: &str,
|
||||
|
||||
Reference in New Issue
Block a user