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