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 } ;
99use 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
157163const 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 )
0 commit comments