Skip to content

Commit 96b4d5f

Browse files
authored
Allow compat session devices to have spaces (#4067)
2 parents 4a1ec0b + 7c9bb73 commit 96b4d5f

File tree

11 files changed

+132
-101
lines changed

11 files changed

+132
-101
lines changed

crates/cli/src/commands/manage.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ impl Options {
292292
.context("User not found")?;
293293

294294
let device = if let Some(device_id) = device_id {
295-
device_id.try_into()?
295+
device_id.into()
296296
} else {
297297
Device::generate(&mut rng)
298298
};

crates/data-model/src/compat/device.rs

Lines changed: 21 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,22 @@ pub struct Device {
2222
}
2323

2424
#[derive(Debug, Error)]
25-
pub enum InvalidDeviceID {
26-
#[error("Device ID contains invalid characters")]
25+
pub enum ToScopeTokenError {
26+
#[error("Device ID contains characters that can't be encoded in a scope")]
2727
InvalidCharacters,
2828
}
2929

3030
impl Device {
3131
/// Get the corresponding [`ScopeToken`] for that device
32-
#[must_use]
33-
pub fn to_scope_token(&self) -> ScopeToken {
34-
// SAFETY: the inner id should only have valid scope characters
35-
let Ok(scope_token) = format!("{DEVICE_SCOPE_PREFIX}{}", self.id).parse() else {
36-
unreachable!()
37-
};
38-
39-
scope_token
32+
///
33+
/// # Errors
34+
///
35+
/// Returns an error if the device ID contains characters that can't be
36+
/// encoded in a scope
37+
pub fn to_scope_token(&self) -> Result<ScopeToken, ToScopeTokenError> {
38+
format!("{DEVICE_SCOPE_PREFIX}{}", self.id)
39+
.parse()
40+
.map_err(|_| ToScopeTokenError::InvalidCharacters)
4041
}
4142

4243
/// Get the corresponding [`Device`] from a [`ScopeToken`]
@@ -45,8 +46,7 @@ impl Device {
4546
#[must_use]
4647
pub fn from_scope_token(token: &ScopeToken) -> Option<Self> {
4748
let id = token.as_str().strip_prefix(DEVICE_SCOPE_PREFIX)?;
48-
// XXX: we might be silently ignoring errors here, but it's probably fine?
49-
Device::try_from(id.to_owned()).ok()
49+
Some(Device::from(id.to_owned()))
5050
}
5151

5252
/// Generate a random device ID
@@ -62,39 +62,15 @@ impl Device {
6262
}
6363
}
6464

65-
const fn valid_device_chars(c: char) -> bool {
66-
// This matches the regex in the policy
67-
c.is_ascii_alphanumeric()
68-
|| c == '.'
69-
|| c == '_'
70-
|| c == '~'
71-
|| c == '!'
72-
|| c == '$'
73-
|| c == '&'
74-
|| c == '\''
75-
|| c == '('
76-
|| c == ')'
77-
|| c == '*'
78-
|| c == '+'
79-
|| c == ','
80-
|| c == ';'
81-
|| c == '='
82-
|| c == ':'
83-
|| c == '@'
84-
|| c == '/'
85-
|| c == '-'
65+
impl From<String> for Device {
66+
fn from(id: String) -> Self {
67+
Self { id }
68+
}
8669
}
8770

88-
impl TryFrom<String> for Device {
89-
type Error = InvalidDeviceID;
90-
91-
/// Create a [`Device`] out of an ID, validating the ID has the right shape
92-
fn try_from(id: String) -> Result<Self, Self::Error> {
93-
if !id.chars().all(valid_device_chars) {
94-
return Err(InvalidDeviceID::InvalidCharacters);
95-
}
96-
97-
Ok(Self { id })
71+
impl From<Device> for String {
72+
fn from(device: Device) -> Self {
73+
device.id
9874
}
9975
}
10076

@@ -112,8 +88,8 @@ mod test {
11288

11389
#[test]
11490
fn test_device_id_to_from_scope_token() {
115-
let device = Device::try_from("AABBCCDDEE".to_owned()).unwrap();
116-
let scope_token = device.to_scope_token();
91+
let device = Device::from("AABBCCDDEE".to_owned());
92+
let scope_token = device.to_scope_token().unwrap();
11793
assert_eq!(
11894
scope_token.as_str(),
11995
"urn:matrix:org.matrix.msc2967.client:device:AABBCCDDEE"

crates/data-model/src/compat/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ mod session;
1212
mod sso_login;
1313

1414
pub use self::{
15-
device::Device,
15+
device::{Device, ToScopeTokenError},
1616
session::{CompatSession, CompatSessionState},
1717
sso_login::{CompatSsoLogin, CompatSsoLoginState},
1818
};

crates/data-model/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub use ulid::Ulid;
2626
pub use self::{
2727
compat::{
2828
CompatAccessToken, CompatRefreshToken, CompatRefreshTokenState, CompatSession,
29-
CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device,
29+
CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, ToScopeTokenError,
3030
},
3131
oauth2::{
3232
AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant,

crates/handlers/src/admin/model.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ impl CompatSession {
229229
Self {
230230
id: Ulid::from_bytes([0x01; 16]),
231231
user_id: Ulid::from_bytes([0x01; 16]),
232-
device_id: Some("AABBCCDDEE".to_owned().try_into().unwrap()),
232+
device_id: Some("AABBCCDDEE".to_owned().into()),
233233
user_session_id: Some(Ulid::from_bytes([0x11; 16])),
234234
redirect_uri: Some("https://example.com/redirect".parse().unwrap()),
235235
created_at: DateTime::default(),
@@ -241,7 +241,7 @@ impl CompatSession {
241241
Self {
242242
id: Ulid::from_bytes([0x02; 16]),
243243
user_id: Ulid::from_bytes([0x01; 16]),
244-
device_id: Some("FFGGHHIIJJ".to_owned().try_into().unwrap()),
244+
device_id: Some("FFGGHHIIJJ".to_owned().into()),
245245
user_session_id: Some(Ulid::from_bytes([0x12; 16])),
246246
redirect_uri: None,
247247
created_at: DateTime::default(),

crates/handlers/src/graphql/query/session.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,7 @@ impl SessionQuery {
4444
return Ok(None);
4545
}
4646

47-
let Ok(device) = Device::try_from(device_id) else {
48-
return Ok(None);
49-
};
47+
let device = Device::from(device_id);
5048
let state = ctx.state();
5149
let mut repo = state.repository().await?;
5250

@@ -81,7 +79,14 @@ impl SessionQuery {
8179

8280
// Then, try to find an OAuth 2.0 session. Because we don't have any dedicated
8381
// device column, we're looking up using the device scope.
84-
let scope = Scope::from_iter([device.to_scope_token()]);
82+
// All device IDs can't necessarily be encoded as a scope. If it's not the case,
83+
// we'll skip looking for OAuth 2.0 sessions.
84+
let Ok(scope_token) = device.to_scope_token() else {
85+
repo.cancel().await?;
86+
87+
return Ok(None);
88+
};
89+
let scope = Scope::from_iter([scope_token]);
8590
let filter = OAuth2SessionFilter::new()
8691
.for_user(&user)
8792
.active_only()

crates/handlers/src/oauth2/introspection.rs

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
// SPDX-License-Identifier: AGPL-3.0-only
55
// Please see LICENSE in the repository root for full details.
66

7-
use axum::{Json, extract::State, response::IntoResponse};
8-
use hyper::StatusCode;
7+
use axum::{Json, extract::State, http::HeaderValue, response::IntoResponse};
8+
use hyper::{HeaderMap, StatusCode};
99
use mas_axum_utils::{
1010
client_authorization::{ClientAuthorization, CredentialsVerificationError},
1111
sentry::SentryEventID,
@@ -74,6 +74,10 @@ pub enum RouteError {
7474
#[error("unknown compat session")]
7575
CantLoadCompatSession,
7676

77+
/// The Device ID in the compat session can't be encoded as a scope
78+
#[error("device ID contains characters that are not allowed in a scope")]
79+
CantEncodeDeviceID(#[from] mas_data_model::ToScopeTokenError),
80+
7781
#[error("invalid user")]
7882
InvalidUser,
7983

@@ -120,7 +124,8 @@ impl IntoResponse for RouteError {
120124
| Self::InvalidUser
121125
| Self::InvalidCompatSession
122126
| Self::InvalidOAuthSession
123-
| Self::InvalidTokenFormat(_) => Json(INACTIVE).into_response(),
127+
| Self::InvalidTokenFormat(_)
128+
| Self::CantEncodeDeviceID(_) => Json(INACTIVE).into_response(),
124129
Self::NotAllowed => (
125130
StatusCode::UNAUTHORIZED,
126131
Json(ClientError::from(ClientErrorCode::AccessDenied)),
@@ -152,6 +157,7 @@ const INACTIVE: IntrospectionResponse = IntrospectionResponse {
152157
aud: None,
153158
iss: None,
154159
jti: None,
160+
device_id: None,
155161
};
156162

157163
const API_SCOPE: ScopeToken = ScopeToken::from_static("urn:matrix:org.matrix.msc2967.client:api:*");
@@ -170,6 +176,7 @@ pub(crate) async fn post(
170176
mut repo: BoxRepository,
171177
activity_tracker: ActivityTracker,
172178
State(encrypter): State<Encrypter>,
179+
headers: HeaderMap,
173180
client_authorization: ClientAuthorization<IntrospectionRequest>,
174181
) -> Result<impl IntoResponse, RouteError> {
175182
let client = client_authorization
@@ -202,6 +209,16 @@ pub(crate) async fn post(
202209
}
203210
}
204211

212+
// Not all device IDs can be encoded as scope. On OAuth 2.0 sessions, we
213+
// don't have this problem, as the device ID *is* already encoded as a scope.
214+
// But on compatibility sessions, it's possible to have device IDs with
215+
// spaces in them, or other weird characters.
216+
// In those cases, we prefer explicitly giving out the device ID as a separate
217+
// field. The client introspecting tells us whether it supports having the
218+
// device ID as a separate field through this header.
219+
let supports_explicit_device_id =
220+
headers.get("X-MAS-Supports-Device-Id") == Some(&HeaderValue::from_static("1"));
221+
205222
// XXX: we should get the IP from the client introspecting the token
206223
let ip = None;
207224

@@ -270,6 +287,7 @@ pub(crate) async fn post(
270287
aud: None,
271288
iss: None,
272289
jti: Some(access_token.jti()),
290+
device_id: None,
273291
}
274292
}
275293

@@ -329,6 +347,7 @@ pub(crate) async fn post(
329347
aud: None,
330348
iss: None,
331349
jti: Some(refresh_token.jti()),
350+
device_id: None,
332351
}
333352
}
334353

@@ -365,7 +384,19 @@ pub(crate) async fn post(
365384

366385
// Grant the synapse admin scope if the session has the admin flag set.
367386
let synapse_admin_scope_opt = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE);
368-
let device_scope_opt = session.device.as_ref().map(Device::to_scope_token);
387+
388+
// If the client supports explicitly giving the device ID in the response, skip
389+
// encoding it in the scope
390+
let device_scope_opt = if supports_explicit_device_id {
391+
None
392+
} else {
393+
session
394+
.device
395+
.as_ref()
396+
.map(Device::to_scope_token)
397+
.transpose()?
398+
};
399+
369400
let scope = [API_SCOPE]
370401
.into_iter()
371402
.chain(device_scope_opt)
@@ -389,6 +420,7 @@ pub(crate) async fn post(
389420
aud: None,
390421
iss: None,
391422
jti: None,
423+
device_id: session.device.map(Device::into),
392424
}
393425
}
394426

@@ -425,7 +457,19 @@ pub(crate) async fn post(
425457

426458
// Grant the synapse admin scope if the session has the admin flag set.
427459
let synapse_admin_scope_opt = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE);
428-
let device_scope_opt = session.device.as_ref().map(Device::to_scope_token);
460+
461+
// If the client supports explicitly giving the device ID in the response, skip
462+
// encoding it in the scope
463+
let device_scope_opt = if supports_explicit_device_id {
464+
None
465+
} else {
466+
session
467+
.device
468+
.as_ref()
469+
.map(Device::to_scope_token)
470+
.transpose()?
471+
};
472+
429473
let scope = [API_SCOPE]
430474
.into_iter()
431475
.chain(device_scope_opt)
@@ -449,6 +493,7 @@ pub(crate) async fn post(
449493
aud: None,
450494
iss: None,
451495
jti: None,
496+
device_id: session.device.map(Device::into),
452497
}
453498
}
454499
};
@@ -777,10 +822,30 @@ mod tests {
777822
response.assert_status(StatusCode::OK);
778823
let response: IntrospectionResponse = response.json();
779824
assert!(response.active);
780-
assert_eq!(response.username, Some("alice".to_owned()));
781-
assert_eq!(response.client_id, Some("legacy".to_owned()));
825+
assert_eq!(response.username.as_deref(), Some("alice"));
826+
assert_eq!(response.client_id.as_deref(), Some("legacy"));
782827
assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken));
783-
assert_eq!(response.scope, Some(expected_scope.clone()));
828+
assert_eq!(response.scope.as_ref(), Some(&expected_scope));
829+
assert_eq!(response.device_id.as_deref(), Some(device_id));
830+
831+
// Check that requesting with X-MAS-Supports-Device-Id removes the device ID
832+
// from the scope but not from the explicit device_id field
833+
let request = Request::post(OAuth2Introspection::PATH)
834+
.basic_auth(&introspecting_client_id, &introspecting_client_secret)
835+
.header("X-MAS-Supports-Device-Id", "1")
836+
.form(json!({ "token": access_token }));
837+
let response = state.request(request).await;
838+
response.assert_status(StatusCode::OK);
839+
let response: IntrospectionResponse = response.json();
840+
assert!(response.active);
841+
assert_eq!(response.username.as_deref(), Some("alice"));
842+
assert_eq!(response.client_id.as_deref(), Some("legacy"));
843+
assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken));
844+
assert_eq!(
845+
response.scope.map(|s| s.to_string()),
846+
Some("urn:matrix:org.matrix.msc2967.client:api:*".to_owned())
847+
);
848+
assert_eq!(response.device_id.as_deref(), Some(device_id));
784849

785850
// Do the same request, but with a token_type_hint
786851
let request = Request::post(OAuth2Introspection::PATH)
@@ -808,10 +873,11 @@ mod tests {
808873
response.assert_status(StatusCode::OK);
809874
let response: IntrospectionResponse = response.json();
810875
assert!(response.active);
811-
assert_eq!(response.username, Some("alice".to_owned()));
812-
assert_eq!(response.client_id, Some("legacy".to_owned()));
876+
assert_eq!(response.username.as_deref(), Some("alice"));
877+
assert_eq!(response.client_id.as_deref(), Some("legacy"));
813878
assert_eq!(response.token_type, Some(OAuthTokenTypeHint::RefreshToken));
814-
assert_eq!(response.scope, Some(expected_scope.clone()));
879+
assert_eq!(response.scope.as_ref(), Some(&expected_scope));
880+
assert_eq!(response.device_id.as_deref(), Some(device_id));
815881

816882
// Do the same request, but with a token_type_hint
817883
let request = Request::post(OAuth2Introspection::PATH)

crates/oauth2-types/src/requests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,9 @@ pub struct IntrospectionResponse {
786786

787787
/// String identifier for the token.
788788
pub jti: Option<String>,
789+
790+
/// MAS extension: explicit device ID
791+
pub device_id: Option<String>,
789792
}
790793

791794
/// A request to the [Revocation Endpoint].

crates/storage-pg/src/app_session.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,7 @@ mod tests {
588588
.unwrap();
589589

590590
let device2 = Device::generate(&mut rng);
591-
let scope = Scope::from_iter([OPENID, device2.to_scope_token()]);
591+
let scope = Scope::from_iter([OPENID, device2.to_scope_token().unwrap()]);
592592

593593
// We're moving the clock forward by 1 minute between each session to ensure
594594
// we're getting consistent ordering in lists.

0 commit comments

Comments
 (0)