diff --git a/bindings/matrix-sdk-ffi/src/authentication_service.rs b/bindings/matrix-sdk-ffi/src/authentication_service.rs index 3820ad69d..cc7ecf233 100644 --- a/bindings/matrix-sdk-ffi/src/authentication_service.rs +++ b/bindings/matrix-sdk-ffi/src/authentication_service.rs @@ -13,7 +13,7 @@ use matrix_sdk::{ registration::{ClientMetadata, Localized, VerifiedClientMetadata}, requests::GrantType, }, - OidcError, + OidcAuthorizationData, OidcError, }, ClientBuildError as MatrixClientBuildError, HttpError, RumaApiError, }; @@ -167,24 +167,6 @@ pub struct OidcConfiguration { pub dynamic_registrations_file: String, } -/// The data required to authenticate against an OIDC server. -#[derive(uniffi::Object)] -pub struct OidcAuthenticationData { - /// The underlying URL for authentication. - pub(crate) url: Url, - /// A unique identifier for the request, used to ensure the response - /// originated from the authentication issuer. - pub(crate) state: String, -} - -#[uniffi::export] -impl OidcAuthenticationData { - /// The login URL to use for authentication. - pub fn login_url(&self) -> String { - self.url.to_string() - } -} - #[derive(uniffi::Object)] pub struct HomeserverLoginDetails { url: String, @@ -360,7 +342,7 @@ impl AuthenticationService { /// returns. pub async fn url_for_oidc_login( &self, - ) -> Result, AuthenticationError> { + ) -> Result, AuthenticationError> { let client_guard = self.client.read().await; let Some(client) = client_guard.as_ref() else { return Err(AuthenticationError::ClientMissing); @@ -376,7 +358,7 @@ impl AuthenticationService { /// Completes the OIDC login process. pub async fn login_with_oidc_callback( &self, - authentication_data: Arc, + authentication_data: Arc, callback_url: String, ) -> Result, AuthenticationError> { let client_guard = self.client.read().await; diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index 3559075b6..a5c260111 100644 --- a/bindings/matrix-sdk-ffi/src/client.rs +++ b/bindings/matrix-sdk-ffi/src/client.rs @@ -13,15 +13,12 @@ use matrix_sdk::{ requests::account_management::AccountManagementActionFull, types::{ client_credentials::ClientCredentials, - errors::ClientErrorCode::AccessDenied, registration::{ ClientMetadata, ClientMetadataVerificationError, VerifiedClientMetadata, }, - requests::Prompt, }, - AuthorizationResponse, Oidc, OidcSession, + OidcAuthorizationData, OidcError, OidcSession, }, - reqwest::StatusCode, ruma::{ api::client::{ media::get_content_thumbnail::v3::Method, @@ -39,7 +36,7 @@ use matrix_sdk::{ serde::Raw, EventEncryptionAlgorithm, RoomId, TransactionId, UInt, UserId, }, - AuthApi, AuthSession, Client as MatrixClient, SessionChange, SessionTokens, + AuthApi, AuthSession, Client as MatrixClient, Error, SessionChange, SessionTokens, }; use matrix_sdk_ui::notification_client::{ NotificationClient as MatrixNotificationClient, @@ -63,7 +60,7 @@ use url::Url; use super::{room::Room, session_verification::SessionVerificationController, RUNTIME}; use crate::{ - authentication_service::{AuthenticationError, OidcAuthenticationData, OidcConfiguration}, + authentication_service::{AuthenticationError, OidcConfiguration}, client, encryption::Encryption, notification::NotificationClient, @@ -362,72 +359,58 @@ impl Client { pub(crate) async fn url_for_oidc_login( &self, oidc_configuration: &OidcConfiguration, - ) -> Result, AuthenticationError> { - let oidc = self.inner.oidc(); + ) -> Result, AuthenticationError> { + let oidc_metadata: VerifiedClientMetadata = oidc_configuration.try_into()?; + let registrations_file = Path::new(&oidc_configuration.dynamic_registrations_file); + let static_registrations = oidc_configuration + .static_registrations + .iter() + .filter_map(|(issuer, client_id)| { + let Ok(issuer) = Url::parse(issuer) else { + tracing::error!("Failed to parse {:?}", issuer); + return None; + }; + Some((issuer, ClientId(client_id.clone()))) + }) + .collect::>(); + let registrations = OidcRegistrations::new( + registrations_file, + oidc_metadata.clone(), + static_registrations, + )?; - let issuer = match oidc.fetch_authentication_issuer().await { - Ok(issuer) => issuer, - Err(error) => { - if error - .as_client_api_error() - .is_some_and(|err| err.status_code == StatusCode::NOT_FOUND) - { - return Err(AuthenticationError::OidcNotSupported); - } else { - return Err(AuthenticationError::ServerUnreachable(error)); - } - } - }; + let data = + self.inner.oidc().url_for_oidc_login(oidc_metadata, registrations).await.map_err( + |e| match e { + OidcError::MissingAuthenticationIssuer => AuthenticationError::OidcNotSupported, + OidcError::MissingRedirectUri => AuthenticationError::OidcMetadataInvalid, + _ => AuthenticationError::OidcError { message: e.to_string() }, + }, + )?; - let redirect_url = Url::parse(&oidc_configuration.redirect_uri) - .map_err(|_e| AuthenticationError::OidcMetadataInvalid)?; - - self.configure_oidc(&oidc, issuer, oidc_configuration).await?; - - let mut data_builder = oidc.login(redirect_url, None)?; - // TODO: Add a check for the Consent prompt when MAS is updated. - data_builder = data_builder.prompt(vec![Prompt::Consent]); - let data = data_builder.build().await?; - - Ok(Arc::new(OidcAuthenticationData { url: data.url, state: data.state })) + Ok(Arc::new(data)) } /// Completes the OIDC login process. pub(crate) async fn login_with_oidc_callback( &self, - authentication_data: Arc, + authorization_data: Arc, callback_url: String, ) -> Result<(), AuthenticationError> { - let oidc = self.inner.oidc(); + let url = Url::parse(&callback_url).or(Err(AuthenticationError::OidcCallbackUrlInvalid))?; - let url = - Url::parse(&callback_url).map_err(|_| AuthenticationError::OidcCallbackUrlInvalid)?; - - let response = AuthorizationResponse::parse_uri(&url) - .map_err(|_| AuthenticationError::OidcCallbackUrlInvalid)?; - - let code = match response { - AuthorizationResponse::Success(code) => code, - AuthorizationResponse::Error(err) => { - if err.error.error == AccessDenied { - // The user cancelled the login in the web view. - return Err(AuthenticationError::OidcCancelled); + self.inner.oidc().login_with_oidc_callback(&authorization_data, url).await.map_err( + |e| match e { + Error::Oidc(OidcError::InvalidCallbackUrl) => { + AuthenticationError::OidcCallbackUrlInvalid } - return Err(AuthenticationError::OidcError { - message: err.error.error.to_string(), - }); - } - }; - - if code.state != authentication_data.state { - return Err(AuthenticationError::OidcCallbackUrlInvalid); - }; - - oidc.finish_authorization(code).await?; - - oidc.finish_login() - .await - .map_err(|e| AuthenticationError::OidcError { message: e.to_string() })?; + Error::Oidc(OidcError::InvalidState) => AuthenticationError::OidcCallbackUrlInvalid, + Error::Oidc(OidcError::CancelledAuthorization) => { + AuthenticationError::OidcCancelled + } + _ => AuthenticationError::OidcError { message: e.to_string() }, + }, + )?; Ok(()) } @@ -457,126 +440,6 @@ impl Client { .any(|login_type| matches!(login_type, get_login_types::v3::LoginType::Password(_))); Ok(supports_password) } - - /// Handle any necessary configuration in order for login via OIDC to - /// succeed. This includes performing dynamic client registration against - /// the homeserver's issuer or restoring a previous registration if one has - /// been stored. - async fn configure_oidc( - &self, - oidc: &Oidc, - issuer: String, - configuration: &OidcConfiguration, - ) -> Result<(), AuthenticationError> { - if oidc.client_credentials().is_some() { - tracing::info!("OIDC is already configured."); - return Ok(()); - }; - - let oidc_metadata: VerifiedClientMetadata = configuration.try_into()?; - let registrations_file = Path::new(&configuration.dynamic_registrations_file); - let static_registrations = configuration - .static_registrations - .iter() - .filter_map(|(issuer, client_id)| { - let Ok(issuer) = Url::parse(issuer) else { - tracing::error!("Failed to parse {:?}", issuer); - return None; - }; - Some((issuer, ClientId(client_id.clone()))) - }) - .collect::>(); - - if self.load_client_registration( - oidc, - issuer.clone(), - oidc_metadata.clone(), - registrations_file, - static_registrations.clone(), - ) { - tracing::info!("OIDC configuration loaded from disk."); - return Ok(()); - } - - tracing::info!("Registering this client for OIDC."); - let registration_response = - oidc.register_client(&issuer, oidc_metadata.clone(), None).await?; - - // The format of the credentials changes according to the client metadata that - // was sent. Public clients only get a client ID. - let credentials = - ClientCredentials::None { client_id: registration_response.client_id.clone() }; - oidc.restore_registered_client(issuer, oidc_metadata, credentials); - - tracing::info!("Persisting OIDC registration data."); - self.store_client_registration(oidc, registrations_file, static_registrations)?; - - Ok(()) - } - - /// Stores the current OIDC dynamic client registration so it can be re-used - /// if we ever log in via the same issuer again. - fn store_client_registration( - &self, - oidc: &Oidc, - registrations_file: &Path, - static_registrations: HashMap, - ) -> Result<(), AuthenticationError> { - let issuer = Url::parse(oidc.issuer().ok_or(AuthenticationError::OidcNotSupported)?) - .map_err(|_| AuthenticationError::OidcError { - message: String::from("Failed to parse issuer URL."), - })?; - let client_id = oidc - .client_credentials() - .ok_or(AuthenticationError::OidcError { - message: String::from("Missing client registration."), - })? - .client_id() - .to_owned(); - - let metadata = oidc.client_metadata().ok_or(AuthenticationError::OidcError { - message: String::from("Missing client metadata."), - })?; - - let registrations = - OidcRegistrations::new(registrations_file, metadata.clone(), static_registrations)?; - registrations.set_and_write_client_id(ClientId(client_id), issuer)?; - - Ok(()) - } - - /// Attempts to load an existing OIDC dynamic client registration for the - /// currently configured issuer. - fn load_client_registration( - &self, - oidc: &Oidc, - issuer: String, - oidc_metadata: VerifiedClientMetadata, - registrations_file: &Path, - static_registrations: HashMap, - ) -> bool { - let Ok(issuer_url) = Url::parse(&issuer) else { - tracing::error!("Failed to parse {issuer:?}"); - return false; - }; - let Some(registrations) = - OidcRegistrations::new(registrations_file, oidc_metadata.clone(), static_registrations) - .ok() - else { - return false; - }; - let Some(client_id) = registrations.client_id(&issuer_url) else { - return false; - }; - - oidc.restore_registered_client( - issuer, - oidc_metadata, - ClientCredentials::None { client_id: client_id.0 }, - ); - - true - } } #[uniffi::export(async_runtime = "tokio")] diff --git a/crates/matrix-sdk/src/oidc/auth_code_builder.rs b/crates/matrix-sdk/src/oidc/auth_code_builder.rs index 1cdc3bf1b..148c6ca7d 100644 --- a/crates/matrix-sdk/src/oidc/auth_code_builder.rs +++ b/crates/matrix-sdk/src/oidc/auth_code_builder.rs @@ -236,9 +236,20 @@ impl OidcAuthCodeUrlBuilder { /// The data needed to perform authorization using OpenID Connect. #[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] pub struct OidcAuthorizationData { /// The URL that should be presented. pub url: Url, - /// A unique identifier for the request. + /// A unique identifier for the request, used to ensure the response + /// originated from the authentication issuer. pub state: String, } + +#[cfg(feature = "uniffi")] +#[uniffi::export] +impl OidcAuthorizationData { + /// The login URL to use for authorization. + pub fn login_url(&self) -> String { + self.url.to_string() + } +} diff --git a/crates/matrix-sdk/src/oidc/mod.rs b/crates/matrix-sdk/src/oidc/mod.rs index 744e1fe71..49c0f0097 100644 --- a/crates/matrix-sdk/src/oidc/mod.rs +++ b/crates/matrix-sdk/src/oidc/mod.rs @@ -170,6 +170,7 @@ use std::{collections::HashMap, fmt, sync::Arc}; use as_variant::as_variant; use eyeball::SharedObservable; use futures_core::Stream; +use http::StatusCode; pub use mas_oidc_client::{error, requests, types}; use mas_oidc_client::{ requests::{ @@ -178,10 +179,11 @@ use mas_oidc_client::{ }, types::{ client_credentials::ClientCredentials, - errors::ClientError, + errors::{ClientError, ClientErrorCode::AccessDenied}, iana::oauth::OAuthTokenTypeHint, oidc::{AccountManagementAction, VerifiedProviderMetadata}, registration::{ClientRegistrationResponse, VerifiedClientMetadata}, + requests::Prompt, scope::{MatrixApiScopeToken, Scope, ScopeToken}, IdToken, }, @@ -219,6 +221,7 @@ use self::{ use crate::{ authentication::{qrcode::LoginWithQrCode, AuthData}, client::SessionChange, + oidc::registrations::{ClientId, OidcRegistrations}, Client, HttpError, RefreshTokenError, Result, }; @@ -434,6 +437,157 @@ impl Oidc { LoginWithQrCode::new(&self.client, client_metadata, data) } + /// A higher level wrapper around the configuration and login methods that + /// will take some client metadata, register the client if needed and begin + /// the login process, returning the authorization data required to show a + /// webview for a user to login to their account. Call + /// [`Oidc::login_with_oidc_callback`] to finish the process when the + /// webview is complete. + pub async fn url_for_oidc_login( + &self, + client_metadata: VerifiedClientMetadata, + registrations: OidcRegistrations, + ) -> Result { + let issuer = match self.fetch_authentication_issuer().await { + Ok(issuer) => issuer, + Err(error) => { + if error + .as_client_api_error() + .is_some_and(|err| err.status_code == StatusCode::NOT_FOUND) + { + return Err(OidcError::MissingAuthenticationIssuer); + } else { + return Err(OidcError::UnknownError(Box::new(error))); + } + } + }; + + let redirect_uris = + client_metadata.redirect_uris.clone().ok_or(OidcError::MissingRedirectUri)?; + + let redirect_url = redirect_uris.first().ok_or(OidcError::MissingRedirectUri)?; + + self.configure(issuer, client_metadata, registrations).await?; + + let mut data_builder = self.login(redirect_url.clone(), None)?; + data_builder = data_builder.prompt(vec![Prompt::Consent]); + let data = data_builder.build().await?; + + Ok(data) + } + + /// A higher level wrapper around the methods to complete a login after the + /// user has logged in through a webview. This method should be used in + /// tandem with [`Oidc::url_for_oidc_login`]. + pub async fn login_with_oidc_callback( + &self, + authorization_data: &OidcAuthorizationData, + callback_url: Url, + ) -> Result<()> { + let response = AuthorizationResponse::parse_uri(&callback_url) + .or(Err(OidcError::InvalidCallbackUrl))?; + + let code = match response { + AuthorizationResponse::Success(code) => code, + AuthorizationResponse::Error(err) => { + if err.error.error == AccessDenied { + // The user cancelled the login in the web view. + return Err(OidcError::CancelledAuthorization.into()); + } + return Err(OidcError::Authorization(err).into()); + } + }; + + // Given the InvalidState error already exists, I wonder if this was ever + // necessary to manually check? + if code.state != authorization_data.state { + return Err(OidcError::InvalidState.into()); + }; + + self.finish_authorization(code).await?; + self.finish_login().await?; + + Ok(()) + } + + /// Higher level wrapper that restores the OIDC client with automatic + /// static/dynamic client registration. + async fn configure( + &self, + issuer: String, + client_metadata: VerifiedClientMetadata, + registrations: OidcRegistrations, + ) -> std::result::Result<(), OidcError> { + if self.client_credentials().is_some() { + tracing::info!("OIDC is already configured."); + return Ok(()); + }; + + if self.load_client_registration(issuer.clone(), client_metadata.clone(), ®istrations) { + tracing::info!("OIDC configuration loaded from disk."); + return Ok(()); + } + + tracing::info!("Registering this client for OIDC."); + let registration_response = + self.register_client(&issuer, client_metadata.clone(), None).await?; + + // The format of the credentials changes according to the client metadata that + // was sent. Public clients only get a client ID. + let credentials = + ClientCredentials::None { client_id: registration_response.client_id.clone() }; + self.restore_registered_client(issuer, client_metadata, credentials); + + tracing::info!("Persisting OIDC registration data."); + self.store_client_registration(®istrations) + .map_err(|e| OidcError::UnknownError(Box::new(e)))?; + + Ok(()) + } + + /// Stores the current OIDC dynamic client registration so it can be re-used + /// if we ever log in via the same issuer again. + fn store_client_registration( + &self, + registrations: &OidcRegistrations, + ) -> std::result::Result<(), OidcError> { + let issuer = Url::parse(self.issuer().ok_or(OidcError::MissingAuthenticationIssuer)?) + .map_err(|e| OidcError::Url(e))?; + let client_id = + self.client_credentials().ok_or(OidcError::NotRegistered)?.client_id().to_owned(); + + registrations + .set_and_write_client_id(ClientId(client_id), issuer) + .map_err(|e| OidcError::UnknownError(Box::new(e)))?; + + Ok(()) + } + + /// Attempts to load an existing OIDC dynamic client registration for a + /// given issuer. + fn load_client_registration( + &self, + issuer: String, + oidc_metadata: VerifiedClientMetadata, + registrations: &OidcRegistrations, + ) -> bool { + let Ok(issuer_url) = Url::parse(&issuer) else { + error!("Failed to parse {issuer:?}"); + return false; + }; + let Some(client_id) = registrations.client_id(&issuer_url) else { + return false; + }; + + self.restore_registered_client( + issuer, + oidc_metadata, + ClientCredentials::None { client_id: client_id.0 }, + ); + + true + } + /// The OpenID Connect Provider used for authorization. /// /// Returns `None` if the client registration was not restored with @@ -1499,6 +1653,14 @@ pub enum OidcError { #[error("no dynamic registration support")] NoRegistrationSupport, + /// The client has not registered while the operation requires it. + #[error("client not registered")] + NotRegistered, + + /// The supplied redirect URIs are missing or empty. + #[error("missing or empty redirect URIs")] + MissingRedirectUri, + /// The device ID was not returned by the homeserver after login. #[error("missing device ID in response")] MissingDeviceId, @@ -1512,6 +1674,18 @@ pub enum OidcError { #[error("the supplied state is unexpected")] InvalidState, + /// The user cancelled authorization in the web view. + #[error("authorization cancelled")] + CancelledAuthorization, + + /// The login was completed with an invalid callback. + #[error("the supplied callback URL is invalid")] + InvalidCallbackUrl, + + /// An error occurred during authorization. + #[error("authorization failed")] + Authorization(AuthorizationError), + /// The device ID is invalid. #[error("invalid device ID")] InvalidDeviceId, diff --git a/crates/matrix-sdk/src/oidc/registrations.rs b/crates/matrix-sdk/src/oidc/registrations.rs index 5d3bb81de..67dd4779e 100644 --- a/crates/matrix-sdk/src/oidc/registrations.rs +++ b/crates/matrix-sdk/src/oidc/registrations.rs @@ -42,7 +42,7 @@ pub enum OidcRegistrationsError { InvalidFilePath, /// An error occurred whilst saving the registration data. #[error("Failed to save the registration data {0}.")] - SaveFailure(#[source] Box), + SaveFailure(#[source] Box), } /// A client ID that has been registered with an OpenID Connect provider.