mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Add device authorization to app-server gateway OAuth (#60486)
## What changed - Advertise `supportsDeviceAuth` in `account/gatewayOAuth/read` and accept `deviceAuth: true` in `account/gatewayOAuth/login`. - Return a device prompt with `verificationUrl`, `userCode`, and `loginId` before approval, then notify the initiating connection of success or failure with the matching `loginId`. - Reuse or refresh saved credentials when possible, returning `prompt: null` without another device authorization. Update protocol schemas, generated bindings, and documentation. ## Testing Add an integration test covering the device prompt, correlated completion notification, capability reporting, and token refresh without another device grant. GitOrigin-RevId: 6ecc38c9baadcfda3272654ed71e4aaa431421bf
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -2010,6 +2010,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"loginId": {
|
||||
"description": "Identifies the completed device login attempt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+47
@@ -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": [
|
||||
|
||||
+47
@@ -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": [
|
||||
|
||||
+7
@@ -25,6 +25,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"loginId": {
|
||||
"description": "Identifies the completed device login attempt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
+4
@@ -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": [
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+4
@@ -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.
|
||||
*/
|
||||
|
||||
+5
-1
@@ -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, };
|
||||
|
||||
+2
-1
@@ -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<string, never>;
|
||||
export type GatewayOAuthLoginResponse = { prompt: GatewayOAuthPrompt | null, };
|
||||
|
||||
@@ -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, };
|
||||
+4
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<String>,
|
||||
/// Authorization handoff, sent only to the connection that started login.
|
||||
pub auth_url: Option<String>,
|
||||
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<String>,
|
||||
/// 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<GatewayOAuthLoginParams>;
|
||||
@@ -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<bool>")]
|
||||
pub supports_device_auth: bool,
|
||||
/// Null when the effective provider does not use gateway OAuth.
|
||||
pub status: Option<GatewayOAuthStatus>,
|
||||
pub error: Option<String>,
|
||||
@@ -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<GatewayOAuthPrompt>,
|
||||
}
|
||||
|
||||
#[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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<GatewayOAuthReadResponse, JSONRPCErrorError> {
|
||||
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::<OAuthSessionError>()),
|
||||
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(
|
||||
|
||||
@@ -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<GatewayOAuthCancel
|
||||
.await
|
||||
}
|
||||
|
||||
async fn authorization_url(server: &mut TestAppServer) -> Result<url::Url> {
|
||||
async fn gateway_login_prompt(server: &mut TestAppServer) -> Result<url::Url> {
|
||||
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::<std::collections::HashMap<_, _>>();
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user