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