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:
smurching-oai
2026-11-10 01:28:45 +00:00
committed by copyberry
parent 1885417cd4
commit d8c42ecf8f
23 changed files with 414 additions and 26 deletions
@@ -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"
},
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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.
*/
@@ -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, };
@@ -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, };
@@ -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")]
+14 -8
View File
@@ -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):