Skip to content

Commit 47a9e2e

Browse files
authored
Add ChatGPT device-code login to app server (openai#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.
1 parent dd30c8e commit 47a9e2e

File tree

14 files changed

+801
-35
lines changed

14 files changed

+801
-35
lines changed

codex-rs/app-server-protocol/schema/json/ClientRequest.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,22 @@
11561156
"title": "ChatgptLoginAccountParams",
11571157
"type": "object"
11581158
},
1159+
{
1160+
"properties": {
1161+
"type": {
1162+
"enum": [
1163+
"chatgptDeviceCode"
1164+
],
1165+
"title": "ChatgptDeviceCodeLoginAccountParamsType",
1166+
"type": "string"
1167+
}
1168+
},
1169+
"required": [
1170+
"type"
1171+
],
1172+
"title": "ChatgptDeviceCodeLoginAccountParams",
1173+
"type": "object"
1174+
},
11591175
{
11601176
"description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.",
11611177
"properties": {

codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8569,6 +8569,22 @@
85698569
"title": "Chatgptv2::LoginAccountParams",
85708570
"type": "object"
85718571
},
8572+
{
8573+
"properties": {
8574+
"type": {
8575+
"enum": [
8576+
"chatgptDeviceCode"
8577+
],
8578+
"title": "ChatgptDeviceCodev2::LoginAccountParamsType",
8579+
"type": "string"
8580+
}
8581+
},
8582+
"required": [
8583+
"type"
8584+
],
8585+
"title": "ChatgptDeviceCodev2::LoginAccountParams",
8586+
"type": "object"
8587+
},
85728588
{
85738589
"description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.",
85748590
"properties": {
@@ -8650,6 +8666,36 @@
86508666
"title": "Chatgptv2::LoginAccountResponse",
86518667
"type": "object"
86528668
},
8669+
{
8670+
"properties": {
8671+
"loginId": {
8672+
"type": "string"
8673+
},
8674+
"type": {
8675+
"enum": [
8676+
"chatgptDeviceCode"
8677+
],
8678+
"title": "ChatgptDeviceCodev2::LoginAccountResponseType",
8679+
"type": "string"
8680+
},
8681+
"userCode": {
8682+
"description": "One-time code the user must enter after signing in.",
8683+
"type": "string"
8684+
},
8685+
"verificationUrl": {
8686+
"description": "URL the client should open in a browser to complete device code authorization.",
8687+
"type": "string"
8688+
}
8689+
},
8690+
"required": [
8691+
"loginId",
8692+
"type",
8693+
"userCode",
8694+
"verificationUrl"
8695+
],
8696+
"title": "ChatgptDeviceCodev2::LoginAccountResponse",
8697+
"type": "object"
8698+
},
86538699
{
86548700
"properties": {
86558701
"type": {

codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5383,6 +5383,22 @@
53835383
"title": "Chatgptv2::LoginAccountParams",
53845384
"type": "object"
53855385
},
5386+
{
5387+
"properties": {
5388+
"type": {
5389+
"enum": [
5390+
"chatgptDeviceCode"
5391+
],
5392+
"title": "ChatgptDeviceCodev2::LoginAccountParamsType",
5393+
"type": "string"
5394+
}
5395+
},
5396+
"required": [
5397+
"type"
5398+
],
5399+
"title": "ChatgptDeviceCodev2::LoginAccountParams",
5400+
"type": "object"
5401+
},
53865402
{
53875403
"description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.",
53885404
"properties": {
@@ -5464,6 +5480,36 @@
54645480
"title": "Chatgptv2::LoginAccountResponse",
54655481
"type": "object"
54665482
},
5483+
{
5484+
"properties": {
5485+
"loginId": {
5486+
"type": "string"
5487+
},
5488+
"type": {
5489+
"enum": [
5490+
"chatgptDeviceCode"
5491+
],
5492+
"title": "ChatgptDeviceCodev2::LoginAccountResponseType",
5493+
"type": "string"
5494+
},
5495+
"userCode": {
5496+
"description": "One-time code the user must enter after signing in.",
5497+
"type": "string"
5498+
},
5499+
"verificationUrl": {
5500+
"description": "URL the client should open in a browser to complete device code authorization.",
5501+
"type": "string"
5502+
}
5503+
},
5504+
"required": [
5505+
"loginId",
5506+
"type",
5507+
"userCode",
5508+
"verificationUrl"
5509+
],
5510+
"title": "ChatgptDeviceCodev2::LoginAccountResponse",
5511+
"type": "object"
5512+
},
54675513
{
54685514
"properties": {
54695515
"type": {

codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@
3737
"title": "Chatgptv2::LoginAccountParams",
3838
"type": "object"
3939
},
40+
{
41+
"properties": {
42+
"type": {
43+
"enum": [
44+
"chatgptDeviceCode"
45+
],
46+
"title": "ChatgptDeviceCodev2::LoginAccountParamsType",
47+
"type": "string"
48+
}
49+
},
50+
"required": [
51+
"type"
52+
],
53+
"title": "ChatgptDeviceCodev2::LoginAccountParams",
54+
"type": "object"
55+
},
4056
{
4157
"description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.",
4258
"properties": {

codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,36 @@
4242
"title": "Chatgptv2::LoginAccountResponse",
4343
"type": "object"
4444
},
45+
{
46+
"properties": {
47+
"loginId": {
48+
"type": "string"
49+
},
50+
"type": {
51+
"enum": [
52+
"chatgptDeviceCode"
53+
],
54+
"title": "ChatgptDeviceCodev2::LoginAccountResponseType",
55+
"type": "string"
56+
},
57+
"userCode": {
58+
"description": "One-time code the user must enter after signing in.",
59+
"type": "string"
60+
},
61+
"verificationUrl": {
62+
"description": "URL the client should open in a browser to complete device code authorization.",
63+
"type": "string"
64+
}
65+
},
66+
"required": [
67+
"loginId",
68+
"type",
69+
"userCode",
70+
"verificationUrl"
71+
],
72+
"title": "ChatgptDeviceCodev2::LoginAccountResponse",
73+
"type": "object"
74+
},
4575
{
4676
"properties": {
4777
"type": {

codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
44

5-
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptAuthTokens",
5+
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
66
/**
77
* Access token (JWT) supplied by the client.
88
* This token is used for backend API requests and email extraction.

codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,12 @@ export type LoginAccountResponse = { "type": "apiKey", } | { "type": "chatgpt",
66
/**
77
* URL the client should open in a browser to initiate the OAuth flow.
88
*/
9-
authUrl: string, } | { "type": "chatgptAuthTokens", };
9+
authUrl: string, } | { "type": "chatgptDeviceCode", loginId: string,
10+
/**
11+
* URL the client should open in a browser to complete device code authorization.
12+
*/
13+
verificationUrl: string,
14+
/**
15+
* One-time code the user must enter after signing in.
16+
*/
17+
userCode: string, } | { "type": "chatgptAuthTokens", };

codex-rs/app-server-protocol/src/protocol/common.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,16 +1435,35 @@ mod tests {
14351435
Ok(())
14361436
}
14371437

1438+
#[test]
1439+
fn serialize_account_login_chatgpt_device_code() -> Result<()> {
1440+
let request = ClientRequest::LoginAccount {
1441+
request_id: RequestId::Integer(4),
1442+
params: v2::LoginAccountParams::ChatgptDeviceCode,
1443+
};
1444+
assert_eq!(
1445+
json!({
1446+
"method": "account/login/start",
1447+
"id": 4,
1448+
"params": {
1449+
"type": "chatgptDeviceCode"
1450+
}
1451+
}),
1452+
serde_json::to_value(&request)?,
1453+
);
1454+
Ok(())
1455+
}
1456+
14381457
#[test]
14391458
fn serialize_account_logout() -> Result<()> {
14401459
let request = ClientRequest::LogoutAccount {
1441-
request_id: RequestId::Integer(4),
1460+
request_id: RequestId::Integer(5),
14421461
params: None,
14431462
};
14441463
assert_eq!(
14451464
json!({
14461465
"method": "account/logout",
1447-
"id": 4,
1466+
"id": 5,
14481467
}),
14491468
serde_json::to_value(&request)?,
14501469
);
@@ -1454,7 +1473,7 @@ mod tests {
14541473
#[test]
14551474
fn serialize_account_login_chatgpt_auth_tokens() -> Result<()> {
14561475
let request = ClientRequest::LoginAccount {
1457-
request_id: RequestId::Integer(5),
1476+
request_id: RequestId::Integer(6),
14581477
params: v2::LoginAccountParams::ChatgptAuthTokens {
14591478
access_token: "access-token".to_string(),
14601479
chatgpt_account_id: "org-123".to_string(),
@@ -1464,7 +1483,7 @@ mod tests {
14641483
assert_eq!(
14651484
json!({
14661485
"method": "account/login/start",
1467-
"id": 5,
1486+
"id": 6,
14681487
"params": {
14691488
"type": "chatgptAuthTokens",
14701489
"accessToken": "access-token",

codex-rs/app-server-protocol/src/protocol/v2.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,6 +1589,9 @@ pub enum LoginAccountParams {
15891589
#[serde(rename = "chatgpt")]
15901590
#[ts(rename = "chatgpt")]
15911591
Chatgpt,
1592+
#[serde(rename = "chatgptDeviceCode")]
1593+
#[ts(rename = "chatgptDeviceCode")]
1594+
ChatgptDeviceCode,
15921595
/// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.
15931596
/// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.
15941597
#[experimental("account/login/start.chatgptAuthTokens")]
@@ -1626,6 +1629,17 @@ pub enum LoginAccountResponse {
16261629
/// URL the client should open in a browser to initiate the OAuth flow.
16271630
auth_url: String,
16281631
},
1632+
#[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")]
1633+
#[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")]
1634+
ChatgptDeviceCode {
1635+
// Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types.
1636+
// Convert to/from UUIDs at the application layer as needed.
1637+
login_id: String,
1638+
/// URL the client should open in a browser to complete device code authorization.
1639+
verification_url: String,
1640+
/// One-time code the user must enter after signing in.
1641+
user_code: String,
1642+
},
16291643
#[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")]
16301644
#[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")]
16311645
ChatgptAuthTokens {},

codex-rs/app-server-test-client/src/lib.rs

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,11 @@ enum CliCommand {
225225
abort_on: Option<usize>,
226226
},
227227
/// Trigger the ChatGPT login flow and wait for completion.
228-
TestLogin,
228+
TestLogin {
229+
/// Use the device-code login flow instead of the browser callback flow.
230+
#[arg(long, default_value_t = false)]
231+
device_code: bool,
232+
},
229233
/// Fetch the current account rate limits from the Codex app-server.
230234
GetAccountRateLimits,
231235
/// List the available models from the Codex app-server.
@@ -372,10 +376,10 @@ pub async fn run() -> Result<()> {
372376
)
373377
.await
374378
}
375-
CliCommand::TestLogin => {
379+
CliCommand::TestLogin { device_code } => {
376380
ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?;
377381
let endpoint = resolve_endpoint(codex_bin, url)?;
378-
test_login(&endpoint, &config_overrides).await
382+
test_login(&endpoint, &config_overrides, device_code).await
379383
}
380384
CliCommand::GetAccountRateLimits => {
381385
ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?;
@@ -1028,17 +1032,38 @@ async fn send_follow_up_v2(
10281032
.await
10291033
}
10301034

1031-
async fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
1035+
async fn test_login(
1036+
endpoint: &Endpoint,
1037+
config_overrides: &[String],
1038+
device_code: bool,
1039+
) -> Result<()> {
10321040
with_client("test-login", endpoint, config_overrides, |client| {
10331041
let initialize = client.initialize()?;
10341042
println!("< initialize response: {initialize:?}");
10351043

1036-
let login_response = client.login_account_chatgpt()?;
1044+
let login_response = if device_code {
1045+
client.login_account_chatgpt_device_code()?
1046+
} else {
1047+
client.login_account_chatgpt()?
1048+
};
10371049
println!("< account/login/start response: {login_response:?}");
1038-
let LoginAccountResponse::Chatgpt { login_id, auth_url } = login_response else {
1039-
bail!("expected chatgpt login response");
1050+
let login_id = match login_response {
1051+
LoginAccountResponse::Chatgpt { login_id, auth_url } => {
1052+
println!("Open the following URL in your browser to continue:\n{auth_url}");
1053+
login_id
1054+
}
1055+
LoginAccountResponse::ChatgptDeviceCode {
1056+
login_id,
1057+
verification_url,
1058+
user_code,
1059+
} => {
1060+
println!(
1061+
"Open the following URL and enter the code to continue:\n{verification_url}\n\nCode: {user_code}"
1062+
);
1063+
login_id
1064+
}
1065+
_ => bail!("expected chatgpt login response"),
10401066
};
1041-
println!("Open the following URL in your browser to continue:\n{auth_url}");
10421067

10431068
let completion = client.wait_for_account_login_completion(&login_id)?;
10441069
println!("< account/login/completed notification: {completion:?}");
@@ -1590,6 +1615,16 @@ impl CodexClient {
15901615
self.send_request(request, request_id, "account/login/start")
15911616
}
15921617

1618+
fn login_account_chatgpt_device_code(&mut self) -> Result<LoginAccountResponse> {
1619+
let request_id = self.request_id();
1620+
let request = ClientRequest::LoginAccount {
1621+
request_id: request_id.clone(),
1622+
params: codex_app_server_protocol::LoginAccountParams::ChatgptDeviceCode,
1623+
};
1624+
1625+
self.send_request(request, request_id, "account/login/start")
1626+
}
1627+
15931628
fn get_account_rate_limits(&mut self) -> Result<GetAccountRateLimitsResponse> {
15941629
let request_id = self.request_id();
15951630
let request = ClientRequest::GetAccountRateLimits {

0 commit comments

Comments
 (0)