diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f9551e7e..6694e321 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,8 +102,10 @@ method to start the process that hosts the UI Control API. Credential requests on the Gateway are handled one at a time; if a new request comes in, it is immediately rejected, and the client is expected to retry if -necessary. Currently, clients cannot cancel their own requests; the user has to -do that via the UI. +necessary. Clients can cancel their own requests by passing a `request_handle` +option: the Gateway exports an `org.freedesktop.impl.portal.Request` object at +that path for the duration of the request, and calling `Close()` on it cancels +the request and dismisses the UI. Otherwise, the user has to cancel via the UI. ### `credentialsd/src/credential_service/` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a06a327..5caa1dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Improvements - daemon: Cancel ceremony if UI disappears (Thank you, @michaelbeaumont!) +- daemon: Allow clients to cancel requests through the `request_handle` option. - daemon: Fix UI service failing to launch when started manually instead of via D-Bus activation. - daemon: Allow Firefox to contact daemon on more distros with different app IDs (Thank you, @michaelbeaumont!) - daemon: Fix all subsequent requests being rejected after the UI failed to launch. diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index cbdc9c5e..51b5b9d2 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -17,6 +17,7 @@ use tokio::sync::mpsc::Receiver; use tokio::sync::oneshot; use tokio::sync::{Mutex as AsyncMutex, mpsc::Sender}; use tokio::task::AbortHandle; +use tokio_util::sync::CancellationToken; use zbus::connection::Connection; use zbus::zvariant::OwnedObjectPath; @@ -27,7 +28,9 @@ use crate::{ model::{CredentialRequest, CredentialResponse}, }; use crate::{ - credential_service::{CredentialServiceError, DeviceStateUpdate, ManageDevice, nfc::NfcState}, + credential_service::{ + CredentialServiceError, DeviceStateUpdate, ManageDevice, RequestId, nfc::NfcState, + }, model::ClientDetails, }; use crate::{dbus::ui_control::Ceremony, gateway::WebAuthnError}; @@ -38,6 +41,8 @@ pub struct UiRequestContext { /// Client window handle window_handle: Option, activation_token: Option, + /// Cancelled when the client closes the request before it completes. + client_cancellation: CancellationToken, response_channel: oneshot::Sender>, } @@ -60,6 +65,7 @@ pub async fn start_flow_control_service, activation_token: Option, + client_cancellation: CancellationToken, ) -> Result { + if client_cancellation.is_cancelled() { + return Err(CredentialServiceError::Internal( + "Request cancelled by client before it started.".to_string(), + )); + } let (request_tx, request_rx) = oneshot::channel(); let (request_id, cancellation_token) = svc.lock().await.init_request(&msg, request_tx).await?; let operation = msg.operation(); @@ -144,6 +157,26 @@ async fn handle>>> = Arc::new(Mutex::new(None)); let set_pin_tx: Arc>>> = Arc::new(Mutex::new(None)); @@ -297,6 +330,25 @@ async fn handle( + svc: &AsyncMutex, + request_id: RequestId, + request_cancellation: &CancellationToken, + client_cancellation: &CancellationToken, +) -> bool { + tokio::select! { + biased; + _ = request_cancellation.cancelled() => false, + _ = client_cancellation.cancelled() => { + tracing::debug!(%request_id, "Client closed the request, cancelling"); + svc.lock().await.cancel_request(request_id).await; + true + } + } +} + fn forward_background_event_stream( flow: Ceremony, mut stream: impl Stream + Send + Unpin + 'static, @@ -323,6 +375,7 @@ pub trait CredentialRequestController { request: CredentialRequest, window_handle: Option, activation_token: Option, + cancellation: CancellationToken, ) -> Result; } @@ -338,6 +391,7 @@ impl CredentialRequestController for CredentialRequestControllerClient { request: CredentialRequest, window_handle: Option, activation_token: Option, + client_cancellation: CancellationToken, ) -> Result { let (tx, rx) = oneshot::channel(); self.initiator @@ -346,6 +400,7 @@ impl CredentialRequestController for CredentialRequestControllerClient { app, window_handle, activation_token, + client_cancellation, response_channel: tx, }) .await @@ -367,10 +422,8 @@ mod tests { use std::pin::Pin; use credentialsd_common::model::{Device, Operation}; - use tokio_util::sync::CancellationToken; use super::*; - use crate::credential_service::RequestId; const TEST_REQUEST_ID: RequestId = 42; @@ -466,6 +519,7 @@ mod tests { app, None, None, + CancellationToken::new(), ) .await; @@ -475,4 +529,72 @@ mod tests { vec![TEST_REQUEST_ID] ); } + + #[tokio::test] + async fn test_client_cancellation_cancels_request() { + let svc = AsyncMutex::new(MockDeviceManager::default()); + let request_cancellation = CancellationToken::new(); + let client_cancellation = CancellationToken::new(); + client_cancellation.cancel(); + + let cancelled = cancel_on_client_request( + &svc, + TEST_REQUEST_ID, + &request_cancellation, + &client_cancellation, + ) + .await; + + assert!(cancelled); + assert_eq!( + *svc.lock().await.cancelled.lock().unwrap(), + vec![TEST_REQUEST_ID] + ); + } + + #[tokio::test] + async fn test_finished_request_is_not_cancelled_by_client() { + let svc = AsyncMutex::new(MockDeviceManager::default()); + let request_cancellation = CancellationToken::new(); + let client_cancellation = CancellationToken::new(); + request_cancellation.cancel(); + client_cancellation.cancel(); + + let cancelled = cancel_on_client_request( + &svc, + TEST_REQUEST_ID, + &request_cancellation, + &client_cancellation, + ) + .await; + + assert!(!cancelled); + assert!(svc.lock().await.cancelled.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn test_cancelled_client_request_does_not_start() { + let svc = Arc::new(AsyncMutex::new(MockDeviceManager::default())); + let app = ClientDetails { + app_id: "org.example.App".to_string(), + pid: 1, + }; + let client_cancellation = CancellationToken::new(); + client_cancellation.cancel(); + + let result = handle( + svc.clone(), + FailingUiController, + create_test_request().await, + app, + None, + None, + client_cancellation, + ) + .await; + + assert!(result.is_err()); + // The request was never initialized, so there is nothing to cancel. + assert!(svc.lock().await.cancelled.lock().unwrap().is_empty()); + } } diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index a39f63d5..e087cb0d 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -7,11 +7,12 @@ use std::{ use serde::{Deserialize, Serialize, ser::SerializeTuple}; use tokio::sync::Mutex as AsyncMutex; +use tokio_util::sync::CancellationToken; use zbus::{ - Connection, DBusError, interface, + Connection, DBusError, fdo, interface, message::Header, - names::{BusName, UniqueName}, - zvariant::{DeserializeDict, Optional, Type, Value}, + names::{BusName, OwnedUniqueName, UniqueName}, + zvariant::{DeserializeDict, Optional, OwnedObjectPath, Type, Value}, }; use credentialsd_common::model::WindowHandle; @@ -29,6 +30,9 @@ use super::{GatewayService, RequestContext, check_origin_from_app}; pub const PORTAL_SERVICE_PATH: &str = "/org/freedesktop/portal/desktop"; +/// Prefix of the request object paths created by xdg-desktop-portal. +const PORTAL_REQUEST_PATH_PREFIX: &str = "/org/freedesktop/portal/desktop/request/"; + pub(super) async fn start_dbus_gateway( svc: Arc>, ) -> Result { @@ -78,6 +82,7 @@ impl CredentialPortalGateway { activation_token, top_origin, public_key, + request_handle, } = options; let request_json = match (&cred_type, public_key) { @@ -113,15 +118,29 @@ impl CredentialPortalGateway { public_key: Some(CreatePublicKeyCredentialRequest { request_json }), }; - let response = self - .gateway_service - .lock() - .await - .handle_create_credential(request, context, parent_window.into(), activation_token) - .await - .map_err(Error::from); - - response.into() + run_ceremony( + connection, + header.sender(), + request_handle, + |cancellation| async move { + let Some(svc) = cancellation + .run_until_cancelled(self.gateway_service.lock()) + .await + else { + return Err(Error::AbortError); + }; + svc.handle_create_credential( + request, + context, + parent_window.into(), + activation_token, + cancellation, + ) + .await + .map_err(Error::from) + }, + ) + .await } #[zbus(out_args("response", "results"))] @@ -138,6 +157,7 @@ impl CredentialPortalGateway { activation_token, top_origin, public_key, + request_handle, } = options; let app_validation_result = validate_app_details( connection, @@ -169,14 +189,116 @@ impl CredentialPortalGateway { public_key: Some(GetPublicKeyCredentialRequest { request_json }), }; - let response = self - .gateway_service - .lock() - .await - .handle_get_credential(request, context, parent_window.into(), activation_token) - .await - .map_err(Error::from); - response.into() + run_ceremony( + connection, + header.sender(), + request_handle, + |cancellation| async move { + let Some(svc) = cancellation + .run_until_cancelled(self.gateway_service.lock()) + .await + else { + return Err(Error::AbortError); + }; + svc.handle_get_credential( + request, + context, + parent_window.into(), + activation_token, + cancellation, + ) + .await + .map_err(Error::from) + }, + ) + .await + } +} + +/// Runs a ceremony that the client can cancel. +/// +/// If `request_handle` is set, an `org.freedesktop.impl.portal.Request` object +/// is exported at that path for the duration of the ceremony. Calling `Close()` +/// on it cancels the token passed to `ceremony`, and the ceremony is reported +/// as cancelled. +async fn run_ceremony( + connection: &Connection, + sender: Option<&UniqueName<'_>>, + request_handle: Option, + ceremony: F, +) -> PortalResult +where + F: FnOnce(CancellationToken) -> Fut, + Fut: Future>, +{ + let cancellation = CancellationToken::new(); + let Some(request_handle) = request_handle else { + return ceremony(cancellation).await.into(); + }; + + let Some(sender) = sender else { + return Err(Error::SecurityError).into(); + }; + if !request_handle + .as_str() + .starts_with(PORTAL_REQUEST_PATH_PREFIX) + { + tracing::warn!(%request_handle, "Invalid request handle passed. Rejecting request."); + return Err(Error::TypeError).into(); + } + let request = PortalRequest { + caller: sender.to_owned().into(), + cancellation: cancellation.clone(), + }; + match connection + .object_server() + .at(request_handle.as_ref(), request) + .await + { + Ok(true) => {} + Ok(false) => { + tracing::warn!(%request_handle, "Request handle already in use. Rejecting request."); + return Err(Error::TypeError).into(); + } + Err(err) => return Err(Error::ZBus(err)).into(), + } + + let result = ceremony(cancellation.clone()).await; + + if let Err(err) = connection + .object_server() + .remove::(request_handle.as_ref()) + .await + { + tracing::warn!(%request_handle, %err, "Failed to remove request handle"); + } + + if result.is_err() && cancellation.is_cancelled() { + tracing::debug!(%request_handle, "Request cancelled by client"); + PortalResult::cancelled() + } else { + result.into() + } +} + +/// Request object that lets the caller cancel an in-flight ceremony. +struct PortalRequest { + /// Unique bus name of the caller that started the ceremony. + caller: OwnedUniqueName, + cancellation: CancellationToken, +} + +#[interface(name = "org.freedesktop.impl.portal.Request")] +impl PortalRequest { + async fn close(&self, #[zbus(header)] header: Header<'_>) -> fdo::Result<()> { + if header.sender() != Some(&self.caller) { + tracing::warn!(sender = ?header.sender(), "Rejecting request to close request from untrusted caller"); + return Err(fdo::Error::AccessDenied( + "Only the caller that started the request may close it".to_string(), + )); + } + self.cancellation.cancel(); + Ok(()) } } @@ -209,6 +331,10 @@ struct CreateCredentialPortalOptions { /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) /// type. public_key: Option, + + /// Path at which to export an `org.freedesktop.impl.portal.Request` + /// object that the caller can use to cancel the request. + request_handle: Option, } #[derive(Debug, DeserializeDict, Type)] @@ -225,6 +351,10 @@ struct GetCredentialPortalOptions { /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) /// type. public_key: Option, + + /// Path at which to export an `org.freedesktop.impl.portal.Request` + /// object that the caller can use to cancel the request. + request_handle: Option, } #[allow(clippy::enum_variant_names)] @@ -288,14 +418,21 @@ impl From for Error { #[derive(Serialize)] enum PortalResponse { Success = 0, - // Cancelled = 1, + Cancelled = 1, Other = 2, } #[derive(Type)] #[zvariant(signature = "ua{sv}")] struct PortalResult { - inner: Result, + /// `None` if the request was cancelled. + inner: Option>, +} + +impl PortalResult { + fn cancelled() -> Self { + PortalResult { inner: None } + } } impl Serialize for PortalResult @@ -309,14 +446,18 @@ where { let mut map = serializer.serialize_tuple(2)?; match &self.inner { - Err(err) => { + None => { + map.serialize_element(&(PortalResponse::Cancelled as u32))?; + map.serialize_element(&HashMap::<&str, Value<'_>>::new())?; + } + Some(Err(err)) => { map.serialize_element(&(PortalResponse::Other as u32))?; map.serialize_element(&HashMap::<&str, Value<'_>>::from([( "error", Value::Str(err.to_string().into()), )]))?; } - Ok(response) => { + Some(Ok(response)) => { map.serialize_element(&(PortalResponse::Success as u32))?; map.serialize_element(&response)?; } @@ -327,7 +468,7 @@ where impl From> for PortalResult { fn from(value: Result) -> Self { - PortalResult { inner: value } + PortalResult { inner: Some(value) } } } @@ -429,3 +570,234 @@ async fn query_peer_pidfd( ) .ok() } + +#[cfg(test)] +mod test { + use std::{collections::HashMap, rc::Rc}; + + use gio::{TestDBus, TestDBusFlags}; + use tokio::sync::oneshot; + use zbus::zvariant::{LE, ObjectPath, OwnedValue, Value, serialized::Context, to_bytes}; + + use super::*; + + const REQUEST_HANDLE: &str = "/org/freedesktop/portal/desktop/request/1_42/t"; + + type TestResponse = HashMap<&'static str, Value<'static>>; + + fn encode( + value: &T, + ) -> zbus::zvariant::serialized::Data<'static, 'static> { + to_bytes(Context::new_dbus(LE, 0), value).unwrap() + } + + fn options(request_handle: Option<&str>) -> HashMap<&'static str, Value<'static>> { + let mut options = HashMap::from([ + ("top_origin", Value::from("https://example.com")), + ("public_key", Value::from("{}")), + ]); + if let Some(handle) = request_handle { + let path = ObjectPath::try_from(handle.to_string()).unwrap(); + options.insert("request_handle", Value::from(path)); + } + options + } + + fn decode_portal_result( + result: PortalResult>, Error>, + ) -> (u32, HashMap) { + encode(&result).deserialize().unwrap().0 + } + + #[test] + fn test_create_options_without_request_handle() { + let (options, _): (CreateCredentialPortalOptions, _) = + encode(&options(None)).deserialize().unwrap(); + assert_eq!(options.public_key.as_deref(), Some("{}")); + assert!(options.request_handle.is_none()); + } + + #[test] + fn test_create_options_with_request_handle() { + let (options, _): (CreateCredentialPortalOptions, _) = + encode(&options(Some(REQUEST_HANDLE))) + .deserialize() + .unwrap(); + assert_eq!(options.request_handle.unwrap().as_str(), REQUEST_HANDLE); + } + + #[test] + fn test_get_options_without_request_handle() { + let (options, _): (GetCredentialPortalOptions, _) = + encode(&options(None)).deserialize().unwrap(); + assert_eq!(options.top_origin.as_deref(), Some("https://example.com")); + assert!(options.request_handle.is_none()); + } + + #[test] + fn test_get_options_with_request_handle() { + let (options, _): (GetCredentialPortalOptions, _) = encode(&options(Some(REQUEST_HANDLE))) + .deserialize() + .unwrap(); + assert_eq!(options.request_handle.unwrap().as_str(), REQUEST_HANDLE); + } + + #[test] + fn test_portal_result_response_codes() { + let (code, results) = decode_portal_result(Ok(HashMap::new()).into()); + assert_eq!(code, PortalResponse::Success as u32); + assert!(results.is_empty()); + + let (code, results) = decode_portal_result(PortalResult::cancelled()); + assert_eq!(code, PortalResponse::Cancelled as u32); + assert!(results.is_empty()); + + let (code, results) = decode_portal_result(Err(Error::NotAllowedError).into()); + assert_eq!(code, PortalResponse::Other as u32); + assert!(results.contains_key("error")); + } + + #[tokio::test] + async fn test_ceremony_without_request_handle_is_not_cancellable() { + let result = run_ceremony( + &connect_test_bus(None).await.0, + None, + None, + |cancellation| async move { + assert!(!cancellation.is_cancelled()); + Ok(TestResponse::new()) + }, + ) + .await; + assert_eq!( + decode_portal_result(result).0, + PortalResponse::Success as u32 + ); + } + + #[tokio::test] + async fn test_invalid_request_handle_is_rejected() { + let (connection, _bus) = connect_test_bus(None).await; + let handle = OwnedObjectPath::try_from(PORTAL_SERVICE_PATH).unwrap(); + let result = run_ceremony( + &connection, + connection.unique_name().map(|n| n.inner()), + Some(handle), + |_| async { Ok(TestResponse::new()) }, + ) + .await; + assert!(matches!(result.inner, Some(Err(Error::TypeError)))); + } + + #[tokio::test] + async fn test_close_cancels_ceremony() { + let (gateway, bus) = connect_test_bus(None).await; + let (caller, _) = connect_test_bus(Some(&bus)).await; + let (other, _) = connect_test_bus(Some(&bus)).await; + let handle = OwnedObjectPath::try_from(REQUEST_HANDLE).unwrap(); + let (started_tx, started_rx) = oneshot::channel(); + + let ceremony = run_ceremony( + &gateway, + caller.unique_name().map(|n| n.inner()), + Some(handle.clone()), + |cancellation| async move { + started_tx.send(()).unwrap(); + // Like the credential service, fail once the request is cancelled. + cancellation.cancelled().await; + Err::(Error::NotAllowedError) + }, + ); + let client = async { + started_rx.await.unwrap(); + let err = close_request(&other, &gateway, &handle).await.unwrap_err(); + assert!(matches!(err, fdo::Error::AccessDenied(_)), "{err:?}"); + close_request(&caller, &gateway, &handle).await.unwrap(); + }; + let (result, ()) = tokio::join!(ceremony, client); + + let (code, _) = decode_portal_result(result); + assert_eq!(code, PortalResponse::Cancelled as u32); + // The request object is removed when the ceremony ends. + assert!(close_request(&caller, &gateway, &handle).await.is_err()); + } + + #[tokio::test] + async fn test_request_handle_removed_after_success() { + let (gateway, bus) = connect_test_bus(None).await; + let (caller, _) = connect_test_bus(Some(&bus)).await; + let handle = OwnedObjectPath::try_from(REQUEST_HANDLE).unwrap(); + + let result = run_ceremony( + &gateway, + caller.unique_name().map(|n| n.inner()), + Some(handle.clone()), + |_| async { Ok(TestResponse::new()) }, + ) + .await; + + assert_eq!( + decode_portal_result(result).0, + PortalResponse::Success as u32 + ); + assert!(close_request(&caller, &gateway, &handle).await.is_err()); + } + + /// Connects to a private test bus, starting one if `bus` is `None`. + async fn connect_test_bus(bus: Option<&TestBus>) -> (Connection, TestBus) { + let bus = match bus { + Some(bus) => bus.clone(), + None => TestBus::new(), + }; + let connection = zbus::connection::Builder::address(bus.address.as_str()) + .unwrap() + .build() + .await + .unwrap(); + (connection, bus) + } + + async fn close_request( + caller: &Connection, + gateway: &Connection, + handle: &OwnedObjectPath, + ) -> fdo::Result<()> { + caller + .call_method( + gateway.unique_name(), + handle, + Some("org.freedesktop.impl.portal.Request"), + "Close", + &(), + ) + .await + .map(|_| ()) + .map_err(fdo::Error::from) + } + + #[derive(Clone)] + struct TestBus { + address: String, + _bus: Rc, + } + + struct TestBusGuard(TestDBus); + + impl Drop for TestBusGuard { + fn drop(&mut self) { + self.0.down(); + } + } + + impl TestBus { + fn new() -> Self { + let bus = TestDBus::new(TestDBusFlags::NONE); + bus.up(); + let address = bus.bus_address().expect("test bus to be up").to_string(); + Self { + address, + _bus: Rc::new(TestBusGuard(bus)), + } + } + } +} diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index 644591ec..e1a52cfb 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -14,6 +14,7 @@ use std::{ use credentialsd_common::model::WindowHandle; use tokio::sync::Mutex as AsyncMutex; +use tokio_util::sync::CancellationToken; use zbus::{ Connection, zvariant::{DeserializeDict, NoneValue, OwnedValue, SerializeDict, Type}, @@ -83,6 +84,7 @@ impl GatewayService { context: RequestContext, parent_window: Option, activation_token: Option, + cancellation: CancellationToken, ) -> Result { let request_environment = validate_request(&context)?; @@ -115,6 +117,7 @@ impl GatewayService { cred_request, parent_window, activation_token, + cancellation, ) .await?; @@ -148,6 +151,7 @@ impl GatewayService { context: RequestContext, parent_window: Option, activation_token: Option, + cancellation: CancellationToken, ) -> Result { let request_environment = validate_request(&context)?; @@ -177,6 +181,7 @@ impl GatewayService { cred_request, parent_window, activation_token, + cancellation, ) .await?; diff --git a/doc/api.md b/doc/api.md index d445b615..b385dbbe 100644 --- a/doc/api.md +++ b/doc/api.md @@ -58,6 +58,7 @@ sequenceDiagram ### Improvements - Document errors returned to gateway requests +- (Gateway): Add optional `request_handle` option to allow clients to cancel requests ## [0.1.0] - 2025-08-14 @@ -204,6 +205,7 @@ CreateCredentialRequest( IN options a{sv} { activation_token: s top_origin: s + request_handle: o }, IN app_id s, @@ -212,6 +214,8 @@ CreateCredentialRequest( For information on `parent_window`, see [Window Identifiers](#window-identifiers). +For information on `request_handle`, see [Cancelling Requests](#cancelling-requests). + > TODO: We should make this a tagged enum ``` @@ -322,6 +326,7 @@ GetCredentialRequest ( IN options a{sv} { activation_token: s top_origin: s + request_handle: o public_key: s }, @@ -331,6 +336,8 @@ GetCredentialRequest ( For information on `parent_window`, see [Window Identifiers](#window-identifiers). +For information on `request_handle`, see [Cancelling Requests](#cancelling-requests). + Note that while only one credential type can be specified in `CreateCredential()`, credential types in this `GetCredential()` are not mutually exclusive: as new credential types are added to the specification, a client may @@ -402,6 +409,43 @@ denoted by `type: "publicKey"`: - `TypeError`: An invalid request is made. - `NotAllowedError`: catch-all error. +## Cancelling Requests + +`CreateCredential()` and `GetCredential()` return a response code and results, +following the [XDG Desktop Portal `Response` conventions][xdg-request]: + +- `0`: Success. The results contain the credential. +- `1`: The request was cancelled by the client. The results are empty. +- `2`: The request failed. The results contain an `error` string. + +A caller that wants to be able to cancel an in-flight request passes the +optional `request_handle` option, an object path below +`/org/freedesktop/portal/desktop/request/`. For the duration of the request, the +gateway exports an object implementing `org.freedesktop.impl.portal.Request` at +that path: + +``` +org.freedesktop.impl.portal.Request { + Close() +} +``` + +Calling `Close()` cancels the request and dismisses the UI, and the original +method call returns response code `1`. `Close()` is only accepted from the same +D-Bus connection that made the `CreateCredential()` or `GetCredential()` call; +other callers receive `org.freedesktop.DBus.Error.AccessDenied`. The object is +removed when the request ends, whether it succeeded, failed or was cancelled. + +If the path is already in use or is outside of the prefix above, the request is +rejected with `TypeError`. If `request_handle` is omitted, the request cannot be +cancelled. + +xdg-desktop-portal passes the path of the `Request` object it created for the +client, so that closing that request, or the client disconnecting, cancels the +ceremony. + +[xdg-request]: https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html + # Flow Control API The Flow Control API is used by the UI to pass user interactions through the