refactor(oauth): Introduce AccountManagementUrlBuilder

It allows to reuse the URL for different actions more easily than having
to call `OAuth::account_management_url` every time for a different
action.

It also adds a method with fallback if we want to ignore action
serialization errors, to always present a URL.

Signed-off-by: Kévin Commaille <zecakeh@tedomum.fr>
This commit is contained in:
Kévin Commaille
2025-03-23 11:56:33 +01:00
committed by Damir Jelić
parent 8c988beaf2
commit bc22ff1221
6 changed files with 192 additions and 128 deletions
+17 -4
View File
@@ -8,7 +8,7 @@ use anyhow::{anyhow, Context as _};
use async_compat::get_runtime_handle;
use matrix_sdk::{
authentication::oauth::{
AccountManagementActionFull, ClientId, OAuthAuthorizationData, OAuthSession,
AccountManagementActionFull, ClientId, OAuthAuthorizationData, OAuthError, OAuthSession,
},
event_cache::EventCacheError,
media::{
@@ -606,11 +606,24 @@ impl Client {
return Ok(None);
}
match self.inner.oauth().account_management_url(action.map(Into::into)).await {
Ok(url) => Ok(url.map(|u| u.to_string())),
let mut url_builder = match self.inner.oauth().account_management_url().await {
Ok(Some(url_builder)) => url_builder,
Ok(None) => return Ok(None),
Err(e) => {
tracing::error!("Failed retrieving account management URL: {e}");
Err(e.into())
return Err(e.into());
}
};
if let Some(action) = action {
url_builder = url_builder.action(action.into());
}
match url_builder.build() {
Ok(url) => Ok(Some(url.to_string())),
Err(e) => {
tracing::error!("Failed to build account management URL: {e}");
Err(OAuthError::AccountManagementUrl(e).into())
}
}
}
+6
View File
@@ -224,6 +224,12 @@ simpler methods:
call to `OAuth::finish_login()`.
- `AuthorizationResponse`, `AuthorizationCode` and `AuthorizationError` are
now private.
- [**breaking**] - `OAuth::account_management_url()` and
`OAuth::fetch_account_management_url()` don't take an action anymore but
return an `AccountManagementUrlBuilder`. The final URL can be obtained with
`AccountManagementUrlBuilder::build()` or
`AccountManagementUrlBuilder::build_or_ignore_action()`.
([#4831](https://github.com/matrix-org/matrix-rust-sdk/pull/4831))
## [0.10.0] - 2025-02-04
@@ -73,43 +73,82 @@ pub enum AccountManagementActionFull {
CrossSigningReset,
}
/// Build the URL for accessing the account management capabilities, as defined
/// in [MSC].
/// Builder for the URL for accessing the account management capabilities, as
/// defined in [MSC4191].
///
/// # Arguments
/// This type can be instantiated with [`OAuth::account_management_url()`] and
/// [`OAuth::fetch_account_management_url()`].
///
/// * `account_management_uri` - The URL to access the issuer's account
/// management capabilities.
///
/// * `action` - The action that the user wishes to take.
///
/// # Returns
///
/// A URL to be opened in a web browser where the end-user will be able to
/// access the account management capabilities of the issuer.
///
/// # Errors
///
/// Returns an error if serializing the action fails.
/// [`AccountManagementUrlBuilder::build()`] and
/// [`AccountManagementUrlBuilder::build_or_ignore_action()`] return a URL to be
/// opened in a web browser where the end-user will be able to access the
/// account management capabilities of the issuer.
///
/// [MSC4191]: https://github.com/matrix-org/matrix-spec-proposals/pull/4191
pub(crate) fn build_account_management_url(
mut account_management_uri: Url,
action: AccountManagementActionFull,
) -> Result<Url, serde_html_form::ser::Error> {
let extra_query = serde_html_form::to_string(action)?;
/// [`OAuth::account_management_url()`]: super::OAuth::account_management_url
/// [`OAuth::fetch_account_management_url()`]: super::OAuth::fetch_account_management_url
#[derive(Debug, Clone)]
pub struct AccountManagementUrlBuilder {
account_management_uri: Url,
action: Option<AccountManagementActionFull>,
}
// Add our parameters to the query, because the URL might already have one.
let mut full_query = account_management_uri.query().map(ToOwned::to_owned).unwrap_or_default();
if !full_query.is_empty() {
full_query.push('&');
impl AccountManagementUrlBuilder {
/// Construct an [`AccountManagementUrlBuilder`] for the given URL.
pub(super) fn new(account_management_uri: Url) -> Self {
Self { account_management_uri, action: None }
}
full_query.push_str(&extra_query);
account_management_uri.set_query(Some(&full_query));
/// Set the action that the user wishes to take.
pub fn action(mut self, action: AccountManagementActionFull) -> Self {
self.action = Some(action);
self
}
Ok(account_management_uri)
/// Get the serialized action, if any.
fn serialized_action(&self) -> Result<Option<String>, serde_html_form::ser::Error> {
self.action.as_ref().map(serde_html_form::to_string).transpose()
}
/// Build the URL with the given serialized action.
fn build_inner(self, serialized_action: Option<String>) -> Url {
let Some(serialized_action) = serialized_action else {
return self.account_management_uri;
};
// Add our parameters to the query, because the URL might already have one.
let mut account_management_uri = self.account_management_uri;
let mut full_query =
account_management_uri.query().map(ToOwned::to_owned).unwrap_or_default();
if !full_query.is_empty() {
full_query.push('&');
}
full_query.push_str(&serialized_action);
account_management_uri.set_query(Some(&full_query));
account_management_uri
}
/// Build the URL to present to the end user.
///
/// # Errors
///
/// Returns an error if serializing the action fails.
pub fn build(self) -> Result<Url, serde_html_form::ser::Error> {
let serialized_action = self.serialized_action()?;
Ok(self.build_inner(serialized_action))
}
/// Build the URL to present to the end user and ignore errors while
/// serializing the action.
///
/// Returns the URL without an action if the action fails to serialize.
pub fn build_or_ignore_action(self) -> Url {
let serialized_action = self.serialized_action().ok().flatten();
self.build_inner(serialized_action)
}
}
#[cfg(test)]
@@ -117,55 +156,56 @@ mod tests {
use ruma::owned_device_id;
use url::Url;
use super::{build_account_management_url, AccountManagementActionFull};
use super::{AccountManagementActionFull, AccountManagementUrlBuilder};
#[test]
fn test_build_account_management_url_actions() {
let base_url = Url::parse("https://example.org").unwrap();
let device_id = owned_device_id!("ABCDEFG");
let url =
build_account_management_url(base_url.clone(), AccountManagementActionFull::Profile)
.unwrap();
let url = AccountManagementUrlBuilder::new(base_url.clone()).build().unwrap();
assert_eq!(url, base_url);
let url = AccountManagementUrlBuilder::new(base_url.clone())
.action(AccountManagementActionFull::Profile)
.build()
.unwrap();
assert_eq!(url.as_str(), "https://example.org/?action=org.matrix.profile");
let url = build_account_management_url(
base_url.clone(),
AccountManagementActionFull::SessionsList,
)
.unwrap();
let url = AccountManagementUrlBuilder::new(base_url.clone())
.action(AccountManagementActionFull::SessionsList)
.build()
.unwrap();
assert_eq!(url.as_str(), "https://example.org/?action=org.matrix.sessions_list");
let url = build_account_management_url(
base_url.clone(),
AccountManagementActionFull::SessionView { device_id: device_id.clone() },
)
.unwrap();
let url = AccountManagementUrlBuilder::new(base_url.clone())
.action(AccountManagementActionFull::SessionView { device_id: device_id.clone() })
.build()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.org/?action=org.matrix.session_view&device_id=ABCDEFG"
);
let url = build_account_management_url(
base_url.clone(),
AccountManagementActionFull::SessionEnd { device_id },
)
.unwrap();
let url = AccountManagementUrlBuilder::new(base_url.clone())
.action(AccountManagementActionFull::SessionEnd { device_id })
.build()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.org/?action=org.matrix.session_end&device_id=ABCDEFG"
);
let url = build_account_management_url(
base_url.clone(),
AccountManagementActionFull::AccountDeactivate,
)
.unwrap();
let url = AccountManagementUrlBuilder::new(base_url.clone())
.action(AccountManagementActionFull::AccountDeactivate)
.build()
.unwrap();
assert_eq!(url.as_str(), "https://example.org/?action=org.matrix.account_deactivate");
let url =
build_account_management_url(base_url, AccountManagementActionFull::CrossSigningReset)
.unwrap();
let url = AccountManagementUrlBuilder::new(base_url)
.action(AccountManagementActionFull::CrossSigningReset)
.build()
.unwrap();
assert_eq!(url.as_str(), "https://example.org/?action=org.matrix.cross_signing_reset");
}
@@ -173,19 +213,34 @@ mod tests {
fn test_build_account_management_url_with_query() {
let base_url = Url::parse("https://example.org/?sid=123456").unwrap();
let url =
build_account_management_url(base_url.clone(), AccountManagementActionFull::Profile)
.unwrap();
let url = AccountManagementUrlBuilder::new(base_url.clone())
.action(AccountManagementActionFull::Profile)
.build()
.unwrap();
assert_eq!(url.as_str(), "https://example.org/?sid=123456&action=org.matrix.profile");
let url = build_account_management_url(
base_url,
AccountManagementActionFull::SessionView { device_id: owned_device_id!("ABCDEFG") },
)
.unwrap();
let url = AccountManagementUrlBuilder::new(base_url)
.action(AccountManagementActionFull::SessionView {
device_id: owned_device_id!("ABCDEFG"),
})
.build()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.org/?sid=123456&action=org.matrix.session_view&device_id=ABCDEFG"
);
}
#[test]
fn test_build_account_management_url_or_ignore_action() {
let base_url = Url::parse("https://example.org").unwrap();
let url = AccountManagementUrlBuilder::new(base_url.clone()).build_or_ignore_action();
assert_eq!(url, base_url);
let url = AccountManagementUrlBuilder::new(base_url)
.action(AccountManagementActionFull::Profile)
.build_or_ignore_action();
assert_eq!(url.as_str(), "https://example.org/?action=org.matrix.profile");
}
}
@@ -187,17 +187,16 @@ use self::cross_process::{CrossProcessRefreshLockGuard, CrossProcessRefreshManag
use self::qrcode::LoginWithQrCode;
#[cfg(not(target_arch = "wasm32"))]
pub use self::registration_store::OAuthRegistrationStore;
pub use self::{
account_management_url::{AccountManagementActionFull, AccountManagementUrlBuilder},
auth_code_builder::{OAuthAuthCodeUrlBuilder, OAuthAuthorizationData},
error::OAuthError,
};
use self::{
account_management_url::build_account_management_url,
http_client::OAuthHttpClient,
oidc_discovery::discover,
registration::{register_client, ClientMetadata, ClientRegistrationResponse},
};
pub use self::{
account_management_url::AccountManagementActionFull,
auth_code_builder::{OAuthAuthCodeUrlBuilder, OAuthAuthorizationData},
error::OAuthError,
};
use super::{AuthData, SessionTokens};
use crate::{client::SessionChange, executor::spawn, Client, HttpError, RefreshTokenError, Result};
@@ -516,63 +515,46 @@ impl OAuth {
/// Build the URL where the user can manage their account.
///
/// # Arguments
/// This will always request the latest server metadata to get the account
/// management URL.
///
/// * `action` - An optional action that wants to be performed by the user
/// when they open the URL. The list of supported actions by the account
/// management URL can be found in the [`AuthorizationServerMetadata`], or
/// directly with [`OAuth::account_management_actions_supported()`].
/// To avoid making a request each time, you can use
/// [`OAuth::account_management_url()`].
///
/// Returns `Ok(None)` if the URL was not found. Returns an error if the
/// request to get the server metadata fails or the URL could not be parsed.
/// Returns an [`AccountManagementUrlBuilder`] if the URL was found. An
/// optional action to perform can be added with `.action()`, and the final
/// URL is obtained with `.build()` or `.build_or_ignore_action()`.
///
/// Returns `Ok(None)` if the URL was not found.
///
/// Returns an error if the request to get the server metadata fails or the
/// URL could not be parsed.
pub async fn fetch_account_management_url(
&self,
action: Option<AccountManagementActionFull>,
) -> Result<Option<Url>, OAuthError> {
) -> Result<Option<AccountManagementUrlBuilder>, OAuthError> {
let server_metadata = self.server_metadata().await?;
self.management_url_from_server_metadata(server_metadata, action)
}
fn management_url_from_server_metadata(
&self,
server_metadata: AuthorizationServerMetadata,
action: Option<AccountManagementActionFull>,
) -> Result<Option<Url>, OAuthError> {
let Some(base_url) = server_metadata.account_management_uri else {
return Ok(None);
};
let url = if let Some(action) = action {
build_account_management_url(base_url, action)
.map_err(OAuthError::AccountManagementUrl)?
} else {
base_url
};
Ok(Some(url))
Ok(server_metadata.account_management_uri.map(AccountManagementUrlBuilder::new))
}
/// Get the account management URL where the user can manage their
/// identity-related settings.
///
/// # Arguments
///
/// * `action` - An optional action that wants to be performed by the user
/// when they open the URL. The list of supported actions by the account
/// management URL can be found in the [`AuthorizationServerMetadata`], or
/// directly with [`OAuth::account_management_actions_supported()`].
///
/// Returns `Ok(None)` if the URL was not found. Returns an error if the
/// request to get the server metadata fails or the URL could not be parsed.
///
/// This method will cache the URL for a while, if the cache is not
/// populated it will internally call
/// [`OAuth::fetch_account_management_url()`] and cache the resulting URL
/// populated it will request the server metadata, like a call to
/// [`OAuth::fetch_account_management_url()`], and cache the resulting URL
/// before returning it.
///
/// Returns an [`AccountManagementUrlBuilder`] if the URL was found. An
/// optional action to perform can be added with `.action()`, and the final
/// URL is obtained with `.build()` or `.build_or_ignore_action()`.
///
/// Returns `Ok(None)` if the URL was not found.
///
/// Returns an error if the request to get the server metadata fails or the
/// URL could not be parsed.
pub async fn account_management_url(
&self,
action: Option<AccountManagementActionFull>,
) -> Result<Option<Url>, OAuthError> {
) -> Result<Option<AccountManagementUrlBuilder>, OAuthError> {
const CACHE_KEY: &str = "SERVER_METADATA";
let mut cache = self.client.inner.caches.server_metadata.lock().await;
@@ -585,7 +567,7 @@ impl OAuth {
server_metadata
};
self.management_url_from_server_metadata(metadata, action)
Ok(metadata.account_management_uri.map(AccountManagementUrlBuilder::new))
}
/// Discover the authentication issuer and retrieve the
@@ -24,8 +24,7 @@ use super::{
use crate::{
authentication::oauth::{
error::{AuthorizationCodeErrorResponseType, OAuthClientRegistrationError},
AccountManagementActionFull, AuthorizationValidationData, ClientRegistrationMethod,
OAuthAuthorizationCodeError,
AuthorizationValidationData, ClientRegistrationMethod, OAuthAuthorizationCodeError,
},
test_utils::{
client::{
@@ -659,7 +658,7 @@ async fn test_management_url_cache() {
assert!(!client.inner.caches.server_metadata.lock().await.contains("SERVER_METADATA"));
let management_url = oauth
.account_management_url(Some(AccountManagementActionFull::Profile))
.account_management_url()
.await
.expect("We should be able to fetch the account management url");
@@ -668,9 +667,9 @@ async fn test_management_url_cache() {
// Check that the server metadata has been inserted into the cache.
assert!(client.inner.caches.server_metadata.lock().await.contains("SERVER_METADATA"));
// Another parameter doesn't make another request for the metadata.
// Another call doesn't make another request for the metadata.
let management_url = oauth
.account_management_url(Some(AccountManagementActionFull::SessionsList))
.account_management_url()
.await
.expect("We should be able to fetch the account management url");
+14 -5
View File
@@ -389,14 +389,23 @@ impl OAuthCli {
/// Get the account management URL.
async fn account(&self, action: Option<AccountManagementActionFull>) {
match self.client.oauth().fetch_account_management_url(action).await {
Ok(Some(url)) => {
println!("\nTo manage your account, visit: {url}");
}
let mut url_builder = match self.client.oauth().fetch_account_management_url().await {
Ok(Some(url_builder)) => url_builder,
_ => {
println!("\nThis homeserver does not provide the URL to manage your account")
println!("\nThis homeserver does not provide the URL to manage your account");
return;
}
};
if let Some(action) = action {
url_builder = url_builder.action(action);
}
// We ignore errors while serializing the action, that way we can always direct
// the end user to the URL and they should be able to do what they want there.
let url = url_builder.build_or_ignore_action();
println!("\nTo manage your account, visit: {url}");
}
/// Watch incoming messages.