Skip to content

Commit a00d871

Browse files
committed
feat(oauth): add flow for reciprocating a login using a QR code generated on the existing device
Signed-off-by: Johannes Marbach <[email protected]>
1 parent e08b851 commit a00d871

File tree

4 files changed

+1036
-2
lines changed

4 files changed

+1036
-2
lines changed

crates/matrix-sdk/src/authentication/oauth/mod.rs

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,12 @@ use self::{
225225
registration::{ClientMetadata, ClientRegistrationResponse, register_client},
226226
};
227227
use super::{AuthData, SessionTokens};
228-
use crate::{Client, HttpError, RefreshTokenError, Result, client::SessionChange, executor::spawn};
228+
use crate::{
229+
Client, HttpError, RefreshTokenError, Result,
230+
authentication::oauth::qrcode::{ReciprocateQrCodeAuthError, scanned},
231+
client::SessionChange,
232+
executor::spawn,
233+
};
229234

230235
pub(crate) struct OAuthCtx {
231236
/// Lock and state when multiple processes may refresh an OAuth 2.0 session.
@@ -364,7 +369,7 @@ impl OAuth {
364369
as_variant!(data, AuthData::OAuth)
365370
}
366371

367-
/// Log in using a QR code.
372+
/// Log in this device using a QR code.
368373
///
369374
/// # Arguments
370375
///
@@ -380,6 +385,11 @@ impl OAuth {
380385
LoginWithQrCodeBuilder { client: &self.client, registration_data }
381386
}
382387

388+
/// Grant login to a new device using a QR code.
389+
pub fn grant_login_with_qr_code<'a>(&'a self) -> GrantLoginWithQrCodeBuilder<'a> {
390+
GrantLoginWithQrCodeBuilder { client: &self.client }
391+
}
392+
383393
/// Restore or register the OAuth 2.0 client for the server with the given
384394
/// metadata, with the given optional [`ClientRegistrationData`].
385395
///
@@ -1505,6 +1515,81 @@ impl<'a> LoginWithQrCodeBuilder<'a> {
15051515
}
15061516
}
15071517

1518+
/// Builder for QR login reciprocation handlers.
1519+
#[cfg(feature = "e2e-encryption")]
1520+
#[derive(Debug)]
1521+
pub struct GrantLoginWithQrCodeBuilder<'a> {
1522+
/// The underlying Matrix API client.
1523+
client: &'a Client,
1524+
}
1525+
1526+
impl<'a> GrantLoginWithQrCodeBuilder<'a> {
1527+
/// Grant login by generating a QR code on this device to be scanned by the
1528+
/// new device.
1529+
///
1530+
/// # Example
1531+
///
1532+
/// ```no_run
1533+
/// use anyhow::bail;
1534+
/// use futures_util::StreamExt;
1535+
/// use matrix_sdk::{
1536+
/// Client, authentication::oauth::{
1537+
/// qrcode::scanned::ReciprocateProgress
1538+
/// }
1539+
/// };
1540+
/// use std::io::stdin;
1541+
/// # _ = async {//!
1542+
/// // Build the client as usual.
1543+
/// let client = Client::builder()
1544+
/// .server_name_or_homeserver_url("matrix.org")
1545+
/// .handle_refresh_tokens()
1546+
/// .build()
1547+
/// .await?;
1548+
///
1549+
/// let oauth = client.oauth();
1550+
///
1551+
/// // Subscribing to the progress is necessary since we need to capture the
1552+
/// // verification URL and open it in a browser to let the user consent to
1553+
/// // the new login.
1554+
/// let handler = oauth.grant_login_with_qr_code().scanned().await?;
1555+
/// let mut progress = handler.subscribe_to_progress();
1556+
///
1557+
/// // Create a task which will show us the progress and allows us to capture
1558+
/// // and open the verification URL.
1559+
/// let task = tokio::spawn(async move {
1560+
/// while let Some(state) = progress.next().await {
1561+
/// match state {
1562+
/// ReciprocateProgress::Created | ReciprocateProgress::WaitingForCheckCode | ReciprocateProgress::SyncingSecrets => (),
1563+
/// ReciprocateProgress::WaitingForAuth { verification_uri } => {
1564+
/// println!("Please open {verification_uri} to confirm the new login")
1565+
/// },
1566+
/// ReciprocateProgress::Done => break,
1567+
/// }
1568+
/// }
1569+
/// });
1570+
///
1571+
/// // Now wait for the other device to scan the QR code
1572+
/// println!("Please scan the QR code on your other device: {:?}", handler.qr_code_data());
1573+
/// handler.wait_for_scan().await?;
1574+
///
1575+
/// // Let the user put in the checkcode so we can verify the channel is secure and
1576+
/// // then attempt to finish the grant.
1577+
/// println!("Please enter the code displayed on your other device");
1578+
/// let mut s = String::new();
1579+
/// stdin().read_line(&mut s).unwrap();
1580+
/// let check_code = s.trim().parse::<u8>().unwrap();
1581+
/// handler.confirm_check_code_and_finish(check_code).await;
1582+
///
1583+
/// // Now wait for the process to finish.
1584+
/// task.await;
1585+
///
1586+
/// println!("Successfully logged in: {:?} {:?}", client.user_id(), client.device_id());
1587+
/// # anyhow::Ok(()) };//!
1588+
/// ```
1589+
pub async fn scanned(&self) -> Result<scanned::ReciprocateHandler, ReciprocateQrCodeAuthError> {
1590+
scanned::ReciprocateHandler::new(self.client.clone()).await
1591+
}
1592+
}
15081593
/// A full session for the OAuth 2.0 API.
15091594
#[derive(Debug, Clone)]
15101595
pub struct OAuthSession {

crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub use vodozemac::ecies::{Error as EciesError, MessageDecodeError};
3737

3838
mod login;
3939
mod messages;
40+
mod reciprocate;
4041
mod rendezvous_channel;
4142
mod secure_channel;
4243

@@ -46,6 +47,7 @@ pub use self::{
4647
LoginWithGeneratedQrCode, LoginWithQrCode, QrProgress,
4748
},
4849
messages::{LoginFailureReason, LoginProtocolType, QrAuthMessage},
50+
reciprocate::{Error as ReciprocateQrCodeAuthError, scanned},
4951
};
5052
use super::CrossProcessRefreshLockError;
5153
#[cfg(doc)]
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright 2025 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use std::time::{Duration, Instant};
16+
17+
use eyeball::SharedObservable;
18+
use matrix_sdk_base::crypto::{store::SecretsBundleExportError, types::SecretsBundle};
19+
use oauth2::VerificationUriComplete;
20+
use ruma::api::client::device;
21+
use thiserror::Error;
22+
use url::Url;
23+
24+
use super::{
25+
LoginProtocolType, QrAuthMessage, SecureChannelError,
26+
secure_channel::{AlmostEstablishedSecureChannel, EstablishedSecureChannel, SecureChannel},
27+
};
28+
use crate::{Client, authentication::oauth::qrcode::LoginFailureReason};
29+
30+
pub mod scanned;
31+
32+
/// Error type for failures related to reciprocating a login.
33+
#[derive(Debug, Error)]
34+
pub enum Error {
35+
/// Secrets backup not set up.
36+
#[error("Secrets backup not set up")]
37+
MissingSecretsBackup(Option<SecretsBundleExportError>),
38+
39+
/// The request took too long to complete.
40+
#[error("The request took too long to complete")]
41+
Timeout,
42+
43+
/// The check code was incorrect.
44+
#[error("The check code was incorrect")]
45+
InvalidCheckCode,
46+
47+
/// The device could not be created.
48+
#[error("The device could not be created")]
49+
UnableToCreateDevice,
50+
51+
/// Auth handshake error.
52+
#[error("Auth handshake error: {0}")]
53+
Unknown(String),
54+
55+
/// Unsupported protocol.
56+
#[error("Unsupported protocol: {0}")]
57+
UnsupportedProtocol(LoginProtocolType),
58+
59+
/// The requested device ID is already in use.
60+
#[error("The requested device ID is already in use")]
61+
DeviceIDAlreadyInUse,
62+
63+
/// Invalid state.
64+
#[error("Invalid state")]
65+
InvalidState,
66+
}
67+
68+
impl From<SecureChannelError> for Error {
69+
fn from(e: SecureChannelError) -> Self {
70+
match e {
71+
SecureChannelError::InvalidCheckCode => Self::InvalidCheckCode,
72+
e => Self::Unknown(e.to_string()),
73+
}
74+
}
75+
}
76+
77+
impl From<SecretsBundleExportError> for Error {
78+
fn from(e: SecretsBundleExportError) -> Self {
79+
Self::MissingSecretsBackup(Some(e))
80+
}
81+
}
82+
83+
/// The progress of the reciprocation process.
84+
enum ReciprocateProgress {
85+
/// The secure channel has been confirmed and this device is waiting for the
86+
/// authorization to complete.
87+
WaitingForAuth {
88+
/// A URI to open in a (secure) system browser to verify the new login.
89+
verification_uri: Url,
90+
},
91+
/// The new device has been granted access and this device is sending the
92+
/// secrets to it.
93+
SyncingSecrets,
94+
/// The process is complete.
95+
Done,
96+
}
97+
98+
async fn export_secrets_bundle(client: &Client) -> Result<SecretsBundle, Error> {
99+
let secrets_bundle = client
100+
.olm_machine()
101+
.await
102+
.as_ref()
103+
.ok_or_else(|| Error::MissingSecretsBackup(None))?
104+
.store()
105+
.export_secrets_bundle()
106+
.await?;
107+
Ok(secrets_bundle)
108+
}
109+
110+
async fn finish_login_grant<P: From<ReciprocateProgress>>(
111+
client: &Client,
112+
channel: &mut EstablishedSecureChannel,
113+
secrets_bundle: &SecretsBundle,
114+
state: &SharedObservable<P>,
115+
) -> Result<(), Error> {
116+
// The new device registers with the authorization server and sends it a device
117+
// authorization authorization request.
118+
// -- MSC4108 OAuth 2.0 login step 2
119+
120+
// We wait for the new device to send us the m.login.protocol message with the
121+
// device authorization grant information. -- MSC4108 OAuth 2.0 login step 3
122+
let message = channel.receive_json().await?;
123+
let QrAuthMessage::LoginProtocol { device_authorization_grant, protocol, device_id } = message
124+
else {
125+
return Err(Error::Unknown(
126+
"Receiving unexpected message when expecting LoginProtocol".to_owned(),
127+
));
128+
};
129+
130+
// We verify the selected protocol.
131+
// -- MSC4108 OAuth 2.0 login step 4
132+
if protocol != LoginProtocolType::DeviceAuthorizationGrant {
133+
channel
134+
.send_json(QrAuthMessage::LoginFailure {
135+
reason: LoginFailureReason::UnsupportedProtocol,
136+
homeserver: None,
137+
})
138+
.await?;
139+
return Err(Error::UnsupportedProtocol(protocol));
140+
}
141+
142+
// We check that the device ID is still available.
143+
// -- MSC4108 OAuth 2.0 login step 4 continued
144+
let request = device::get_device::v3::Request::new(device_id.to_base64().into());
145+
if client.send(request).await.is_ok() {
146+
channel
147+
.send_json(QrAuthMessage::LoginFailure {
148+
reason: LoginFailureReason::DeviceAlreadyExists,
149+
homeserver: None,
150+
})
151+
.await?;
152+
return Err(Error::DeviceIDAlreadyInUse);
153+
}
154+
155+
// We emit an update so that the caller can open the verification URI in a
156+
// system browser to consent to the login.
157+
// -- MSC4108 OAuth 2.0 login step 4 continued
158+
let verification_uri = Url::parse(
159+
device_authorization_grant
160+
.verification_uri_complete
161+
.map(VerificationUriComplete::into_secret)
162+
.unwrap_or(device_authorization_grant.verification_uri.to_string())
163+
.as_str(),
164+
)
165+
.map_err(|_| Error::UnableToCreateDevice)?;
166+
state.set(ReciprocateProgress::WaitingForAuth { verification_uri }.into());
167+
168+
// We send the new device the m.login.protocol_accepted message to let it know
169+
// that the consent process is in progress.
170+
// -- MSC4108 OAuth 2.0 login step 4 continued
171+
let message = QrAuthMessage::LoginProtocolAccepted;
172+
channel.send_json(&message).await?;
173+
174+
// The new device displays the user code it received from the authorization
175+
// server and starts polling for an access token. In parallel, the user
176+
// consents to the new login in the browser on this device, while verifying
177+
// the user code displayed on the other device. -- MSC4108 OAuth 2.0 login
178+
// steps 5 & 6
179+
180+
// We wait for the new device to send us the m.login.success message
181+
let message: QrAuthMessage = channel.receive_json().await?;
182+
let QrAuthMessage::LoginSuccess = message else {
183+
return Err(Error::Unknown(
184+
"Receiving unexpected message when expecting LoginSuccess".to_owned(),
185+
));
186+
};
187+
188+
// We check that the new device was created successfully allowing for up to 10
189+
// seconds of delay. -- MSC4108 Secret sharing and device verification step
190+
// 1
191+
let request = device::get_device::v3::Request::new(device_id.to_base64().into());
192+
let deadline = Instant::now() + Duration::from_secs(10);
193+
loop {
194+
if client.send(request.clone()).await.is_ok() {
195+
break;
196+
}
197+
// If the deadline hasn't yet passed, give it some time and retry the request.
198+
if Instant::now() < deadline {
199+
tokio::time::sleep(Duration::from_secs(1)).await;
200+
continue;
201+
}
202+
// The deadline has passed. Let's fail the login process.
203+
channel
204+
.send_json(QrAuthMessage::LoginFailure {
205+
reason: LoginFailureReason::DeviceNotFound,
206+
homeserver: None,
207+
})
208+
.await?;
209+
return Err(Error::DeviceIDAlreadyInUse);
210+
}
211+
212+
// We send the new device the secrets bundle.
213+
// -- MSC4108 Secret sharing and device verification step 2
214+
state.set(ReciprocateProgress::SyncingSecrets.into());
215+
let message = QrAuthMessage::LoginSecrets(secrets_bundle.clone());
216+
channel.send_json(&message).await?;
217+
218+
// And we're done.
219+
state.set(ReciprocateProgress::Done.into());
220+
221+
Ok(())
222+
}

0 commit comments

Comments
 (0)