Skip to content
Open
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
3 changes: 3 additions & 0 deletions crates/matrix-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ All notable changes to this project will be documented in this file.
- [**breaking**] Add `authentication::oauth::qrcode::login::LoginProgress::SyncingSecrets` to
indicate that secrets are being synced between the two devices.
([#5760](https://github.com/matrix-org/matrix-rust-sdk/pull/5760))
- Add `authentication::oauth::OAuth::grant_login_with_qr_code` to reciprocate a login by
generating a QR code on the existing device.
([#5801](https://github.com/matrix-org/matrix-rust-sdk/pull/5801))

### Refactor

Expand Down
86 changes: 85 additions & 1 deletion crates/matrix-sdk/src/authentication/oauth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ use self::{
registration::{ClientMetadata, ClientRegistrationResponse, register_client},
};
use super::{AuthData, SessionTokens};
#[cfg(feature = "e2e-encryption")]
use crate::authentication::oauth::qrcode::{ReciprocateQrCodeAuthError, scanned};
use crate::{Client, HttpError, RefreshTokenError, Result, client::SessionChange, executor::spawn};

pub(crate) struct OAuthCtx {
Expand Down Expand Up @@ -364,7 +366,7 @@ impl OAuth {
as_variant!(data, AuthData::OAuth)
}

/// Log in using a QR code.
/// Log in this device using a QR code.
///
/// # Arguments
///
Expand All @@ -380,6 +382,12 @@ impl OAuth {
LoginWithQrCodeBuilder { client: &self.client, registration_data }
}

/// Grant login to a new device using a QR code.
#[cfg(feature = "e2e-encryption")]
pub fn grant_login_with_qr_code<'a>(&'a self) -> GrantLoginWithQrCodeBuilder<'a> {
GrantLoginWithQrCodeBuilder { client: &self.client }
}

/// Restore or register the OAuth 2.0 client for the server with the given
/// metadata, with the given optional [`ClientRegistrationData`].
///
Expand Down Expand Up @@ -1505,6 +1513,82 @@ impl<'a> LoginWithQrCodeBuilder<'a> {
}
}

/// Builder for QR login reciprocation handlers.
#[cfg(feature = "e2e-encryption")]
#[derive(Debug)]
pub struct GrantLoginWithQrCodeBuilder<'a> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, I like this grant login terminology. I think it's much easier to understand compared to reciprocate.

/// The underlying Matrix API client.
client: &'a Client,
}

#[cfg(feature = "e2e-encryption")]
impl<'a> GrantLoginWithQrCodeBuilder<'a> {
/// Grant login by generating a QR code on this device to be scanned by the
/// new device.
///
/// # Example
///
/// ```no_run
/// use anyhow::bail;
/// use futures_util::StreamExt;
/// use matrix_sdk::{
/// Client, authentication::oauth::{
/// qrcode::scanned::ReciprocateProgress
/// }
/// };
/// use std::io::stdin;
/// # _ = async {
/// // Build the client as usual.
/// let client = Client::builder()
/// .server_name_or_homeserver_url("matrix.org")
/// .handle_refresh_tokens()
/// .build()
/// .await?;
///
/// let oauth = client.oauth();
///
/// // Subscribing to the progress is necessary since we need to capture the
/// // verification URL and open it in a browser to let the user consent to
/// // the new login.
/// let mut handler = oauth.grant_login_with_qr_code().scanned().await?;
/// let mut progress = handler.subscribe_to_progress();
///
/// // Create a task which will show us the progress and allows us to capture
/// // and open the verification URL.
/// let task = tokio::spawn(async move {
/// while let Some(state) = progress.next().await {
/// match state {
/// ReciprocateProgress::Created | ReciprocateProgress::WaitingForCheckCode | ReciprocateProgress::SyncingSecrets => (),
/// ReciprocateProgress::WaitingForAuth { verification_uri } => {
/// println!("Please open {verification_uri} to confirm the new login")
/// },
/// ReciprocateProgress::Done => break,
/// }
/// }
/// });
///
/// // Now wait for the other device to scan the QR code
/// println!("Please scan the QR code on your other device: {:?}", handler.qr_code_data());
/// handler.wait_for_scan().await?;
///
/// // Let the user put in the checkcode so we can verify the channel is secure and
/// // then attempt to finish the grant.
/// println!("Please enter the code displayed on your other device");
/// let mut s = String::new();
/// stdin().read_line(&mut s).unwrap();
/// let check_code = s.trim().parse::<u8>().unwrap();
/// handler.confirm_check_code_and_finish(check_code).await;
///
/// // Now wait for the process to finish.
/// task.await;
///
/// println!("Successfully logged in: {:?} {:?}", client.user_id(), client.device_id());
/// # anyhow::Ok(()) };
/// ```
pub async fn scanned(&self) -> Result<scanned::ReciprocateHandler, ReciprocateQrCodeAuthError> {
scanned::ReciprocateHandler::new(self.client.clone()).await
}
}
/// A full session for the OAuth 2.0 API.
#[derive(Debug, Clone)]
pub struct OAuthSession {
Expand Down
6 changes: 3 additions & 3 deletions crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,7 @@ mod test {
server.mock_query_keys().ok().expect(1).named("query_keys").mount().await;

let client = HttpClient::new(reqwest::Client::new(), Default::default());
let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url)
let alice = SecureChannel::reciprocate(client, &rendezvous_server.homeserver_url)
.await
.expect("Alice should be able to create a secure channel.");

Expand Down Expand Up @@ -1043,7 +1043,7 @@ mod test {
server.mock_who_am_i().ok().named("whoami").mount().await;

let client = HttpClient::new(reqwest::Client::new(), Default::default());
let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url)
let alice = SecureChannel::reciprocate(client, &rendezvous_server.homeserver_url)
.await
.expect("Alice should be able to create a secure channel.");

Expand Down Expand Up @@ -1325,7 +1325,7 @@ mod test {
server.mock_who_am_i().ok().named("whoami").mount().await;

let client = HttpClient::new(reqwest::Client::new(), Default::default());
let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url)
let alice = SecureChannel::reciprocate(client, &rendezvous_server.homeserver_url)
.await
.expect("Alice should be able to create a secure channel.");

Expand Down
2 changes: 2 additions & 0 deletions crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub use vodozemac::ecies::{Error as EciesError, MessageDecodeError};

mod login;
mod messages;
mod reciprocate;
mod rendezvous_channel;
mod secure_channel;

Expand All @@ -46,6 +47,7 @@ pub use self::{
LoginWithGeneratedQrCode, LoginWithQrCode, QrProgress,
},
messages::{LoginFailureReason, LoginProtocolType, QrAuthMessage},
reciprocate::{Error as ReciprocateQrCodeAuthError, scanned},
};
use super::CrossProcessRefreshLockError;
#[cfg(doc)]
Expand Down
222 changes: 222 additions & 0 deletions crates/matrix-sdk/src/authentication/oauth/qrcode/reciprocate/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Copyright 2025 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::time::{Duration, Instant};

use eyeball::SharedObservable;
use matrix_sdk_base::crypto::{store::SecretsBundleExportError, types::SecretsBundle};
use oauth2::VerificationUriComplete;
use ruma::api::client::device;
use thiserror::Error;
use url::Url;

use super::{
LoginProtocolType, QrAuthMessage, SecureChannelError,
secure_channel::{AlmostEstablishedSecureChannel, EstablishedSecureChannel, SecureChannel},
};
use crate::{Client, authentication::oauth::qrcode::LoginFailureReason};

pub mod scanned;

/// Error type for failures related to reciprocating a login.
#[derive(Debug, Error)]
pub enum Error {
/// Secrets backup not set up.
#[error("Secrets backup not set up")]
MissingSecretsBackup(Option<SecretsBundleExportError>),

/// The request took too long to complete.
#[error("The request took too long to complete")]
Timeout,

/// The check code was incorrect.
#[error("The check code was incorrect")]
InvalidCheckCode,

/// The device could not be created.
#[error("The device could not be created")]
UnableToCreateDevice,

/// Auth handshake error.
#[error("Auth handshake error: {0}")]
Unknown(String),

/// Unsupported protocol.
#[error("Unsupported protocol: {0}")]
UnsupportedProtocol(LoginProtocolType),

/// The requested device ID is already in use.
#[error("The requested device ID is already in use")]
DeviceIDAlreadyInUse,

/// Invalid state.
#[error("Invalid state")]
InvalidState,
}

impl From<SecureChannelError> for Error {
fn from(e: SecureChannelError) -> Self {
match e {
SecureChannelError::InvalidCheckCode => Self::InvalidCheckCode,
e => Self::Unknown(e.to_string()),
}
}
}

impl From<SecretsBundleExportError> for Error {
fn from(e: SecretsBundleExportError) -> Self {
Self::MissingSecretsBackup(Some(e))
}
}

/// The progress of the reciprocation process.
enum ReciprocateProgress {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be pub, no?

I think naming this GrangLoginProgress might be nicer as well.

/// The secure channel has been confirmed and this device is waiting for the
/// authorization to complete.
WaitingForAuth {
/// A URI to open in a (secure) system browser to verify the new login.
verification_uri: Url,
},
/// The new device has been granted access and this device is sending the
/// secrets to it.
SyncingSecrets,
/// The process is complete.
Done,
}

async fn export_secrets_bundle(client: &Client) -> Result<SecretsBundle, Error> {
let secrets_bundle = client
.olm_machine()
.await
.as_ref()
.ok_or_else(|| Error::MissingSecretsBackup(None))?
.store()
.export_secrets_bundle()
.await?;
Ok(secrets_bundle)
}

async fn finish_login_grant<P: From<ReciprocateProgress>>(
client: &Client,
channel: &mut EstablishedSecureChannel,
secrets_bundle: &SecretsBundle,
state: &SharedObservable<P>,
) -> Result<(), Error> {
// The new device registers with the authorization server and sends it a device
// authorization authorization request.
// -- MSC4108 OAuth 2.0 login step 2

// We wait for the new device to send us the m.login.protocol message with the
// device authorization grant information. -- MSC4108 OAuth 2.0 login step 3
let message = channel.receive_json().await?;
let QrAuthMessage::LoginProtocol { device_authorization_grant, protocol, device_id } = message
else {
return Err(Error::Unknown(
"Receiving unexpected message when expecting LoginProtocol".to_owned(),
));
};

// We verify the selected protocol.
// -- MSC4108 OAuth 2.0 login step 4
if protocol != LoginProtocolType::DeviceAuthorizationGrant {
channel
.send_json(QrAuthMessage::LoginFailure {
reason: LoginFailureReason::UnsupportedProtocol,
homeserver: None,
})
.await?;
return Err(Error::UnsupportedProtocol(protocol));
}

// We check that the device ID is still available.
// -- MSC4108 OAuth 2.0 login step 4 continued
let request = device::get_device::v3::Request::new(device_id.to_base64().into());
if client.send(request).await.is_ok() {
channel
.send_json(QrAuthMessage::LoginFailure {
reason: LoginFailureReason::DeviceAlreadyExists,
homeserver: None,
})
.await?;
return Err(Error::DeviceIDAlreadyInUse);
}
Comment on lines +145 to +153
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds a bit too lenient, we should specifically check for a 404 error.

Otherwise we'll continue with the login even if we get any other form of error despite the device existing.


// We emit an update so that the caller can open the verification URI in a
// system browser to consent to the login.
// -- MSC4108 OAuth 2.0 login step 4 continued
let verification_uri = Url::parse(
device_authorization_grant
.verification_uri_complete
.map(VerificationUriComplete::into_secret)
.unwrap_or(device_authorization_grant.verification_uri.to_string())
.as_str(),
)
.map_err(|_| Error::UnableToCreateDevice)?;
state.set(ReciprocateProgress::WaitingForAuth { verification_uri }.into());

// We send the new device the m.login.protocol_accepted message to let it know
// that the consent process is in progress.
// -- MSC4108 OAuth 2.0 login step 4 continued
let message = QrAuthMessage::LoginProtocolAccepted;
channel.send_json(&message).await?;
Comment on lines +166 to +172
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we want to set the WaitingForAuth state only after we notified the new device that it can poll the server for the access token?

I guess it doesn't matter much but it sounds like it's more correct to first let the device know and only then let the user open the verification URI.


// The new device displays the user code it received from the authorization
// server and starts polling for an access token. In parallel, the user
// consents to the new login in the browser on this device, while verifying
// the user code displayed on the other device. -- MSC4108 OAuth 2.0 login
// steps 5 & 6

// We wait for the new device to send us the m.login.success message
let message: QrAuthMessage = channel.receive_json().await?;
let QrAuthMessage::LoginSuccess = message else {
return Err(Error::Unknown(
"Receiving unexpected message when expecting LoginSuccess".to_owned(),
));
};

// We check that the new device was created successfully allowing for up to 10
// seconds of delay. -- MSC4108 Secret sharing and device verification step
// 1
let request = device::get_device::v3::Request::new(device_id.to_base64().into());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is the second instance where we need this, can we add a convenience method to the Client for this?

Maybe even something like fn device_exists(device_id: ...) -> Result<bool> which handles the 404 for you so the snippet bellow becomes:

if client.device_exists(device_id).await? {
    ...
} else {
    ...
}

let deadline = Instant::now() + Duration::from_secs(10);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let deadline = Instant::now() + Duration::from_secs(10);
let deadline = Instant::now() + Duration::from_secs(10);

loop {
if client.send(request.clone()).await.is_ok() {
break;
}
// If the deadline hasn't yet passed, give it some time and retry the request.
if Instant::now() < deadline {
tokio::time::sleep(Duration::from_secs(1)).await;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sleep duration might be a bit heavy, what do you think about 500ms?

continue;
}
// The deadline has passed. Let's fail the login process.
channel
.send_json(QrAuthMessage::LoginFailure {
reason: LoginFailureReason::DeviceNotFound,
homeserver: None,
})
.await?;
return Err(Error::DeviceIDAlreadyInUse);
Comment on lines +194 to +209
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an if/else if/else branch with the branches flattened out.

Can we make this a proper if/else if/else to make it clear from a glance that only one of the branches will be executed at each iteration.

}

// We send the new device the secrets bundle.
// -- MSC4108 Secret sharing and device verification step 2
state.set(ReciprocateProgress::SyncingSecrets.into());
let message = QrAuthMessage::LoginSecrets(secrets_bundle.clone());
channel.send_json(&message).await?;

// And we're done.
state.set(ReciprocateProgress::Done.into());

Ok(())
}
Loading
Loading