Skip to content

Commit 39cf8b3

Browse files
authored
Allow requesting additional scopes for OAuth2 authorization code flow
For custom integrations it might be necessary to allow the SDK to request additional scopes for the OAuth2 authorization code flow. Currently, only the MSC2967 client API and client device scopes are requested statically. Signed-off-by: fl0lli <github@fl0lli.de>
1 parent bb67150 commit 39cf8b3

File tree

10 files changed

+143
-35
lines changed

10 files changed

+143
-35
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ insta = { version = "1.42.1", features = ["json", "redactions"] }
5151
itertools = "0.14.0"
5252
js-sys = "0.3.69"
5353
mime = "0.3.17"
54+
oauth2 = { version = "5.0.0", default-features = false, features = ["reqwest", "timing-resistant-secret-traits"] }
5455
once_cell = "1.20.2"
5556
pbkdf2 = { version = "0.12.2" }
5657
pin-project-lite = "0.2.16"

bindings/matrix-sdk-ffi/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file.
88

99
### Breaking changes:
1010

11+
- `Client::url_for_oidc` now allows requesting additional scopes for the OAuth2 authorization code grant.
12+
([#5395](https://github.com/matrix-org/matrix-rust-sdk/pull/5395))
1113
- `Client::url_for_oidc` now allows passing an optional existing device id from a previous login call.
1214
([#5394](https://github.com/matrix-org/matrix-rust-sdk/pull/5394))
1315
- `ClientBuilder::build_with_qr_code` has been removed. Instead, the Client should be built by passing

bindings/matrix-sdk-ffi/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] }
7878
url.workspace = true
7979
uuid = { version = "1.4.1", features = ["v4"] }
8080
zeroize.workspace = true
81+
oauth2.workspace = true
8182

8283
[target.'cfg(target_family = "wasm")'.dependencies]
8384
console_error_panic_hook = "0.1.7"

bindings/matrix-sdk-ffi/src/client.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ use matrix_sdk_ui::{
5151
unable_to_decrypt_hook::UtdHookManager,
5252
};
5353
use mime::Mime;
54+
use oauth2::Scope;
5455
use ruma::{
5556
api::client::{alias::get_alias, error::ErrorKind, uiaa::UserIdentifier},
5657
events::{
@@ -462,20 +463,34 @@ impl Client {
462463
/// If not set, a random one will be generated. It can be an existing
463464
/// device ID from a previous login call. Note that this should be done
464465
/// only if the client also holds the corresponding encryption keys.
466+
///
467+
/// * `additional_scopes` - Additional scopes to request from the
468+
/// authorization server, e.g. "urn:matrix:client:com.example.msc9999.foo".
469+
/// The scopes for API access and the device ID according to the
470+
/// [specification](https://spec.matrix.org/v1.15/client-server-api/#allocated-scope-tokens)
471+
/// are always requested.
465472
pub async fn url_for_oidc(
466473
&self,
467474
oidc_configuration: &OidcConfiguration,
468475
prompt: Option<OidcPrompt>,
469476
login_hint: Option<String>,
470477
device_id: Option<String>,
478+
additional_scopes: Option<Vec<String>>,
471479
) -> Result<Arc<OAuthAuthorizationData>, OidcError> {
472480
let registration_data = oidc_configuration.registration_data()?;
473481
let redirect_uri = oidc_configuration.redirect_uri()?;
474482

475483
let device_id = device_id.map(OwnedDeviceId::from);
476484

477-
let mut url_builder =
478-
self.inner.oauth().login(redirect_uri, device_id, Some(registration_data));
485+
let additional_scopes =
486+
additional_scopes.map(|scopes| scopes.into_iter().map(Scope::new).collect::<Vec<_>>());
487+
488+
let mut url_builder = self.inner.oauth().login(
489+
redirect_uri,
490+
device_id,
491+
Some(registration_data),
492+
additional_scopes,
493+
);
479494

480495
if let Some(prompt) = prompt {
481496
url_builder = url_builder.prompt(vec![prompt.into()]);

crates/matrix-sdk/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ All notable changes to this project will be documented in this file.
66

77
## [Unreleased] - ReleaseDate
88

9+
### Breaking changes:
10+
11+
- `OAuth::login` now allows requesting additional scopes for the authorization code grant.
12+
([#5395](https://github.com/matrix-org/matrix-rust-sdk/pull/5395))
13+
914
## [0.13.0] - 2025-07-10
1015

1116
### Security Fixes

crates/matrix-sdk/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ matrix-sdk-sqlite = { workspace = true, optional = true }
9494
matrix-sdk-test = { workspace = true, optional = true }
9595
mime.workspace = true
9696
mime2ext = "0.1.53"
97-
oauth2 = { version = "5.0.0", default-features = false, features = ["reqwest", "timing-resistant-secret-traits"] }
97+
oauth2.workspace = true
9898
once_cell.workspace = true
9999
percent-encoding = "2.3.1"
100100
pin-project-lite.workspace = true

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

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -854,7 +854,10 @@ impl OAuth {
854854
}
855855

856856
/// The scopes to request for logging in and the corresponding device ID.
857-
fn login_scopes(device_id: Option<OwnedDeviceId>) -> ([Scope; 2], OwnedDeviceId) {
857+
fn login_scopes(
858+
device_id: Option<OwnedDeviceId>,
859+
additional_scopes: Option<Vec<Scope>>,
860+
) -> (Vec<Scope>, OwnedDeviceId) {
858861
/// Scope to grand full access to the client-server API.
859862
const SCOPE_MATRIX_CLIENT_SERVER_API_FULL_ACCESS: &str =
860863
"urn:matrix:org.matrix.msc2967.client:api:*";
@@ -864,13 +867,16 @@ impl OAuth {
864867
// Generate the device ID if it is not provided.
865868
let device_id = device_id.unwrap_or_else(DeviceId::new);
866869

867-
(
868-
[
869-
Scope::new(SCOPE_MATRIX_CLIENT_SERVER_API_FULL_ACCESS.to_owned()),
870-
Scope::new(format!("{SCOPE_MATRIX_DEVICE_ID_PREFIX}{device_id}")),
871-
],
872-
device_id,
873-
)
870+
let mut scopes = vec![
871+
Scope::new(SCOPE_MATRIX_CLIENT_SERVER_API_FULL_ACCESS.to_owned()),
872+
Scope::new(format!("{SCOPE_MATRIX_DEVICE_ID_PREFIX}{device_id}")),
873+
];
874+
875+
if let Some(extra_scopes) = additional_scopes {
876+
scopes.extend(extra_scopes);
877+
}
878+
879+
(scopes, device_id)
874880
}
875881

876882
/// Log in via OAuth 2.0 with the Authorization Code flow.
@@ -903,6 +909,12 @@ impl OAuth {
903909
/// [`OAuth::register_client()`] or [`OAuth::restore_registered_client()`]
904910
/// was called previously.
905911
///
912+
/// * `additional_scopes` - Additional scopes to request from the
913+
/// authorization server, e.g. "urn:matrix:client:com.example.msc9999.foo".
914+
/// The scopes for API access and the device ID according to the
915+
/// [specification](https://spec.matrix.org/v1.15/client-server-api/#allocated-scope-tokens)
916+
/// are always requested.
917+
///
906918
/// # Example
907919
///
908920
/// ```no_run
@@ -921,7 +933,7 @@ impl OAuth {
921933
/// let client_metadata: Raw<ClientMetadata> = client_metadata();
922934
/// let registration_data = client_metadata.into();
923935
///
924-
/// let auth_data = oauth.login(redirect_uri, None, Some(registration_data))
936+
/// let auth_data = oauth.login(redirect_uri, None, Some(registration_data), None)
925937
/// .build()
926938
/// .await?;
927939
///
@@ -942,8 +954,9 @@ impl OAuth {
942954
redirect_uri: Url,
943955
device_id: Option<OwnedDeviceId>,
944956
registration_data: Option<ClientRegistrationData>,
957+
additional_scopes: Option<Vec<Scope>>,
945958
) -> OAuthAuthCodeUrlBuilder {
946-
let (scopes, device_id) = Self::login_scopes(device_id);
959+
let (scopes, device_id) = Self::login_scopes(device_id, additional_scopes);
947960

948961
OAuthAuthCodeUrlBuilder::new(
949962
self.clone(),
@@ -1125,7 +1138,7 @@ impl OAuth {
11251138
device_id: Option<OwnedDeviceId>,
11261139
) -> Result<oauth2::StandardDeviceAuthorizationResponse, qrcode::DeviceAuthorizationOAuthError>
11271140
{
1128-
let (scopes, _) = Self::login_scopes(device_id);
1141+
let (scopes, _) = Self::login_scopes(device_id, None);
11291142

11301143
let client_id = self.client_id().ok_or(OAuthError::NotRegistered)?.clone();
11311144

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

Lines changed: 87 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use anyhow::Context as _;
22
use assert_matches::assert_matches;
33
use matrix_sdk_base::store::RoomLoadSettings;
44
use matrix_sdk_test::async_test;
5-
use oauth2::{ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl};
5+
use oauth2::{ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope};
66
use ruma::{
77
api::client::discovery::get_authorization_server_metadata::v1::Prompt, device_id,
88
owned_device_id, user_id, DeviceId, ServerName,
@@ -56,6 +56,7 @@ async fn check_authorization_url(
5656
device_id: Option<&DeviceId>,
5757
expected_prompt: Option<&str>,
5858
expected_login_hint: Option<&str>,
59+
additional_scopes: Option<Vec<Scope>>,
5960
) {
6061
tracing::debug!("authorization data URL = {}", authorization_data.url);
6162

@@ -85,15 +86,45 @@ async fn check_authorization_url(
8586
num_expected -= 1;
8687
}
8788
"scope" => {
88-
let expected_start = "urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:";
89-
assert!(val.starts_with(expected_start));
90-
assert!(val.len() > expected_start.len());
89+
let actual_scopes: Vec<String> = val.split(' ').map(String::from).collect();
90+
91+
assert!(actual_scopes.len() >= 2, "Expected at least two scopes");
92+
93+
assert!(
94+
actual_scopes
95+
.contains(&"urn:matrix:org.matrix.msc2967.client:api:*".to_owned()),
96+
"Expected Matrix API scope not found in scopes"
97+
);
9198

9299
// Only check the device ID if we know it. If it's generated randomly we don't
93100
// know it.
94101
if let Some(device_id) = device_id {
95-
assert!(val.ends_with(device_id.as_str()));
96-
assert_eq!(val.len(), expected_start.len() + device_id.as_str().len());
102+
let device_id_scope =
103+
format!("urn:matrix:org.matrix.msc2967.client:device:{device_id}");
104+
assert!(
105+
actual_scopes.contains(&device_id_scope),
106+
"Expected device ID scope not found in scopes"
107+
)
108+
} else {
109+
assert!(
110+
actual_scopes
111+
.iter()
112+
.any(|s| s.starts_with("urn:matrix:org.matrix.msc2967.client:device:")),
113+
"Expected device ID scope not found in scopes"
114+
);
115+
}
116+
117+
if let Some(additional_scopes) = &additional_scopes {
118+
// Check if the additional scopes are present in the actual scopes.
119+
let expected_len = 2 + additional_scopes.len();
120+
assert_eq!(actual_scopes.len(), expected_len, "Expected {expected_len} scopes",);
121+
122+
for scope in additional_scopes {
123+
assert!(
124+
actual_scopes.contains(scope),
125+
"Expected additional scope not found in scopes: {scope:?}",
126+
);
127+
}
97128
}
98129

99130
num_expected -= 1;
@@ -146,7 +177,7 @@ async fn test_high_level_login() -> anyhow::Result<()> {
146177

147178
// When getting the OIDC login URL.
148179
let authorization_data = oauth
149-
.login(redirect_uri.clone(), None, Some(registration_data))
180+
.login(redirect_uri.clone(), None, Some(registration_data), None)
150181
.prompt(vec![Prompt::Create])
151182
.build()
152183
.await
@@ -169,12 +200,16 @@ async fn test_high_level_login_cancellation() -> anyhow::Result<()> {
169200
// Given a client ready to complete login.
170201
let (oauth, server, mut redirect_uri, registration_data) = mock_environment().await.unwrap();
171202

172-
let authorization_data =
173-
oauth.login(redirect_uri.clone(), None, Some(registration_data)).build().await.unwrap();
203+
let authorization_data = oauth
204+
.login(redirect_uri.clone(), None, Some(registration_data), None)
205+
.build()
206+
.await
207+
.unwrap();
174208

175209
assert_eq!(oauth.client_id().map(|id| id.as_str()), Some("test_client_id"));
176210

177-
check_authorization_url(&authorization_data, &oauth, &server.uri(), None, None, None).await;
211+
check_authorization_url(&authorization_data, &oauth, &server.uri(), None, None, None, None)
212+
.await;
178213

179214
// When completing login with a cancellation callback.
180215
redirect_uri.set_query(Some(&format!(
@@ -200,12 +235,16 @@ async fn test_high_level_login_invalid_state() -> anyhow::Result<()> {
200235
// Given a client ready to complete login.
201236
let (oauth, server, mut redirect_uri, registration_data) = mock_environment().await.unwrap();
202237

203-
let authorization_data =
204-
oauth.login(redirect_uri.clone(), None, Some(registration_data)).build().await.unwrap();
238+
let authorization_data = oauth
239+
.login(redirect_uri.clone(), None, Some(registration_data), None)
240+
.build()
241+
.await
242+
.unwrap();
205243

206244
assert_eq!(oauth.client_id().map(|id| id.as_str()), Some("test_client_id"));
207245

208-
check_authorization_url(&authorization_data, &oauth, &server.uri(), None, None, None).await;
246+
check_authorization_url(&authorization_data, &oauth, &server.uri(), None, None, None, None)
247+
.await;
209248

210249
// When completing login with an old/tampered state.
211250
redirect_uri.set_query(Some("code=42&state=imposter_alert"));
@@ -229,7 +268,7 @@ async fn test_login_url() -> anyhow::Result<()> {
229268
let server_uri = server.uri();
230269

231270
let oauth_server = server.oauth();
232-
oauth_server.mock_server_metadata().ok().expect(3).mount().await;
271+
oauth_server.mock_server_metadata().ok().expect(4).mount().await;
233272

234273
let client = server.client_builder().registered_with_oauth().build().await;
235274
let oauth = client.oauth();
@@ -239,15 +278,26 @@ async fn test_login_url() -> anyhow::Result<()> {
239278
let redirect_uri_str = REDIRECT_URI_STRING;
240279
let redirect_uri = Url::parse(redirect_uri_str)?;
241280

281+
let additional_scopes =
282+
vec![Scope::new("urn:test:scope1".to_owned()), Scope::new("urn:test:scope2".to_owned())];
283+
242284
// No extra parameters.
243285
let authorization_data =
244-
oauth.login(redirect_uri.clone(), Some(device_id.clone()), None).build().await?;
245-
check_authorization_url(&authorization_data, &oauth, &server_uri, Some(&device_id), None, None)
246-
.await;
286+
oauth.login(redirect_uri.clone(), Some(device_id.clone()), None, None).build().await?;
287+
check_authorization_url(
288+
&authorization_data,
289+
&oauth,
290+
&server_uri,
291+
Some(&device_id),
292+
None,
293+
None,
294+
None,
295+
)
296+
.await;
247297

248298
// With prompt parameter.
249299
let authorization_data = oauth
250-
.login(redirect_uri.clone(), Some(device_id.clone()), None)
300+
.login(redirect_uri.clone(), Some(device_id.clone()), None, None)
251301
.prompt(vec![Prompt::Create])
252302
.build()
253303
.await?;
@@ -258,12 +308,13 @@ async fn test_login_url() -> anyhow::Result<()> {
258308
Some(&device_id),
259309
Some("create"),
260310
None,
311+
None,
261312
)
262313
.await;
263314

264315
// With user_id_hint parameter.
265316
let authorization_data = oauth
266-
.login(redirect_uri.clone(), Some(device_id.clone()), None)
317+
.login(redirect_uri.clone(), Some(device_id.clone()), None, None)
267318
.user_id_hint(user_id!("@joe:example.org"))
268319
.build()
269320
.await?;
@@ -274,6 +325,23 @@ async fn test_login_url() -> anyhow::Result<()> {
274325
Some(&device_id),
275326
None,
276327
Some("mxid:@joe:example.org"),
328+
None,
329+
)
330+
.await;
331+
332+
// With additional scopes.
333+
let authorization_data = oauth
334+
.login(redirect_uri.clone(), Some(device_id.clone()), None, Some(additional_scopes.clone()))
335+
.build()
336+
.await?;
337+
check_authorization_url(
338+
&authorization_data,
339+
&oauth,
340+
&server_uri,
341+
Some(&device_id),
342+
None,
343+
None,
344+
Some(additional_scopes),
277345
)
278346
.await;
279347

examples/oauth_cli/src/main.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,10 @@ impl OAuthCli {
187187
// the redirect when the custom URI scheme is opened.
188188
let (redirect_uri, server_handle) = LocalServerBuilder::new().spawn().await?;
189189

190-
let OAuthAuthorizationData { url, .. } =
191-
oauth.login(redirect_uri, None, Some(client_metadata().into())).build().await?;
190+
let OAuthAuthorizationData { url, .. } = oauth
191+
.login(redirect_uri, None, Some(client_metadata().into()), None)
192+
.build()
193+
.await?;
192194

193195
let query_string =
194196
use_auth_url(&url, server_handle).await.map(|query| query.0).unwrap_or_default();

0 commit comments

Comments
 (0)