diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index f5ad15c2c4..99333c4ba5 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1681,6 +1681,10 @@ "GatewayOAuthLoginParams": { "description": "Authorize a configured provider without changing the default or any thread's provider.", "properties": { + "deviceAuth": { + "description": "Select device authorization when the server advertises support for it.", + "type": "boolean" + }, "modelProvider": { "description": "Defaults to the current provider when omitted.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6750a891fc..e4784fe678 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2010,6 +2010,13 @@ "null" ] }, + "loginId": { + "description": "Identifies the completed device login attempt.", + "type": [ + "string", + "null" + ] + }, "providerId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b72ef37ac7..c1252489a6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -13294,6 +13294,13 @@ "null" ] }, + "loginId": { + "description": "Identifies the completed device login attempt.", + "type": [ + "string", + "null" + ] + }, "providerId": { "type": "string" }, @@ -13311,6 +13318,10 @@ "GatewayOAuthLoginParams": { "description": "Authorize a configured provider without changing the default or any thread's provider.", "properties": { + "deviceAuth": { + "description": "Select device authorization when the server advertises support for it.", + "type": "boolean" + }, "modelProvider": { "description": "Defaults to the current provider when omitted.", "type": [ @@ -13323,9 +13334,40 @@ }, "GatewayOAuthLoginResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "prompt": { + "anyOf": [ + { + "$ref": "#/definitions/v2/GatewayOAuthPrompt" + }, + { + "type": "null" + } + ] + } + }, "title": "GatewayOAuthLoginResponse", "type": "object" }, + "GatewayOAuthPrompt": { + "properties": { + "loginId": { + "type": "string" + }, + "userCode": { + "type": "string" + }, + "verificationUrl": { + "type": "string" + } + }, + "required": [ + "loginId", + "userCode", + "verificationUrl" + ], + "type": "object" + }, "GatewayOAuthReadParams": { "description": "Read credential readiness for a configured provider without starting browser login.", "properties": { @@ -13369,6 +13411,11 @@ } ], "description": "Null when the effective provider does not use gateway OAuth." + }, + "supportsDeviceAuth": { + "default": false, + "description": "Whether the administrator configured a device authorization endpoint.", + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 25170e10d6..c71cef12ff 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9186,6 +9186,13 @@ "null" ] }, + "loginId": { + "description": "Identifies the completed device login attempt.", + "type": [ + "string", + "null" + ] + }, "providerId": { "type": "string" }, @@ -9203,6 +9210,10 @@ "GatewayOAuthLoginParams": { "description": "Authorize a configured provider without changing the default or any thread's provider.", "properties": { + "deviceAuth": { + "description": "Select device authorization when the server advertises support for it.", + "type": "boolean" + }, "modelProvider": { "description": "Defaults to the current provider when omitted.", "type": [ @@ -9215,9 +9226,40 @@ }, "GatewayOAuthLoginResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "prompt": { + "anyOf": [ + { + "$ref": "#/definitions/GatewayOAuthPrompt" + }, + { + "type": "null" + } + ] + } + }, "title": "GatewayOAuthLoginResponse", "type": "object" }, + "GatewayOAuthPrompt": { + "properties": { + "loginId": { + "type": "string" + }, + "userCode": { + "type": "string" + }, + "verificationUrl": { + "type": "string" + } + }, + "required": [ + "loginId", + "userCode", + "verificationUrl" + ], + "type": "object" + }, "GatewayOAuthReadParams": { "description": "Read credential readiness for a configured provider without starting browser login.", "properties": { @@ -9261,6 +9303,11 @@ } ], "description": "Null when the effective provider does not use gateway OAuth." + }, + "supportsDeviceAuth": { + "default": false, + "description": "Whether the administrator configured a device authorization endpoint.", + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthChangedNotification.json index 0730c19889..471a48f868 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthChangedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthChangedNotification.json @@ -25,6 +25,13 @@ "null" ] }, + "loginId": { + "description": "Identifies the completed device login attempt.", + "type": [ + "string", + "null" + ] + }, "providerId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthLoginResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthLoginResponse.json index bb11dc15a7..39dd63dd1d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthLoginResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthLoginResponse.json @@ -1,5 +1,38 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "GatewayOAuthPrompt": { + "properties": { + "loginId": { + "type": "string" + }, + "userCode": { + "type": "string" + }, + "verificationUrl": { + "type": "string" + } + }, + "required": [ + "loginId", + "userCode", + "verificationUrl" + ], + "type": "object" + } + }, + "properties": { + "prompt": { + "anyOf": [ + { + "$ref": "#/definitions/GatewayOAuthPrompt" + }, + { + "type": "null" + } + ] + } + }, "title": "GatewayOAuthLoginResponse", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthReadResponse.json index 98340d2c7b..3c93998b65 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GatewayOAuthReadResponse.json @@ -39,6 +39,11 @@ } ], "description": "Null when the effective provider does not use gateway OAuth." + }, + "supportsDeviceAuth": { + "default": false, + "description": "Whether the administrator configured a device authorization endpoint.", + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/NullableGatewayOAuthLoginParams.json b/codex-rs/app-server-protocol/schema/json/v2/NullableGatewayOAuthLoginParams.json index 62155cbc02..35921c6388 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/NullableGatewayOAuthLoginParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/NullableGatewayOAuthLoginParams.json @@ -12,6 +12,10 @@ "GatewayOAuthLoginParams": { "description": "Authorize a configured provider without changing the default or any thread's provider.", "properties": { + "deviceAuth": { + "description": "Select device authorization when the server advertises support for it.", + "type": "boolean" + }, "modelProvider": { "description": "Defaults to the current provider when omitted.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 3ee59785d8..d83ab0f2aa 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index cf46d378f6..c10f65af88 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthChangedNotification.ts index 6e88ea2542..64d15ce444 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthChangedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthChangedNotification.ts @@ -4,6 +4,10 @@ import type { GatewayOAuthStatus } from "./GatewayOAuthStatus"; export type GatewayOAuthChangedNotification = { +/** + * Identifies the completed device login attempt. + */ +loginId: string | null, /** * Authorization handoff, sent only to the connection that started login. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthLoginParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthLoginParams.ts index 5f447f017c..43b6e48041 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthLoginParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthLoginParams.ts @@ -9,4 +9,8 @@ export type GatewayOAuthLoginParams = { /** * Defaults to the current provider when omitted. */ -modelProvider?: string | null, }; +modelProvider?: string | null, +/** + * Select device authorization when the server advertises support for it. + */ +deviceAuth?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthLoginResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthLoginResponse.ts index a171d58303..3ec5ad4d33 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthLoginResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthLoginResponse.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GatewayOAuthPrompt } from "./GatewayOAuthPrompt"; -export type GatewayOAuthLoginResponse = Record; +export type GatewayOAuthLoginResponse = { prompt: GatewayOAuthPrompt | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthPrompt.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthPrompt.ts new file mode 100644 index 0000000000..66e990a7d3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthPrompt.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GatewayOAuthPrompt = { loginId: string, verificationUrl: string, userCode: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthReadResponse.ts index 78b53398d5..6f5d2f00e2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthReadResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GatewayOAuthReadResponse.ts @@ -11,6 +11,10 @@ export type GatewayOAuthReadResponse = { providerId: string, providerName: strin * Whether the selected provider uses gateway OAuth, even when already signed in. */ required: boolean, +/** + * Whether the administrator configured a device authorization endpoint. + */ +supportsDeviceAuth?: boolean, /** * Null when the effective provider does not use gateway OAuth. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 53c270e729..7e6c488806 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -214,6 +214,7 @@ export type { GatewayOAuthCancelResponse } from "./GatewayOAuthCancelResponse"; export type { GatewayOAuthChangedNotification } from "./GatewayOAuthChangedNotification"; export type { GatewayOAuthLoginParams } from "./GatewayOAuthLoginParams"; export type { GatewayOAuthLoginResponse } from "./GatewayOAuthLoginResponse"; +export type { GatewayOAuthPrompt } from "./GatewayOAuthPrompt"; export type { GatewayOAuthReadParams } from "./GatewayOAuthReadParams"; export type { GatewayOAuthReadResponse } from "./GatewayOAuthReadResponse"; export type { GatewayOAuthStatus } from "./GatewayOAuthStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index bae18f3b92..da570d48e2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -637,6 +637,8 @@ pub enum GatewayOAuthStatus { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct GatewayOAuthChangedNotification { + /// Identifies the completed device login attempt. + pub login_id: Option, /// Authorization handoff, sent only to the connection that started login. pub auth_url: Option, pub provider_id: String, @@ -675,6 +677,9 @@ pub struct GatewayOAuthLoginParams { /// Defaults to the current provider when omitted. #[ts(optional = nullable)] pub model_provider: Option, + /// Select device authorization when the server advertises support for it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub device_auth: bool, } pub type NullableGatewayOAuthLoginParams = Option; @@ -688,6 +693,10 @@ pub struct GatewayOAuthReadResponse { pub provider_name: String, /// Whether the selected provider uses gateway OAuth, even when already signed in. pub required: bool, + /// Whether the administrator configured a device authorization endpoint. + #[serde(default)] + #[ts(optional, as = "Option")] + pub supports_device_auth: bool, /// Null when the effective provider does not use gateway OAuth. pub status: Option, pub error: Option, @@ -696,7 +705,26 @@ pub struct GatewayOAuthReadResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct GatewayOAuthLoginResponse {} +pub struct GatewayOAuthLoginResponse { + pub prompt: Option, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GatewayOAuthPrompt { + pub login_id: String, + pub verification_url: String, + pub user_code: String, +} + +impl std::fmt::Debug for GatewayOAuthPrompt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatewayOAuthPrompt") + .field("login_id", &self.login_id) + .finish_non_exhaustive() + } +} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5e9dfd6df8..0508b37567 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -660,20 +660,26 @@ managers using the same home and network configuration within the process and cannot be undone by a later connection that omits the capability. - `account/gatewayOAuth/read` returns the selected `providerId`, - `providerName`, `required`, `status`, and `error`. `required` indicates that this + `providerName`, `required`, `supportsDeviceAuth`, `status`, and `error`. `required` indicates that this provider uses gateway OAuth, including when already signed in. With a credential exporter, `required` is false and `status` is null. This read does not refresh - tokens or open a browser. + tokens or open a browser. `supportsDeviceAuth` means a + device authorization endpoint is configured. Older servers omit this field. `notReady` means credentials are not ready. `succeeded` means saved credentials are locally usable, not that a gateway request has been verified. Reads observe usable replacement credentials saved by another process sharing the same home. -- `account/gatewayOAuth/login` starts authorization and returns `{}` after - the credential has been saved. Providers requiring OpenAI authentication need a - primary account first. A second login request fails while a login is active. +- `account/gatewayOAuth/login` starts browser authorization and returns + `{"prompt": null}` after saving the credential. Providers requiring OpenAI + authentication need a primary account first. A second login request fails + while a login is active. The initiating connection receives a `started` notification with `authUrl`; the client must open that URL in a browser that can reach the server callback port. - Other status notifications set `authUrl` to null. Login is rejected if the - initiating connection opted out of `account/gatewayOAuth/changed` notifications. + Pass `deviceAuth: true` to use device authorization. If the saved credential + can be reused or refreshed, `prompt` is null. Otherwise it returns a + `prompt` with `verificationUrl`, `userCode`, and `loginId` before approval. + The initiating connection receives `account/gatewayOAuth/changed` with that + login ID when the attempt succeeds or fails. Login requires the connection + to accept these notifications. - `account/gatewayOAuth/cancel` cancels the calling connection's login and returns `{}` after the active login releases its slot, so the client can immediately start another login. Closing that connection also cancels its login and releases @@ -684,7 +690,7 @@ cannot be undone by a later connection that omits the capability. default. Clients can read readiness when connecting and after changing configuration. Notifications follow the standard per-connection `optOutNotificationMethods` setting. - These payloads never contain credentials. + These payloads never contain access tokens, refresh tokens, or the device grant credential. Read and login accept an optional `modelProvider`, for example `{"modelProvider": "my_gateway"}`. Omitted params, null params, and an omitted or diff --git a/codex-rs/app-server/src/gateway_oauth_notifications.rs b/codex-rs/app-server/src/gateway_oauth_notifications.rs index 4034e3f621..017e9e59ea 100644 --- a/codex-rs/app-server/src/gateway_oauth_notifications.rs +++ b/codex-rs/app-server/src/gateway_oauth_notifications.rs @@ -55,6 +55,7 @@ pub(crate) fn spawn( outgoing .send_server_notification(ServerNotification::GatewayOAuthChanged( GatewayOAuthChangedNotification { + login_id: None, auth_url: None, provider_id: provider_id.clone(), status, diff --git a/codex-rs/app-server/src/message_processor_gateway_oauth_tests.rs b/codex-rs/app-server/src/message_processor_gateway_oauth_tests.rs index 7f9eb41c9c..3f1852f1ff 100644 --- a/codex-rs/app-server/src/message_processor_gateway_oauth_tests.rs +++ b/codex-rs/app-server/src/message_processor_gateway_oauth_tests.rs @@ -76,6 +76,7 @@ base_url = "{url}/v1" wire_api = "responses" [model_providers.gateway.gateway_oauth] authorization_url = "{url}/authorize" +device_authorization_url = "{url}/devicecode" token_url = "{url}/token" client_id = "app-server-login-success" delivery = {{ kind = "header", name = "X-Gateway-Authorization" }} @@ -178,7 +179,7 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }} .await? .error_for_status()?; let response: GatewayOAuthLoginResponse = read_response(&mut outgoing, /*request_id*/ 2).await; - assert_eq!(response, GatewayOAuthLoginResponse {}); + assert_eq!(response, GatewayOAuthLoginResponse { prompt: None }); send_request( &processor, &session, @@ -192,6 +193,7 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }} provider_id: "gateway".to_string(), provider_name: "OpenAI".to_string(), required: true, + supports_device_auth: true, status: Some(GatewayOAuthStatus::Succeeded), error: None, } diff --git a/codex-rs/app-server/src/request_processors/account_processor/gateway_oauth.rs b/codex-rs/app-server/src/request_processors/account_processor/gateway_oauth.rs index d06c7d5592..538737f174 100644 --- a/codex-rs/app-server/src/request_processors/account_processor/gateway_oauth.rs +++ b/codex-rs/app-server/src/request_processors/account_processor/gateway_oauth.rs @@ -7,6 +7,7 @@ use crate::transport::ConnectionId; use codex_app_server_protocol::GatewayOAuthCancelResponse; use codex_app_server_protocol::GatewayOAuthLoginResponse; use codex_app_server_protocol::GatewayOAuthReadResponse; +use codex_login::OAuthSessionError; use codex_login::OAuthSessionManager; use codex_model_provider::GatewayAuthManager; use std::sync::PoisonError; @@ -73,13 +74,15 @@ impl AccountRequestProcessor { params: codex_app_server_protocol::GatewayOAuthReadParams, ) -> Result { let (config, client) = self.gateway_client(params.model_provider).await?; + let supports_device_auth = client + .as_ref() + .is_some_and(|client| client.config().device_authorization_url.is_some()); let state = if let Some(client) = client { - Some(status_fields( - client - .status() - .await - .map_err(|err| internal_error(err.to_string()))?, - )) + let status = client + .status() + .await + .map_err(|err| internal_error(err.to_string()))?; + Some(status_fields(status)) } else { None }; @@ -87,6 +90,7 @@ impl AccountRequestProcessor { provider_id: config.model_provider_id.clone(), provider_name: config.model_provider.name.clone(), required: state.is_some(), + supports_device_auth, status: state.as_ref().map(|state| state.0), error: state.and_then(|state| state.1), }) @@ -130,6 +134,76 @@ impl AccountRequestProcessor { "Sign in to your primary account before signing in to the gateway", )); } + if params.device_auth { + if client.config().device_authorization_url.is_none() { + return Err(invalid_request( + "The gateway does not support device authorization", + )); + } + let saved = client.as_ref().clone().without_interactive_login(); + let credentials = tokio::select! { + result = saved.resolve_access_token() => result, + _ = cancel.cancelled() => return Err(invalid_request("Gateway sign-in cancelled")), + }; + match credentials { + Ok(_) => return Ok(GatewayOAuthLoginResponse { prompt: None }), + Err(err) + if matches!( + err.get_ref() + .and_then(|err| err.downcast_ref::()), + Some(OAuthSessionError::LoginRequired) + ) => {} + Err(err) => return Err(internal_error(err.to_string())), + } + let (prompt_tx, prompt_rx) = tokio::sync::oneshot::channel(); + let login_id = uuid::Uuid::new_v4().to_string(); + let provider_id = config.model_provider_id.clone(); + let outgoing = Arc::clone(&self.outgoing); + let login = tokio::spawn(async move { + let result = client + .login_with_device_code(cancel.cancelled(), |prompt| { + let _ = prompt_tx.send(codex_app_server_protocol::GatewayOAuthPrompt { + login_id: login_id.clone(), + verification_url: prompt.verification_uri.to_string(), + user_code: prompt.user_code.clone(), + }); + }) + .await; + drop(guard); + let status = if result.is_ok() { + codex_app_server_protocol::GatewayOAuthStatus::Succeeded + } else { + codex_app_server_protocol::GatewayOAuthStatus::Failed + }; + outgoing + .send_server_notification_to_connections( + &[owner], + ServerNotification::GatewayOAuthChanged( + codex_app_server_protocol::GatewayOAuthChangedNotification { + login_id: Some(login_id), + auth_url: None, + provider_id, + status, + error: result.as_ref().err().map(ToString::to_string), + }, + ), + ) + .await; + result + }); + return match prompt_rx.await { + Ok(prompt) => Ok(GatewayOAuthLoginResponse { + prompt: Some(prompt), + }), + Err(_) => { + login + .await + .map_err(|err| internal_error(err.to_string()))? + .map_err(|err| internal_error(err.to_string()))?; + Ok(GatewayOAuthLoginResponse { prompt: None }) + } + }; + } let (url_tx, url_rx) = tokio::sync::oneshot::channel(); let login = client.login_with_browser(cancel.cancelled(), |url| { let _ = url_tx.send(url.to_string()); @@ -140,7 +214,7 @@ impl AccountRequestProcessor { url = url_rx => { if let Ok(url) = url { self.outgoing.send_server_notification_to_connections(&[owner], ServerNotification::GatewayOAuthChanged(codex_app_server_protocol::GatewayOAuthChangedNotification { - auth_url: Some(url), provider_id: config.model_provider_id.clone(), + login_id: None, auth_url: Some(url), provider_id: config.model_provider_id.clone(), status: codex_app_server_protocol::GatewayOAuthStatus::Started, error: None, })).await; } @@ -149,7 +223,7 @@ impl AccountRequestProcessor { }; result.map_err(|err| internal_error(err.to_string()))?; drop(guard); - Ok(GatewayOAuthLoginResponse {}) + Ok(GatewayOAuthLoginResponse { prompt: None }) } pub(crate) async fn gateway_oauth_cancel( diff --git a/codex-rs/app-server/tests/suite/v2/gateway_oauth_tests.rs b/codex-rs/app-server/tests/suite/v2/gateway_oauth_tests.rs index 1f389b50b6..dfe815c823 100644 --- a/codex-rs/app-server/tests/suite/v2/gateway_oauth_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/gateway_oauth_tests.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; +use core_test_support::oauth::mount_device_code_grant; use pretty_assertions::assert_eq; use std::time::Duration; use tempfile::TempDir; @@ -92,7 +93,7 @@ async fn cancel_gateway(server: &mut TestAppServer) -> Result Result { +async fn gateway_login_prompt(server: &mut TestAppServer) -> Result { timeout(TIMEOUT, async { loop { let changed: GatewayOAuthChangedNotification = server @@ -252,7 +253,7 @@ async fn login_to_gateway(server: &mut TestAppServer, oauth: &MockServer) -> Res let login = server .send_raw_request("account/gatewayOAuth/login", /*params*/ None) .await?; - let url = authorization_url(server).await?; + let url = gateway_login_prompt(server).await?; let query = url .query_pairs() .collect::>(); @@ -351,6 +352,76 @@ async fn file_credentials_reuse_gateway_login_after_restart() -> Result<()> { Ok(()) } +#[tokio::test] +async fn device_login_sends_the_code_and_can_refresh_without_another_sign_in() -> Result<()> { + let oauth = MockServer::start().await; + let _device_grant = mount_device_code_grant( + &oauth, + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "first-access", "refresh_token": "first-refresh", + "token_type": "Bearer", "expires_in": 1, + })), + ) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains("refresh_token=first-refresh")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-access", "token_type": "Bearer", "expires_in": 3600, + }))) + .expect(1) + .mount(&oauth) + .await; + let home = TempDir::new()?; + let config = gateway_config(&oauth).replace("model_provider = \"gateway\"\n", ""); + std::fs::write( + home.path().join("requirements.toml"), + format!( + "{config}\ndevice_authorization_url = '{}/devicecode'\ncredentials_store = 'file'\n", + oauth.uri() + ), + )?; + let mut server = gateway_server(home.path()).await?; + assert!(!read_gateway(&mut server).await?.required); + + let params = serde_json::json!({"modelProvider": "gateway", "deviceAuth": true}); + let login = server + .send_raw_request("account/gatewayOAuth/login", Some(params.clone())) + .await?; + let response: GatewayOAuthLoginResponse = server.read_response(login).await?; + let prompt = response.prompt.context("missing gateway device prompt")?; + assert_eq!(prompt.verification_url, "https://login.example/device"); + assert_eq!(prompt.user_code, "ABCD-EFGH"); + timeout( + TIMEOUT, + server.read_stream_until_matching_notification("device login completed", |notification| { + notification.method == "account/gatewayOAuth/changed" + && notification.params.as_ref().is_some_and(|params| { + params["loginId"].as_str() == Some(prompt.login_id.as_str()) + && params["status"] == "succeeded" + }) + }), + ) + .await??; + + // The first token is already due for refresh. The issuer expects exactly one device grant. + let retry = server + .send_raw_request("account/gatewayOAuth/login", Some(params)) + .await?; + let reused: GatewayOAuthLoginResponse = server.read_response(retry).await?; + assert!(reused.prompt.is_none()); + let read = server + .send_raw_request( + "account/gatewayOAuth/read", + Some(serde_json::json!({"modelProvider": "gateway"})), + ) + .await?; + let state: GatewayOAuthReadResponse = server.read_response(read).await?; + assert!(state.supports_device_auth); + assert_eq!(state.status, Some(GatewayOAuthStatus::Succeeded)); + Ok(()) +} + #[tokio::test] async fn token_exchange_failure_completes_login_and_updates_readiness() -> Result<()> { let home = TempDir::new()?; @@ -381,7 +452,7 @@ async fn token_exchange_failure_completes_login_and_updates_readiness() -> Resul let login = server .send_raw_request("account/gatewayOAuth/login", /*params*/ None) .await?; - let authorization_url = authorization_url(&mut server).await?; + let authorization_url = gateway_login_prompt(&mut server).await?; let query = authorization_url .query_pairs() .into_owned() @@ -419,6 +490,7 @@ async fn token_exchange_failure_completes_login_and_updates_readiness() -> Resul changed.params.context("gateway status")? )?, GatewayOAuthChangedNotification { + login_id: None, provider_id: "gateway".into(), status: GatewayOAuthStatus::Failed, auth_url: None, @@ -432,6 +504,7 @@ async fn token_exchange_failure_completes_login_and_updates_readiness() -> Resul provider_id: "gateway".to_string(), provider_name: "Test gateway".to_string(), required: true, + supports_device_auth: false, status: Some(GatewayOAuthStatus::Failed), error: Some(error.error.message), } @@ -505,12 +578,14 @@ async fn read_is_passive_and_canceled_login_can_be_retried_immediately() -> Resu provider_id: "gateway".to_string(), provider_name: "Test gateway".to_string(), required: true, + supports_device_auth: false, status: Some(GatewayOAuthStatus::NotReady), error: None } ); assert!(oauth.received_requests().await.unwrap().is_empty()); let expected = GatewayOAuthChangedNotification { + login_id: None, provider_id: "gateway".to_string(), status: GatewayOAuthStatus::NotReady, auth_url: None, @@ -543,7 +618,7 @@ async fn read_is_passive_and_canceled_login_can_be_retried_immediately() -> Resu let login = server .send_raw_request("account/gatewayOAuth/login", /*params*/ None) .await?; - let url = authorization_url(&mut server).await?; + let url = gateway_login_prompt(&mut server).await?; assert!( url.as_str() .starts_with(&format!("{}/authorize?", oauth.uri())) @@ -567,7 +642,7 @@ async fn read_is_passive_and_canceled_login_can_be_retried_immediately() -> Resu let retry = server .send_raw_request("account/gatewayOAuth/login", /*params*/ None) .await?; - authorization_url(&mut server).await?; + gateway_login_prompt(&mut server).await?; let _: GatewayOAuthCancelResponse = cancel_gateway(&mut server).await?; for request_id in [login, retry] { let error = timeout( @@ -776,6 +851,7 @@ async fn selected_gateway_read_notifies_matching_providers() -> Result<()> { provider_id: "gateway".into(), provider_name: "Test gateway".into(), required: true, + supports_device_auth: false, status: Some(GatewayOAuthStatus::NotReady), error: None, } diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index d6cbadd31d..ad8b9d0fa0 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -1885,16 +1885,26 @@ class GatewayOAuthLoginParams(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + device_auth: Annotated[ + bool | None, + Field( + alias="deviceAuth", + description="Select device authorization when the server advertises support for it.", + ), + ] = None model_provider: Annotated[ str | None, Field(alias="modelProvider", description="Defaults to the current provider when omitted."), ] = None -class GatewayOAuthLoginResponse(BaseModel): +class GatewayOAuthPrompt(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + login_id: Annotated[str, Field(alias="loginId")] + user_code: Annotated[str, Field(alias="userCode")] + verification_url: Annotated[str, Field(alias="verificationUrl")] class GatewayOAuthReadParams(BaseModel): @@ -8945,10 +8955,21 @@ class GatewayOAuthChangedNotification(BaseModel): ), ] = None error: str | None = None + login_id: Annotated[ + str | None, + Field(alias="loginId", description="Identifies the completed device login attempt."), + ] = None provider_id: Annotated[str, Field(alias="providerId")] status: GatewayOAuthStatus +class GatewayOAuthLoginResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + prompt: GatewayOAuthPrompt | None = None + + class GatewayOAuthReadResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -8966,6 +8987,13 @@ class GatewayOAuthReadResponse(BaseModel): GatewayOAuthStatus | None, Field(description="Null when the effective provider does not use gateway OAuth."), ] = None + supports_device_auth: Annotated[ + bool | None, + Field( + alias="supportsDeviceAuth", + description="Whether the administrator configured a device authorization endpoint.", + ), + ] = False class GetAccountResponse(BaseModel):