Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ffi: expose methods for SSO login #3558

Merged
merged 17 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion bindings/matrix-sdk-ffi/src/authentication.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use std::collections::HashMap;
use std::{
collections::HashMap,
fmt::{self, Debug},
sync::Arc,
};

use matrix_sdk::{
oidc::{
Expand All @@ -15,6 +19,8 @@ use matrix_sdk::{
};
use url::Url;

use crate::client::Client;

#[derive(uniffi::Object)]
pub struct HomeserverLoginDetails {
pub(crate) url: String,
Expand Down Expand Up @@ -47,6 +53,54 @@ impl HomeserverLoginDetails {
}
}

/// An object encapsulating the SSO login flow
#[derive(uniffi::Object)]
pub struct SsoHandler {
/// The wrapped Client.
pub(crate) client: Arc<Client>,

/// The underlying URL for authentication.
pub(crate) url: String,
}

#[uniffi::export(async_runtime = "tokio")]
impl SsoHandler {
/// Returns the URL for starting SSO authentication. The URL should be
/// opened in a web view. Once the web view succeeds, call `finish` with
/// the callback URL.
pub fn url(&self) -> String {
self.url.clone()
}

/// Completes the SSO login process.
pub async fn finish(&self, callback_url: String) -> Result<(), SsoError> {
let auth = self.client.inner.matrix_auth();
let url = Url::parse(&callback_url).map_err(|_| SsoError::CallbackUrlInvalid)?;
let builder =
auth.login_with_sso_callback(url).map_err(|_| SsoError::CallbackUrlInvalid)?;
builder.await.map_err(|_| SsoError::LoginWithTokenFailed)?;
Ok(())
}
}

impl Debug for SsoHandler {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
fmt.debug_struct("SsoHandler").field("url", &self.url).finish_non_exhaustive()
}
}

#[derive(Debug, thiserror::Error, uniffi::Error)]
#[uniffi(flat_error)]
pub enum SsoError {
#[error("The supplied callback URL used to complete SSO is invalid.")]
CallbackUrlInvalid,
#[error("Logging in with the token from the supplied callback URL failed.")]
LoginWithTokenFailed,

#[error("An error occurred: {message}")]
Generic { message: String },
}

/// The configuration to use when authenticating with OIDC.
#[derive(uniffi::Record)]
pub struct OidcConfiguration {
Expand Down
17 changes: 16 additions & 1 deletion bindings/matrix-sdk-ffi/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{
collections::HashMap,
fmt::Debug,
mem::ManuallyDrop,
path::Path,
sync::{Arc, RwLock},
Expand Down Expand Up @@ -60,7 +61,7 @@ use url::Url;

use super::{room::Room, session_verification::SessionVerificationController, RUNTIME};
use crate::{
authentication::{HomeserverLoginDetails, OidcConfiguration, OidcError},
authentication::{HomeserverLoginDetails, OidcConfiguration, OidcError, SsoError, SsoHandler},
client,
encryption::Encryption,
notification::NotificationClient,
Expand Down Expand Up @@ -287,6 +288,20 @@ impl Client {
Ok(())
}

/// Returns a handler to start the SSO login process.
pub(crate) async fn start_sso_login(
self: &Arc<Self>,
redirect_url: String,
idp_id: Option<String>,
) -> Result<Arc<SsoHandler>, SsoError> {
let auth = self.inner.matrix_auth();
let url = auth
.get_sso_login_url(redirect_url.as_str(), idp_id.as_deref())
.await
.map_err(|e| SsoError::Generic { message: e.to_string() })?;
Ok(Arc::new(SsoHandler { client: Arc::clone(self), url }))
}

/// Requests the URL needed for login in a web view using OIDC. Once the web
/// view has succeeded, call `login_with_oidc_callback` with the callback it
/// returns. If a failure occurs and a callback isn't available, make sure
Expand Down
62 changes: 62 additions & 0 deletions crates/matrix-sdk/src/matrix_auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ use ruma::{
serde::JsonObject,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{debug, error, info, instrument};
use url::Url;

use crate::{
authentication::AuthData,
Expand Down Expand Up @@ -73,6 +75,14 @@ pub struct MatrixAuth {
client: Client,
}

/// Errors that can occur when using the SSO API.
#[derive(Debug, Error)]
pub enum SsoError {
/// The supplied callback URL used to complete SSO is invalid.
#[error("callback URL invalid")]
CallbackUrlInvalid,
}

impl MatrixAuth {
pub(crate) fn new(client: Client) -> Self {
Self { client }
Expand Down Expand Up @@ -292,6 +302,58 @@ impl MatrixAuth {
LoginBuilder::new_token(self.clone(), token.to_owned())
}

/// A higher level wrapper around the methods to complete an SSO login after
/// the user has logged in through a webview. This method should be used
/// in tandem with [`MatrixAuth::get_sso_login_url`].
Johennes marked this conversation as resolved.
Show resolved Hide resolved
///
/// # Arguments
///
/// * `callback_url` - The received callback URL carrying the login token.
///
/// # Examples
///
/// ```no_run
/// use matrix_sdk::Client;
/// # use url::Url;
/// # let homeserver = Url::parse("https://example.com").unwrap();
/// # let redirect_url = "http://localhost:1234";
/// # let callback_url = Url::parse("http://localhost:1234?loginToken=token").unwrap();
/// # async {
/// let client = Client::new(homeserver).await.unwrap();
/// let auth = client.matrix_auth();
/// let sso_url = auth.get_sso_login_url(redirect_url, None);
///
/// // Let the user authenticate at the SSO URL.
/// // Receive the callback_url.
///
/// let response = auth
/// .login_with_sso_callback(callback_url)
/// .unwrap()
/// .initial_device_display_name("My app")
/// .await
/// .unwrap();
///
/// println!(
/// "Logged in as {}, got device_id {} and access_token {}",
/// response.user_id, response.device_id, response.access_token,
/// );
/// # };
/// ```
pub fn login_with_sso_callback(&self, callback_url: Url) -> Result<LoginBuilder, SsoError> {
#[derive(Deserialize)]
struct QueryParameters {
#[serde(rename = "loginToken")]
login_token: Option<String>,
}

let query_string = callback_url.query().unwrap_or_default();
let query: QueryParameters =
serde_html_form::from_str(query_string).map_err(|_| SsoError::CallbackUrlInvalid)?;
let token = query.login_token.ok_or(SsoError::CallbackUrlInvalid)?;

Ok(self.login_token(token.as_str()))
}

/// Log into the server via Single Sign-On.
///
/// This takes care of the whole SSO flow:
Expand Down
36 changes: 36 additions & 0 deletions crates/matrix-sdk/tests/integration/matrix_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,42 @@ async fn test_login_with_sso_token() {
assert!(logged_in, "Client should be logged in");
}

#[async_test]
async fn test_login_with_sso_callback() {
let (client, server) = no_retry_test_client_with_server().await;

Mock::given(method("GET"))
.and(path("/_matrix/client/r0/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::LOGIN_TYPES))
.mount(&server)
.await;

let auth = client.matrix_auth();
let can_sso = auth
.get_login_types()
.await
.unwrap()
.flows
.iter()
.any(|flow| matches!(flow, LoginType::Sso(_)));
assert!(can_sso);

let sso_url = auth.get_sso_login_url("http://127.0.0.1:3030", None).await;
sso_url.unwrap();

Mock::given(method("POST"))
.and(path("/_matrix/client/r0/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::LOGIN))
.mount(&server)
.await;

let callback_url = Url::parse("http://127.0.0.1:3030?loginToken=averysmalltoken").unwrap();
auth.login_with_sso_callback(callback_url).unwrap().await.unwrap();

let logged_in = client.logged_in();
assert!(logged_in, "Client should be logged in");
}

#[async_test]
async fn test_login_error() {
let (client, server) = no_retry_test_client_with_server().await;
Expand Down
Loading