sdk: Add the OIDC helper methods from the FFI.

This commit is contained in:
Doug
2024-06-19 16:14:56 +01:00
parent 735ae1ce75
commit 082dda0b24
5 changed files with 235 additions and 205 deletions

View File

@@ -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<Arc<OidcAuthenticationData>, AuthenticationError> {
) -> Result<Arc<OidcAuthorizationData>, 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<OidcAuthenticationData>,
authentication_data: Arc<OidcAuthorizationData>,
callback_url: String,
) -> Result<Arc<Client>, AuthenticationError> {
let client_guard = self.client.read().await;

View File

@@ -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<Arc<OidcAuthenticationData>, AuthenticationError> {
let oidc = self.inner.oidc();
) -> Result<Arc<OidcAuthorizationData>, 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::<HashMap<_, _>>();
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<OidcAuthenticationData>,
authorization_data: Arc<OidcAuthorizationData>,
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::<HashMap<_, _>>();
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<Url, ClientId>,
) -> 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<Url, ClientId>,
) -> 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")]

View File

@@ -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()
}
}

View File

@@ -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<OidcAuthorizationData, OidcError> {
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(), &registrations) {
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(&registrations)
.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,

View File

@@ -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<dyn std::error::Error>),
SaveFailure(#[source] Box<dyn std::error::Error + Send + Sync>),
}
/// A client ID that has been registered with an OpenID Connect provider.