mas_handlers/oauth2/
introspection.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use axum::{Json, extract::State, http::HeaderValue, response::IntoResponse};
8use hyper::{HeaderMap, StatusCode};
9use mas_axum_utils::{
10    client_authorization::{ClientAuthorization, CredentialsVerificationError},
11    sentry::SentryEventID,
12};
13use mas_data_model::{Device, TokenFormatError, TokenType};
14use mas_iana::oauth::{OAuthClientAuthenticationMethod, OAuthTokenTypeHint};
15use mas_keystore::Encrypter;
16use mas_storage::{
17    BoxClock, BoxRepository, Clock,
18    compat::{CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository},
19    oauth2::{OAuth2AccessTokenRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository},
20    user::UserRepository,
21};
22use oauth2_types::{
23    errors::{ClientError, ClientErrorCode},
24    requests::{IntrospectionRequest, IntrospectionResponse},
25    scope::ScopeToken,
26};
27use thiserror::Error;
28
29use crate::{ActivityTracker, impl_from_error_for_route};
30
31#[derive(Debug, Error)]
32pub enum RouteError {
33    /// An internal error occurred.
34    #[error(transparent)]
35    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
36
37    /// The client could not be found.
38    #[error("could not find client")]
39    ClientNotFound,
40
41    /// The client is not allowed to introspect.
42    #[error("client is not allowed to introspect")]
43    NotAllowed,
44
45    /// The token type is not the one expected.
46    #[error("unexpected token type")]
47    UnexpectedTokenType,
48
49    /// The overall token format is invalid.
50    #[error("invalid token format")]
51    InvalidTokenFormat(#[from] TokenFormatError),
52
53    /// The token could not be found in the database.
54    #[error("unknown {0}")]
55    UnknownToken(TokenType),
56
57    /// The token is not valid.
58    #[error("{0} is not valid")]
59    InvalidToken(TokenType),
60
61    /// The OAuth session is not valid.
62    #[error("invalid oauth session")]
63    InvalidOAuthSession,
64
65    /// The OAuth session could not be found in the database.
66    #[error("unknown oauth session")]
67    CantLoadOAuthSession,
68
69    /// The compat session is not valid.
70    #[error("invalid compat session")]
71    InvalidCompatSession,
72
73    /// The compat session could not be found in the database.
74    #[error("unknown compat session")]
75    CantLoadCompatSession,
76
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
81    #[error("invalid user")]
82    InvalidUser,
83
84    #[error("unknown user")]
85    CantLoadUser,
86
87    #[error("bad request")]
88    BadRequest,
89
90    #[error(transparent)]
91    ClientCredentialsVerification(#[from] CredentialsVerificationError),
92}
93
94impl IntoResponse for RouteError {
95    fn into_response(self) -> axum::response::Response {
96        let event_id = sentry::capture_error(&self);
97        let response = match self {
98            e @ (Self::Internal(_)
99            | Self::CantLoadCompatSession
100            | Self::CantLoadOAuthSession
101            | Self::CantLoadUser) => (
102                StatusCode::INTERNAL_SERVER_ERROR,
103                Json(
104                    ClientError::from(ClientErrorCode::ServerError).with_description(e.to_string()),
105                ),
106            )
107                .into_response(),
108            Self::ClientNotFound => (
109                StatusCode::UNAUTHORIZED,
110                Json(ClientError::from(ClientErrorCode::InvalidClient)),
111            )
112                .into_response(),
113            Self::ClientCredentialsVerification(e) => (
114                StatusCode::UNAUTHORIZED,
115                Json(
116                    ClientError::from(ClientErrorCode::InvalidClient)
117                        .with_description(e.to_string()),
118                ),
119            )
120                .into_response(),
121            Self::UnknownToken(_)
122            | Self::UnexpectedTokenType
123            | Self::InvalidToken(_)
124            | Self::InvalidUser
125            | Self::InvalidCompatSession
126            | Self::InvalidOAuthSession
127            | Self::InvalidTokenFormat(_)
128            | Self::CantEncodeDeviceID(_) => Json(INACTIVE).into_response(),
129            Self::NotAllowed => (
130                StatusCode::UNAUTHORIZED,
131                Json(ClientError::from(ClientErrorCode::AccessDenied)),
132            )
133                .into_response(),
134            Self::BadRequest => (
135                StatusCode::BAD_REQUEST,
136                Json(ClientError::from(ClientErrorCode::InvalidRequest)),
137            )
138                .into_response(),
139        };
140
141        (SentryEventID::from(event_id), response).into_response()
142    }
143}
144
145impl_from_error_for_route!(mas_storage::RepositoryError);
146
147const INACTIVE: IntrospectionResponse = IntrospectionResponse {
148    active: false,
149    scope: None,
150    client_id: None,
151    username: None,
152    token_type: None,
153    exp: None,
154    expires_in: None,
155    iat: None,
156    nbf: None,
157    sub: None,
158    aud: None,
159    iss: None,
160    jti: None,
161    device_id: None,
162};
163
164const API_SCOPE: ScopeToken = ScopeToken::from_static("urn:matrix:org.matrix.msc2967.client:api:*");
165const SYNAPSE_ADMIN_SCOPE: ScopeToken = ScopeToken::from_static("urn:synapse:admin:*");
166
167#[tracing::instrument(
168    name = "handlers.oauth2.introspection.post",
169    fields(client.id = client_authorization.client_id()),
170    skip_all,
171    err,
172)]
173#[allow(clippy::too_many_lines)]
174pub(crate) async fn post(
175    clock: BoxClock,
176    State(http_client): State<reqwest::Client>,
177    mut repo: BoxRepository,
178    activity_tracker: ActivityTracker,
179    State(encrypter): State<Encrypter>,
180    headers: HeaderMap,
181    client_authorization: ClientAuthorization<IntrospectionRequest>,
182) -> Result<impl IntoResponse, RouteError> {
183    let client = client_authorization
184        .credentials
185        .fetch(&mut repo)
186        .await?
187        .ok_or(RouteError::ClientNotFound)?;
188
189    let method = match &client.token_endpoint_auth_method {
190        None | Some(OAuthClientAuthenticationMethod::None) => {
191            return Err(RouteError::NotAllowed);
192        }
193        Some(c) => c,
194    };
195
196    client_authorization
197        .credentials
198        .verify(&http_client, &encrypter, method, &client)
199        .await?;
200
201    let Some(form) = client_authorization.form else {
202        return Err(RouteError::BadRequest);
203    };
204
205    let token = &form.token;
206    let token_type = TokenType::check(token)?;
207    if let Some(hint) = form.token_type_hint {
208        if token_type != hint {
209            return Err(RouteError::UnexpectedTokenType);
210        }
211    }
212
213    // Not all device IDs can be encoded as scope. On OAuth 2.0 sessions, we
214    // don't have this problem, as the device ID *is* already encoded as a scope.
215    // But on compatibility sessions, it's possible to have device IDs with
216    // spaces in them, or other weird characters.
217    // In those cases, we prefer explicitly giving out the device ID as a separate
218    // field. The client introspecting tells us whether it supports having the
219    // device ID as a separate field through this header.
220    let supports_explicit_device_id =
221        headers.get("X-MAS-Supports-Device-Id") == Some(&HeaderValue::from_static("1"));
222
223    // XXX: we should get the IP from the client introspecting the token
224    let ip = None;
225
226    let reply = match token_type {
227        TokenType::AccessToken => {
228            let mut access_token = repo
229                .oauth2_access_token()
230                .find_by_token(token)
231                .await?
232                .ok_or(RouteError::UnknownToken(TokenType::AccessToken))?;
233
234            if !access_token.is_valid(clock.now()) {
235                return Err(RouteError::InvalidToken(TokenType::AccessToken));
236            }
237
238            let session = repo
239                .oauth2_session()
240                .lookup(access_token.session_id)
241                .await?
242                .ok_or(RouteError::InvalidOAuthSession)?;
243
244            if !session.is_valid() {
245                return Err(RouteError::InvalidOAuthSession);
246            }
247
248            // If this is the first time we're using this token, mark it as used
249            if !access_token.is_used() {
250                access_token = repo
251                    .oauth2_access_token()
252                    .mark_used(&clock, access_token)
253                    .await?;
254            }
255
256            // The session might not have a user on it (for Client Credentials grants for
257            // example), so we're optionally fetching the user
258            let (sub, username) = if let Some(user_id) = session.user_id {
259                let user = repo
260                    .user()
261                    .lookup(user_id)
262                    .await?
263                    .ok_or(RouteError::CantLoadUser)?;
264
265                if !user.is_valid() {
266                    return Err(RouteError::InvalidUser);
267                }
268
269                (Some(user.sub), Some(user.username))
270            } else {
271                (None, None)
272            };
273
274            activity_tracker
275                .record_oauth2_session(&clock, &session, ip)
276                .await;
277
278            IntrospectionResponse {
279                active: true,
280                scope: Some(session.scope),
281                client_id: Some(session.client_id.to_string()),
282                username,
283                token_type: Some(OAuthTokenTypeHint::AccessToken),
284                exp: access_token.expires_at,
285                expires_in: access_token
286                    .expires_at
287                    .map(|expires_at| expires_at.signed_duration_since(clock.now())),
288                iat: Some(access_token.created_at),
289                nbf: Some(access_token.created_at),
290                sub,
291                aud: None,
292                iss: None,
293                jti: Some(access_token.jti()),
294                device_id: None,
295            }
296        }
297
298        TokenType::RefreshToken => {
299            let refresh_token = repo
300                .oauth2_refresh_token()
301                .find_by_token(token)
302                .await?
303                .ok_or(RouteError::UnknownToken(TokenType::RefreshToken))?;
304
305            if !refresh_token.is_valid() {
306                return Err(RouteError::InvalidToken(TokenType::RefreshToken));
307            }
308
309            let session = repo
310                .oauth2_session()
311                .lookup(refresh_token.session_id)
312                .await?
313                .ok_or(RouteError::CantLoadOAuthSession)?;
314
315            if !session.is_valid() {
316                return Err(RouteError::InvalidOAuthSession);
317            }
318
319            // The session might not have a user on it (for Client Credentials grants for
320            // example), so we're optionally fetching the user
321            let (sub, username) = if let Some(user_id) = session.user_id {
322                let user = repo
323                    .user()
324                    .lookup(user_id)
325                    .await?
326                    .ok_or(RouteError::CantLoadUser)?;
327
328                if !user.is_valid() {
329                    return Err(RouteError::InvalidUser);
330                }
331
332                (Some(user.sub), Some(user.username))
333            } else {
334                (None, None)
335            };
336
337            activity_tracker
338                .record_oauth2_session(&clock, &session, ip)
339                .await;
340
341            IntrospectionResponse {
342                active: true,
343                scope: Some(session.scope),
344                client_id: Some(session.client_id.to_string()),
345                username,
346                token_type: Some(OAuthTokenTypeHint::RefreshToken),
347                exp: None,
348                expires_in: None,
349                iat: Some(refresh_token.created_at),
350                nbf: Some(refresh_token.created_at),
351                sub,
352                aud: None,
353                iss: None,
354                jti: Some(refresh_token.jti()),
355                device_id: None,
356            }
357        }
358
359        TokenType::CompatAccessToken => {
360            let access_token = repo
361                .compat_access_token()
362                .find_by_token(token)
363                .await?
364                .ok_or(RouteError::UnknownToken(TokenType::CompatAccessToken))?;
365
366            if !access_token.is_valid(clock.now()) {
367                return Err(RouteError::InvalidToken(TokenType::CompatAccessToken));
368            }
369
370            let session = repo
371                .compat_session()
372                .lookup(access_token.session_id)
373                .await?
374                .ok_or(RouteError::CantLoadCompatSession)?;
375
376            if !session.is_valid() {
377                return Err(RouteError::InvalidCompatSession);
378            }
379
380            let user = repo
381                .user()
382                .lookup(session.user_id)
383                .await?
384                .ok_or(RouteError::CantLoadUser)?;
385
386            if !user.is_valid() {
387                return Err(RouteError::InvalidUser)?;
388            }
389
390            // Grant the synapse admin scope if the session has the admin flag set.
391            let synapse_admin_scope_opt = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE);
392
393            // If the client supports explicitly giving the device ID in the response, skip
394            // encoding it in the scope
395            let device_scope_opt = if supports_explicit_device_id {
396                None
397            } else {
398                session
399                    .device
400                    .as_ref()
401                    .map(Device::to_scope_token)
402                    .transpose()?
403            };
404
405            let scope = [API_SCOPE]
406                .into_iter()
407                .chain(device_scope_opt)
408                .chain(synapse_admin_scope_opt)
409                .collect();
410
411            activity_tracker
412                .record_compat_session(&clock, &session, ip)
413                .await;
414
415            IntrospectionResponse {
416                active: true,
417                scope: Some(scope),
418                client_id: Some("legacy".into()),
419                username: Some(user.username),
420                token_type: Some(OAuthTokenTypeHint::AccessToken),
421                exp: access_token.expires_at,
422                expires_in: access_token
423                    .expires_at
424                    .map(|expires_at| expires_at.signed_duration_since(clock.now())),
425                iat: Some(access_token.created_at),
426                nbf: Some(access_token.created_at),
427                sub: Some(user.sub),
428                aud: None,
429                iss: None,
430                jti: None,
431                device_id: session.device.map(Device::into),
432            }
433        }
434
435        TokenType::CompatRefreshToken => {
436            let refresh_token = repo
437                .compat_refresh_token()
438                .find_by_token(token)
439                .await?
440                .ok_or(RouteError::UnknownToken(TokenType::CompatRefreshToken))?;
441
442            if !refresh_token.is_valid() {
443                return Err(RouteError::InvalidToken(TokenType::CompatRefreshToken));
444            }
445
446            let session = repo
447                .compat_session()
448                .lookup(refresh_token.session_id)
449                .await?
450                .ok_or(RouteError::CantLoadCompatSession)?;
451
452            if !session.is_valid() {
453                return Err(RouteError::InvalidCompatSession);
454            }
455
456            let user = repo
457                .user()
458                .lookup(session.user_id)
459                .await?
460                .ok_or(RouteError::CantLoadUser)?;
461
462            if !user.is_valid() {
463                return Err(RouteError::InvalidUser)?;
464            }
465
466            // Grant the synapse admin scope if the session has the admin flag set.
467            let synapse_admin_scope_opt = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE);
468
469            // If the client supports explicitly giving the device ID in the response, skip
470            // encoding it in the scope
471            let device_scope_opt = if supports_explicit_device_id {
472                None
473            } else {
474                session
475                    .device
476                    .as_ref()
477                    .map(Device::to_scope_token)
478                    .transpose()?
479            };
480
481            let scope = [API_SCOPE]
482                .into_iter()
483                .chain(device_scope_opt)
484                .chain(synapse_admin_scope_opt)
485                .collect();
486
487            activity_tracker
488                .record_compat_session(&clock, &session, ip)
489                .await;
490
491            IntrospectionResponse {
492                active: true,
493                scope: Some(scope),
494                client_id: Some("legacy".into()),
495                username: Some(user.username),
496                token_type: Some(OAuthTokenTypeHint::RefreshToken),
497                exp: None,
498                expires_in: None,
499                iat: Some(refresh_token.created_at),
500                nbf: Some(refresh_token.created_at),
501                sub: Some(user.sub),
502                aud: None,
503                iss: None,
504                jti: None,
505                device_id: session.device.map(Device::into),
506            }
507        }
508    };
509
510    repo.save().await?;
511
512    Ok(Json(reply))
513}
514
515#[cfg(test)]
516mod tests {
517    use chrono::Duration;
518    use hyper::{Request, StatusCode};
519    use mas_data_model::{AccessToken, RefreshToken};
520    use mas_iana::oauth::OAuthTokenTypeHint;
521    use mas_matrix::{HomeserverConnection, ProvisionRequest};
522    use mas_router::{OAuth2Introspection, OAuth2RegistrationEndpoint, SimpleRoute};
523    use mas_storage::Clock;
524    use oauth2_types::{
525        registration::ClientRegistrationResponse,
526        requests::IntrospectionResponse,
527        scope::{OPENID, Scope},
528    };
529    use serde_json::json;
530    use sqlx::PgPool;
531    use zeroize::Zeroizing;
532
533    use crate::{
534        oauth2::generate_token_pair,
535        test_utils::{RequestBuilderExt, ResponseExt, TestState, setup},
536    };
537
538    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
539    async fn test_introspect_oauth_tokens(pool: PgPool) {
540        setup();
541        let state = TestState::from_pool(pool).await.unwrap();
542
543        // Provision a client which will be used to do introspection requests
544        let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({
545            "client_uri": "https://introspecting.com/",
546            "grant_types": [],
547            "token_endpoint_auth_method": "client_secret_basic",
548        }));
549
550        let response = state.request(request).await;
551        response.assert_status(StatusCode::CREATED);
552        let client: ClientRegistrationResponse = response.json();
553        let introspecting_client_id = client.client_id;
554        let introspecting_client_secret = client.client_secret.unwrap();
555
556        // Provision a client which will be used to generate tokens
557        let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({
558            "client_uri": "https://client.com/",
559            "redirect_uris": ["https://client.com/"],
560            "response_types": ["code"],
561            "grant_types": ["authorization_code", "refresh_token"],
562            "token_endpoint_auth_method": "none",
563        }));
564
565        let response = state.request(request).await;
566        response.assert_status(StatusCode::CREATED);
567        let ClientRegistrationResponse { client_id, .. } = response.json();
568
569        let mut repo = state.repository().await.unwrap();
570        // Provision a user and an oauth session
571        let user = repo
572            .user()
573            .add(&mut state.rng(), &state.clock, "alice".to_owned())
574            .await
575            .unwrap();
576
577        let mxid = state.homeserver_connection.mxid(&user.username);
578        state
579            .homeserver_connection
580            .provision_user(&ProvisionRequest::new(mxid, &user.sub))
581            .await
582            .unwrap();
583
584        let client = repo
585            .oauth2_client()
586            .find_by_client_id(&client_id)
587            .await
588            .unwrap()
589            .unwrap();
590
591        let browser_session = repo
592            .browser_session()
593            .add(&mut state.rng(), &state.clock, &user, None)
594            .await
595            .unwrap();
596
597        let session = repo
598            .oauth2_session()
599            .add_from_browser_session(
600                &mut state.rng(),
601                &state.clock,
602                &client,
603                &browser_session,
604                Scope::from_iter([OPENID]),
605            )
606            .await
607            .unwrap();
608
609        let (AccessToken { access_token, .. }, RefreshToken { refresh_token, .. }) =
610            generate_token_pair(
611                &mut state.rng(),
612                &state.clock,
613                &mut repo,
614                &session,
615                Duration::microseconds(5 * 60 * 1000 * 1000),
616            )
617            .await
618            .unwrap();
619
620        repo.save().await.unwrap();
621
622        // Now that we have a token, we can introspect it
623        let request = Request::post(OAuth2Introspection::PATH)
624            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
625            .form(json!({ "token": access_token }));
626        let response = state.request(request).await;
627        response.assert_status(StatusCode::OK);
628        let response: IntrospectionResponse = response.json();
629        assert!(response.active);
630        assert_eq!(response.username, Some("alice".to_owned()));
631        assert_eq!(response.client_id, Some(client_id.clone()));
632        assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken));
633        assert_eq!(response.scope, Some(Scope::from_iter([OPENID])));
634
635        // Do the same request, but with a token_type_hint
636        let request = Request::post(OAuth2Introspection::PATH)
637            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
638            .form(json!({"token": access_token, "token_type_hint": "access_token"}));
639        let response = state.request(request).await;
640        response.assert_status(StatusCode::OK);
641        let response: IntrospectionResponse = response.json();
642        assert!(response.active);
643
644        // Do the same request, but with the wrong token_type_hint
645        let request = Request::post(OAuth2Introspection::PATH)
646            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
647            .form(json!({"token": access_token, "token_type_hint": "refresh_token"}));
648        let response = state.request(request).await;
649        response.assert_status(StatusCode::OK);
650        let response: IntrospectionResponse = response.json();
651        assert!(!response.active); // It shouldn't be active
652
653        // Do the same, but with a refresh token
654        let request = Request::post(OAuth2Introspection::PATH)
655            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
656            .form(json!({ "token": refresh_token }));
657        let response = state.request(request).await;
658        response.assert_status(StatusCode::OK);
659        let response: IntrospectionResponse = response.json();
660        assert!(response.active);
661        assert_eq!(response.username, Some("alice".to_owned()));
662        assert_eq!(response.client_id, Some(client_id.clone()));
663        assert_eq!(response.token_type, Some(OAuthTokenTypeHint::RefreshToken));
664        assert_eq!(response.scope, Some(Scope::from_iter([OPENID])));
665
666        // Do the same request, but with a token_type_hint
667        let request = Request::post(OAuth2Introspection::PATH)
668            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
669            .form(json!({"token": refresh_token, "token_type_hint": "refresh_token"}));
670        let response = state.request(request).await;
671        response.assert_status(StatusCode::OK);
672        let response: IntrospectionResponse = response.json();
673        assert!(response.active);
674
675        // Do the same request, but with the wrong token_type_hint
676        let request = Request::post(OAuth2Introspection::PATH)
677            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
678            .form(json!({"token": refresh_token, "token_type_hint": "access_token"}));
679        let response = state.request(request).await;
680        response.assert_status(StatusCode::OK);
681        let response: IntrospectionResponse = response.json();
682        assert!(!response.active); // It shouldn't be active
683
684        // We should have recorded the session last activity
685        state.activity_tracker.flush().await;
686        let mut repo = state.repository().await.unwrap();
687        let session = repo
688            .oauth2_session()
689            .lookup(session.id)
690            .await
691            .unwrap()
692            .unwrap();
693        assert_eq!(session.last_active_at, Some(state.clock.now()));
694
695        // And recorded the access token as used
696        let access_token_lookup = repo
697            .oauth2_access_token()
698            .find_by_token(&access_token)
699            .await
700            .unwrap()
701            .unwrap();
702        assert!(access_token_lookup.is_used());
703        assert_eq!(access_token_lookup.first_used_at, Some(state.clock.now()));
704        repo.cancel().await.unwrap();
705
706        // Advance the clock to invalidate the access token
707        let old_now = state.clock.now();
708        state.clock.advance(Duration::try_hours(1).unwrap());
709
710        let request = Request::post(OAuth2Introspection::PATH)
711            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
712            .form(json!({ "token": access_token }));
713        let response = state.request(request).await;
714        response.assert_status(StatusCode::OK);
715        let response: IntrospectionResponse = response.json();
716        assert!(!response.active); // It shouldn't be active anymore
717
718        // That should not have updated the session last activity
719        state.activity_tracker.flush().await;
720        let mut repo = state.repository().await.unwrap();
721        let session = repo
722            .oauth2_session()
723            .lookup(session.id)
724            .await
725            .unwrap()
726            .unwrap();
727        assert_eq!(session.last_active_at, Some(old_now));
728        repo.cancel().await.unwrap();
729
730        // But the refresh token should still be valid
731        let request = Request::post(OAuth2Introspection::PATH)
732            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
733            .form(json!({ "token": refresh_token }));
734        let response = state.request(request).await;
735        response.assert_status(StatusCode::OK);
736        let response: IntrospectionResponse = response.json();
737        assert!(response.active);
738
739        // But this time, we should have updated the session last activity
740        state.activity_tracker.flush().await;
741        let mut repo = state.repository().await.unwrap();
742        let session = repo
743            .oauth2_session()
744            .lookup(session.id)
745            .await
746            .unwrap()
747            .unwrap();
748        assert_eq!(session.last_active_at, Some(state.clock.now()));
749        repo.cancel().await.unwrap();
750    }
751
752    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
753    async fn test_introspect_compat_tokens(pool: PgPool) {
754        setup();
755        let state = TestState::from_pool(pool).await.unwrap();
756
757        // Provision a client which will be used to do introspection requests
758        let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({
759            "client_uri": "https://introspecting.com/",
760            "grant_types": [],
761            "token_endpoint_auth_method": "client_secret_basic",
762        }));
763
764        let response = state.request(request).await;
765        response.assert_status(StatusCode::CREATED);
766        let client: ClientRegistrationResponse = response.json();
767        let introspecting_client_id = client.client_id;
768        let introspecting_client_secret = client.client_secret.unwrap();
769
770        // Provision a user with a password, so that we can use the password flow
771        let mut repo = state.repository().await.unwrap();
772        let user = repo
773            .user()
774            .add(&mut state.rng(), &state.clock, "alice".to_owned())
775            .await
776            .unwrap();
777
778        let mxid = state.homeserver_connection.mxid(&user.username);
779        state
780            .homeserver_connection
781            .provision_user(&ProvisionRequest::new(mxid, &user.sub))
782            .await
783            .unwrap();
784
785        let (version, hashed_password) = state
786            .password_manager
787            .hash(&mut state.rng(), Zeroizing::new(b"password".to_vec()))
788            .await
789            .unwrap();
790
791        repo.user_password()
792            .add(
793                &mut state.rng(),
794                &state.clock,
795                &user,
796                version,
797                hashed_password,
798                None,
799            )
800            .await
801            .unwrap();
802
803        repo.save().await.unwrap();
804
805        // Now do a password flow to get an access token and a refresh token
806        let request = Request::post("/_matrix/client/v3/login").json(json!({
807            "type": "m.login.password",
808            "refresh_token": true,
809            "identifier": {
810                "type": "m.id.user",
811                "user": "alice",
812            },
813            "password": "password",
814        }));
815        let response = state.request(request).await;
816        response.assert_status(StatusCode::OK);
817        let response: serde_json::Value = response.json();
818        let access_token = response["access_token"].as_str().unwrap();
819        let refresh_token = response["refresh_token"].as_str().unwrap();
820        let device_id = response["device_id"].as_str().unwrap();
821        let expected_scope: Scope =
822            format!("urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:{device_id}")
823                .parse()
824                .unwrap();
825
826        // Now that we have a token, we can introspect it
827        let request = Request::post(OAuth2Introspection::PATH)
828            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
829            .form(json!({ "token": access_token }));
830        let response = state.request(request).await;
831        response.assert_status(StatusCode::OK);
832        let response: IntrospectionResponse = response.json();
833        assert!(response.active);
834        assert_eq!(response.username.as_deref(), Some("alice"));
835        assert_eq!(response.client_id.as_deref(), Some("legacy"));
836        assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken));
837        assert_eq!(response.scope.as_ref(), Some(&expected_scope));
838        assert_eq!(response.device_id.as_deref(), Some(device_id));
839
840        // Check that requesting with X-MAS-Supports-Device-Id removes the device ID
841        // from the scope but not from the explicit device_id field
842        let request = Request::post(OAuth2Introspection::PATH)
843            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
844            .header("X-MAS-Supports-Device-Id", "1")
845            .form(json!({ "token": access_token }));
846        let response = state.request(request).await;
847        response.assert_status(StatusCode::OK);
848        let response: IntrospectionResponse = response.json();
849        assert!(response.active);
850        assert_eq!(response.username.as_deref(), Some("alice"));
851        assert_eq!(response.client_id.as_deref(), Some("legacy"));
852        assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken));
853        assert_eq!(
854            response.scope.map(|s| s.to_string()),
855            Some("urn:matrix:org.matrix.msc2967.client:api:*".to_owned())
856        );
857        assert_eq!(response.device_id.as_deref(), Some(device_id));
858
859        // Do the same request, but with a token_type_hint
860        let request = Request::post(OAuth2Introspection::PATH)
861            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
862            .form(json!({"token": access_token, "token_type_hint": "access_token"}));
863        let response = state.request(request).await;
864        response.assert_status(StatusCode::OK);
865        let response: IntrospectionResponse = response.json();
866        assert!(response.active);
867
868        // Do the same request, but with the wrong token_type_hint
869        let request = Request::post(OAuth2Introspection::PATH)
870            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
871            .form(json!({"token": access_token, "token_type_hint": "refresh_token"}));
872        let response = state.request(request).await;
873        response.assert_status(StatusCode::OK);
874        let response: IntrospectionResponse = response.json();
875        assert!(!response.active); // It shouldn't be active
876
877        // Do the same, but with a refresh token
878        let request = Request::post(OAuth2Introspection::PATH)
879            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
880            .form(json!({ "token": refresh_token }));
881        let response = state.request(request).await;
882        response.assert_status(StatusCode::OK);
883        let response: IntrospectionResponse = response.json();
884        assert!(response.active);
885        assert_eq!(response.username.as_deref(), Some("alice"));
886        assert_eq!(response.client_id.as_deref(), Some("legacy"));
887        assert_eq!(response.token_type, Some(OAuthTokenTypeHint::RefreshToken));
888        assert_eq!(response.scope.as_ref(), Some(&expected_scope));
889        assert_eq!(response.device_id.as_deref(), Some(device_id));
890
891        // Do the same request, but with a token_type_hint
892        let request = Request::post(OAuth2Introspection::PATH)
893            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
894            .form(json!({"token": refresh_token, "token_type_hint": "refresh_token"}));
895        let response = state.request(request).await;
896        response.assert_status(StatusCode::OK);
897        let response: IntrospectionResponse = response.json();
898        assert!(response.active);
899
900        // Do the same request, but with the wrong token_type_hint
901        let request = Request::post(OAuth2Introspection::PATH)
902            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
903            .form(json!({"token": refresh_token, "token_type_hint": "access_token"}));
904        let response = state.request(request).await;
905        response.assert_status(StatusCode::OK);
906        let response: IntrospectionResponse = response.json();
907        assert!(!response.active); // It shouldn't be active
908
909        // Advance the clock to invalidate the access token
910        state.clock.advance(Duration::try_hours(1).unwrap());
911
912        let request = Request::post(OAuth2Introspection::PATH)
913            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
914            .form(json!({ "token": access_token }));
915        let response = state.request(request).await;
916        response.assert_status(StatusCode::OK);
917        let response: IntrospectionResponse = response.json();
918        assert!(!response.active); // It shouldn't be active anymore
919
920        // But the refresh token should still be valid
921        let request = Request::post(OAuth2Introspection::PATH)
922            .basic_auth(&introspecting_client_id, &introspecting_client_secret)
923            .form(json!({ "token": refresh_token }));
924        let response = state.request(request).await;
925        response.assert_status(StatusCode::OK);
926        let response: IntrospectionResponse = response.json();
927        assert!(response.active);
928    }
929}