mas_handlers/admin/
call_context.rs

1// Copyright 2024, 2025 New Vector Ltd.
2// Copyright 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::convert::Infallible;
8
9use aide::OperationIo;
10use axum::{
11    Json,
12    extract::FromRequestParts,
13    response::{IntoResponse, Response},
14};
15use axum_extra::TypedHeader;
16use headers::{Authorization, authorization::Bearer};
17use hyper::StatusCode;
18use mas_axum_utils::record_error;
19use mas_data_model::{Session, User};
20use mas_storage::{BoxClock, BoxRepository, RepositoryError};
21use ulid::Ulid;
22
23use super::response::ErrorResponse;
24use crate::BoundActivityTracker;
25
26#[derive(Debug, thiserror::Error)]
27pub enum Rejection {
28    /// The authorization header is missing
29    #[error("Missing authorization header")]
30    MissingAuthorizationHeader,
31
32    /// The authorization header is invalid
33    #[error("Invalid authorization header")]
34    InvalidAuthorizationHeader,
35
36    /// Couldn't load the database repository
37    #[error("Couldn't load the database repository")]
38    RepositorySetup(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
39
40    /// A database operation failed
41    #[error("Invalid repository operation")]
42    Repository(#[from] RepositoryError),
43
44    /// The access token could not be found in the database
45    #[error("Unknown access token")]
46    UnknownAccessToken,
47
48    /// The access token provided expired
49    #[error("Access token expired")]
50    TokenExpired,
51
52    /// The session associated with the access token was revoked
53    #[error("Access token revoked")]
54    SessionRevoked,
55
56    /// The user associated with the session is locked
57    #[error("User locked")]
58    UserLocked,
59
60    /// Failed to load the session
61    #[error("Failed to load session {0}")]
62    LoadSession(Ulid),
63
64    /// Failed to load the user
65    #[error("Failed to load user {0}")]
66    LoadUser(Ulid),
67
68    /// The session does not have the `urn:mas:admin` scope
69    #[error("Missing urn:mas:admin scope")]
70    MissingScope,
71}
72
73impl IntoResponse for Rejection {
74    fn into_response(self) -> Response {
75        let response = ErrorResponse::from_error(&self);
76        let sentry_event_id = record_error!(
77            self,
78            Self::RepositorySetup(_)
79                | Self::Repository(_)
80                | Self::LoadSession(_)
81                | Self::LoadUser(_)
82        );
83
84        let status = match &self {
85            Rejection::InvalidAuthorizationHeader | Rejection::MissingAuthorizationHeader => {
86                StatusCode::BAD_REQUEST
87            }
88
89            Rejection::UnknownAccessToken
90            | Rejection::TokenExpired
91            | Rejection::SessionRevoked
92            | Rejection::UserLocked
93            | Rejection::MissingScope => StatusCode::UNAUTHORIZED,
94
95            Rejection::RepositorySetup(_)
96            | Rejection::Repository(_)
97            | Rejection::LoadSession(_)
98            | Rejection::LoadUser(_) => StatusCode::INTERNAL_SERVER_ERROR,
99        };
100
101        (status, sentry_event_id, Json(response)).into_response()
102    }
103}
104
105/// An extractor which authorizes the request
106///
107/// Because we need to load the database repository and the clock, we keep them
108/// in the context to avoid creating two instances for each request.
109#[non_exhaustive]
110#[derive(OperationIo)]
111#[aide(input)]
112pub struct CallContext {
113    pub repo: BoxRepository,
114    pub clock: BoxClock,
115    pub user: Option<User>,
116    pub session: Session,
117}
118
119impl<S> FromRequestParts<S> for CallContext
120where
121    S: Send + Sync,
122    BoundActivityTracker: FromRequestParts<S, Rejection = Infallible>,
123    BoxRepository: FromRequestParts<S>,
124    BoxClock: FromRequestParts<S, Rejection = Infallible>,
125    <BoxRepository as FromRequestParts<S>>::Rejection:
126        Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
127{
128    type Rejection = Rejection;
129
130    async fn from_request_parts(
131        parts: &mut axum::http::request::Parts,
132        state: &S,
133    ) -> Result<Self, Self::Rejection> {
134        let Ok(activity_tracker) = BoundActivityTracker::from_request_parts(parts, state).await;
135        let Ok(clock) = BoxClock::from_request_parts(parts, state).await;
136
137        // Load the database repository
138        let mut repo = BoxRepository::from_request_parts(parts, state)
139            .await
140            .map_err(Into::into)
141            .map_err(Rejection::RepositorySetup)?;
142
143        // Extract the access token from the authorization header
144        let token = TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
145            .await
146            .map_err(|e| {
147                // We map to two differentsson of errors depending on whether the header is
148                // missing or invalid
149                if e.is_missing() {
150                    Rejection::MissingAuthorizationHeader
151                } else {
152                    Rejection::InvalidAuthorizationHeader
153                }
154            })?;
155
156        let token = token.token();
157
158        // Look for the access token in the database
159        let token = repo
160            .oauth2_access_token()
161            .find_by_token(token)
162            .await?
163            .ok_or(Rejection::UnknownAccessToken)?;
164
165        // Look for the associated session in the database
166        let session = repo
167            .oauth2_session()
168            .lookup(token.session_id)
169            .await?
170            .ok_or_else(|| Rejection::LoadSession(token.session_id))?;
171
172        // Record the activity on the session
173        activity_tracker
174            .record_oauth2_session(&clock, &session)
175            .await;
176
177        // Load the user if there is one
178        let user = if let Some(user_id) = session.user_id {
179            let user = repo
180                .user()
181                .lookup(user_id)
182                .await?
183                .ok_or_else(|| Rejection::LoadUser(user_id))?;
184            Some(user)
185        } else {
186            None
187        };
188
189        // If there is a user for this session, check that it is not locked
190        if let Some(user) = &user {
191            if !user.is_valid() {
192                return Err(Rejection::UserLocked);
193            }
194        }
195
196        if !session.is_valid() {
197            return Err(Rejection::SessionRevoked);
198        }
199
200        if !token.is_valid(clock.now()) {
201            return Err(Rejection::TokenExpired);
202        }
203
204        // For now, we only check that the session has the admin scope
205        // Later we might want to check other route-specific scopes
206        if !session.scope.contains("urn:mas:admin") {
207            return Err(Rejection::MissingScope);
208        }
209
210        Ok(Self {
211            repo,
212            clock,
213            user,
214            session,
215        })
216    }
217}