From c6cd294777973fd1f0368c081f263c0906b04f4e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 4 Jun 2026 12:31:17 +0200 Subject: [PATCH 1/5] mas-context: add a Requester to the LogContext Add a `Requester` enum (a user, an OAuth2 client, or the homeserver) stored in a set-once slot on the `LogContext`. This lets request handlers record which entity authenticated a request so it can be logged later. --- Cargo.lock | 1 + crates/context/Cargo.toml | 1 + crates/context/src/lib.rs | 89 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4305a870f..f7d455c02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3244,6 +3244,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "ulid", ] [[package]] diff --git a/crates/context/Cargo.toml b/crates/context/Cargo.toml index ad86d1426..c19a6db64 100644 --- a/crates/context/Cargo.toml +++ b/crates/context/Cargo.toml @@ -27,3 +27,4 @@ tracing.workspace = true tracing-subscriber.workspace = true tracing-opentelemetry.workspace = true opentelemetry.workspace = true +ulid.workspace = true diff --git a/crates/context/src/lib.rs b/crates/context/src/lib.rs index c9644282d..298680dbb 100644 --- a/crates/context/src/lib.rs +++ b/crates/context/src/lib.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -11,7 +12,7 @@ mod service; use std::{ borrow::Cow, sync::{ - Arc, + Arc, OnceLock, atomic::{AtomicU64, Ordering}, }, time::Duration, @@ -19,6 +20,7 @@ use std::{ use quanta::Instant; use tokio::task_local; +use ulid::Ulid; pub use self::{ fmt::EventFormatter, @@ -27,6 +29,68 @@ pub use self::{ service::LogContextService, }; +/// An identified principal making a request, recorded on the [`LogContext`]. +/// +/// This is the entity that *authenticated the request* (the caller), not the +/// subject an operation might be about — e.g. on the introspection endpoint +/// it is the homeserver or client doing the introspecting, not the user whose +/// token is being introspected. +#[derive(Clone, Debug)] +pub enum Requester { + /// A user, identified by their Ulid and (when known) their username. + User { + id: Ulid, + /// Sometimes only the `user_id` is known at auth-resolution time (e.g. + /// `OAuth2`/compat bearer flows that don't load the `User` row). + username: Option, + }, + + /// An `OAuth2` client acting on its own behalf (e.g. at the token endpoint, + /// or a client-credentials session), identified by its internal Ulid. + OAuth2Client { id: Ulid }, + + /// The homeserver, e.g. calling the introspection endpoint with its shared + /// secret. + Homeserver, +} + +impl Requester { + /// Build a `User` requester with both an id and a known username. + #[must_use] + pub fn user(id: Ulid, username: impl Into) -> Self { + Self::User { + id, + username: Some(username.into()), + } + } + + /// Build a `User` requester from a `user_id` alone (username unknown). + #[must_use] + pub fn user_id_only(id: Ulid) -> Self { + Self::User { id, username: None } + } + + /// Build an `OAuth2Client` requester from a client's internal Ulid. + #[must_use] + pub fn oauth2_client(id: Ulid) -> Self { + Self::OAuth2Client { id } + } +} + +impl std::fmt::Display for Requester { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::User { + id, + username: Some(name), + } => write!(f, "user:{id}({name})"), + Self::User { id, username: None } => write!(f, "user:{id}"), + Self::OAuth2Client { id } => write!(f, "oauth2-client:{id}"), + Self::Homeserver => write!(f, "homeserver"), + } + } +} + /// A counter which increments each time we create a new log context /// It will wrap around if we create more than [`u64::MAX`] contexts static LOG_CONTEXT_INDEX: AtomicU64 = AtomicU64::new(0); @@ -57,6 +121,10 @@ struct LogContextInner { /// An approximation of the total CPU time spent in the context, in /// nanoseconds cpu_time: AtomicU64, + + /// The identified principal making the request, if it has been resolved. + /// Set once per context, first writer wins. + requester: OnceLock, } impl LogContext { @@ -69,6 +137,7 @@ impl LogContext { start: Instant::now(), polls: AtomicU64::new(0), cpu_time: AtomicU64::new(0), + requester: OnceLock::new(), }; Self { @@ -101,6 +170,24 @@ impl LogContext { result } + /// Associate a [`Requester`] with this log context. Silently does nothing + /// if a requester has already been recorded (first writer wins). + pub fn set_requester(&self, requester: Requester) { + let _ = self.inner.requester.set(requester); + } + + /// Get the [`Requester`] associated with this log context, if one was set. + #[must_use] + pub fn requester(&self) -> Option<&Requester> { + self.inner.requester.get() + } + + /// Convenience: set the [`Requester`] on the current task-local + /// `LogContext`, if one exists. No-op outside of a `LogContext` scope. + pub fn maybe_set_requester(requester: Requester) { + Self::maybe_with(|ctx| ctx.set_requester(requester)); + } + /// Create a snapshot of the log context statistics #[must_use] pub fn stats(&self) -> LogContextStats { From 715d5164303ae245b166f76ce771f212f317f540 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 4 Jun 2026 12:31:17 +0200 Subject: [PATCH 2/5] mas-axum-utils: record the requester at shared auth points Add a `RecordAsRequester` trait mapping domain types (User, BrowserSession, OAuth2 Session, CompatSession, Client) to a `Requester`, and record it from the two shared auth-resolution points: `SessionInfo::load_active_session` (browser sessions) and `AccessToken::fetch` (OAuth2 bearer tokens). --- Cargo.lock | 1 + crates/axum-utils/Cargo.toml | 1 + crates/axum-utils/src/lib.rs | 3 + crates/axum-utils/src/log_context.rs | 61 +++++++++++++++++++++ crates/axum-utils/src/session.rs | 7 ++- crates/axum-utils/src/user_authorization.rs | 5 ++ 6 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 crates/axum-utils/src/log_context.rs diff --git a/Cargo.lock b/Cargo.lock index f7d455c02..df9a4c62f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3102,6 +3102,7 @@ dependencies = [ "headers", "http", "icu_locid", + "mas-context", "mas-data-model", "mas-http", "mas-iana", diff --git a/crates/axum-utils/Cargo.toml b/crates/axum-utils/Cargo.toml index ceb47a4c7..857cbcc28 100644 --- a/crates/axum-utils/Cargo.toml +++ b/crates/axum-utils/Cargo.toml @@ -39,6 +39,7 @@ url.workspace = true ulid.workspace = true oauth2-types.workspace = true +mas-context.workspace = true mas-data-model.workspace = true mas-http.workspace = true mas-iana.workspace = true diff --git a/crates/axum-utils/src/lib.rs b/crates/axum-utils/src/lib.rs index a4e769dcd..7228b9894 100644 --- a/crates/axum-utils/src/lib.rs +++ b/crates/axum-utils/src/lib.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -14,6 +15,7 @@ pub mod error_wrapper; pub mod fancy_error; pub mod jwt; pub mod language_detection; +pub mod log_context; pub mod sentry; pub mod session; pub mod user_authorization; @@ -23,5 +25,6 @@ pub use axum; pub use self::{ error_wrapper::ErrorWrapper, fancy_error::{GenericError, InternalError}, + log_context::RecordAsRequester, session::{SessionInfo, SessionInfoExt}, }; diff --git a/crates/axum-utils/src/log_context.rs b/crates/axum-utils/src/log_context.rs new file mode 100644 index 000000000..53d845d47 --- /dev/null +++ b/crates/axum-utils/src/log_context.rs @@ -0,0 +1,61 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Ergonomic glue between domain types and the [`LogContext`] requester slot. +//! +//! Auth-resolution sites can call `value.maybe_record_as_requester()` on a +//! [`User`], [`BrowserSession`], [`Session`], [`CompatSession`] or [`Client`] +//! instead of reaching for [`LogContext::maybe_set_requester`] + [`Requester`] +//! directly. Each call site should record the entity that *authenticated the +//! request* exactly once; [`LogContext`] is first-writer-wins, so an accidental +//! second call is harmless. + +use mas_context::{LogContext, Requester}; +use mas_data_model::{BrowserSession, Client, CompatSession, Session, User}; + +/// Record `self` as the [`Requester`] on the current [`LogContext`], if any. +/// +/// First writer wins — calling on a request that already has a requester +/// recorded is a no-op. +pub trait RecordAsRequester { + fn maybe_record_as_requester(&self); +} + +impl RecordAsRequester for User { + fn maybe_record_as_requester(&self) { + LogContext::maybe_set_requester(Requester::user(self.id, self.username.clone())); + } +} + +impl RecordAsRequester for BrowserSession { + fn maybe_record_as_requester(&self) { + self.user.maybe_record_as_requester(); + } +} + +impl RecordAsRequester for Session { + /// Records the `OAuth2` session's user (without a username) when it has + /// one, otherwise the client acting on its own behalf + /// (client-credentials). + fn maybe_record_as_requester(&self) { + if let Some(user_id) = self.user_id { + LogContext::maybe_set_requester(Requester::user_id_only(user_id)); + } else { + LogContext::maybe_set_requester(Requester::oauth2_client(self.client_id)); + } + } +} + +impl RecordAsRequester for CompatSession { + fn maybe_record_as_requester(&self) { + LogContext::maybe_set_requester(Requester::user_id_only(self.user_id)); + } +} + +impl RecordAsRequester for Client { + fn maybe_record_as_requester(&self) { + LogContext::maybe_set_requester(Requester::oauth2_client(self.id)); + } +} diff --git a/crates/axum-utils/src/session.rs b/crates/axum-utils/src/session.rs index b5ed670ae..44b0c37fa 100644 --- a/crates/axum-utils/src/session.rs +++ b/crates/axum-utils/src/session.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -9,7 +10,7 @@ use mas_storage::RepositoryAccess; use serde::{Deserialize, Serialize}; use ulid::Ulid; -use crate::cookies::CookieJar; +use crate::{cookies::CookieJar, log_context::RecordAsRequester}; /// An encrypted cookie to save the session ID #[derive(Serialize, Deserialize, Debug, Default, Clone)] @@ -53,6 +54,10 @@ impl SessionInfo { // Ensure that the session is still active .filter(BrowserSession::active); + if let Some(session) = &maybe_session { + session.maybe_record_as_requester(); + } + Ok(maybe_session) } diff --git a/crates/axum-utils/src/user_authorization.rs b/crates/axum-utils/src/user_authorization.rs index beb3ece2b..dd8e6cecb 100644 --- a/crates/axum-utils/src/user_authorization.rs +++ b/crates/axum-utils/src/user_authorization.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -24,6 +25,8 @@ use mas_storage::{ use serde::{Deserialize, de::DeserializeOwned}; use thiserror::Error; +use crate::log_context::RecordAsRequester; + #[derive(Debug, Deserialize)] struct AuthorizedForm { #[serde(default)] @@ -62,6 +65,8 @@ impl AccessToken { .await? .ok_or(AuthorizationVerificationError::InvalidToken)?; + session.maybe_record_as_requester(); + Ok((token, session)) } } From be257bf683ef970f1d7ae5a0ba0c39a123a69872 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 4 Jun 2026 12:31:17 +0200 Subject: [PATCH 3/5] mas-handlers: record the requester at the remaining auth sites Record the requester for the admin API, GraphQL, the compat login/logout/logout_all/refresh endpoints, and the OAuth2 token (the authenticated client) and introspection (the introspecting client, or the homeserver) endpoints. Introspection records the caller, never the subject of the introspected token. The request test helper now runs each request inside a `LogContext`, with a helper to assert the recorded requester. --- crates/handlers/src/admin/call_context.rs | 10 +- crates/handlers/src/compat/login.rs | 65 +++++++++- crates/handlers/src/compat/logout.rs | 5 +- crates/handlers/src/compat/logout_all.rs | 5 +- crates/handlers/src/compat/refresh.rs | 5 +- crates/handlers/src/graphql/mod.rs | 8 +- .../handlers/src/oauth2/device/authorize.rs | 3 + crates/handlers/src/oauth2/introspection.rs | 120 ++++++++++++++++++ crates/handlers/src/oauth2/revoke.rs | 3 + crates/handlers/src/oauth2/token.rs | 5 + crates/handlers/src/session.rs | 7 +- crates/handlers/src/test_utils.rs | 40 +++++- crates/handlers/src/upstream_oauth2/link.rs | 10 +- crates/handlers/src/views/login.rs | 6 +- crates/handlers/src/views/logout.rs | 6 +- .../src/views/register/steps/finish.rs | 5 +- 16 files changed, 291 insertions(+), 12 deletions(-) diff --git a/crates/handlers/src/admin/call_context.rs b/crates/handlers/src/admin/call_context.rs index 1cffe682e..e3f0db99a 100644 --- a/crates/handlers/src/admin/call_context.rs +++ b/crates/handlers/src/admin/call_context.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -15,7 +16,7 @@ use axum::{ use axum_extra::TypedHeader; use headers::{Authorization, authorization::Bearer}; use hyper::StatusCode; -use mas_axum_utils::record_error; +use mas_axum_utils::{RecordAsRequester, record_error}; use mas_data_model::{ BoxClock, Session, TokenFormatError, TokenType, User, personal::session::{PersonalSession, PersonalSessionOwner}, @@ -273,10 +274,17 @@ where } } + user.maybe_record_as_requester(); + Some(user) } else { // Double check we're not using a PersonalSession assert!(matches!(session, CallerSession::OAuth2Session(_))); + // There is no user on this session (client-credentials): attribute + // the request to the client instead. + if let CallerSession::OAuth2Session(session) = &session { + session.maybe_record_as_requester(); + } None }; diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 76b4074a5..875add7ee 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -13,7 +14,7 @@ use axum::{Json, extract::State, response::IntoResponse}; use axum_extra::typed_header::TypedHeader; use chrono::Duration; use hyper::StatusCode; -use mas_axum_utils::record_error; +use mas_axum_utils::{RecordAsRequester, record_error}; use mas_data_model::{ BoxClock, BoxRng, Clock, CompatSession, CompatSsoLoginState, Device, SessionLimitConfig, SiteConfig, TokenType, User, @@ -405,6 +406,8 @@ pub(crate) async fn post( .await?; } + user.maybe_record_as_requester(); + let user_id = homeserver.mxid(&user.username); // If the client asked for a refreshable token, make it expire @@ -1202,6 +1205,66 @@ mod tests { user } + /// A successful compat password login records the acting user, with their + /// username, as the requester on the log context. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_login_records_requester(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let user = user_with_password(&state, "alice", "password", false).await; + + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { "type": "m.id.user", "user": "alice" }, + "password": "password", + })); + let (response, requester) = state.request_capturing_requester(request).await; + response.assert_status(StatusCode::OK); + + assert_eq!(requester, Some(format!("user:{}(alice)", user.id))); + } + + /// A compat token refresh records the acting user too, but without the + /// username (it isn't loaded on that path) — exercising the id-only form. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_refresh_records_requester_id_only(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let user = user_with_password(&state, "alice", "password", false).await; + + // Login first, asking for a refresh token. + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { "type": "m.id.user", "user": "alice" }, + "password": "password", + "refresh_token": true, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + let refresh_token = body["refresh_token"].as_str().unwrap().to_owned(); + + let request = Request::post("/_matrix/client/v3/refresh") + .json(serde_json::json!({ "refresh_token": refresh_token })); + let (response, requester) = state.request_capturing_requester(request).await; + response.assert_status(StatusCode::OK); + + assert_eq!(requester, Some(format!("user:{}", user.id))); + } + + /// A request that doesn't authenticate records no requester. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_unauthenticated_request_records_no_requester(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let request = Request::get("/_matrix/client/v3/login").empty(); + let (response, requester) = state.request_capturing_requester(request).await; + response.assert_status(StatusCode::OK); + + assert_eq!(requester, None); + } + /// Test that a user can login with a password using the Matrix /// compatibility API. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/compat/logout.rs b/crates/handlers/src/compat/logout.rs index 8800fcf44..3d7532468 100644 --- a/crates/handlers/src/compat/logout.rs +++ b/crates/handlers/src/compat/logout.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -10,7 +11,7 @@ use axum::{Json, response::IntoResponse}; use axum_extra::typed_header::TypedHeader; use headers::{Authorization, authorization::Bearer}; use hyper::StatusCode; -use mas_axum_utils::record_error; +use mas_axum_utils::{RecordAsRequester, record_error}; use mas_data_model::{BoxClock, BoxRng, Clock, TokenType}; use mas_storage::{ BoxRepository, RepositoryAccess, @@ -117,6 +118,8 @@ pub(crate) async fn post( // XXX: this is probably not the right error .ok_or(RouteError::InvalidAuthorization)?; + user.maybe_record_as_requester(); + // This will make the access token invalid repo.compat_session().finish(&clock, session).await?; diff --git a/crates/handlers/src/compat/logout_all.rs b/crates/handlers/src/compat/logout_all.rs index f2ec10a82..158b846fd 100644 --- a/crates/handlers/src/compat/logout_all.rs +++ b/crates/handlers/src/compat/logout_all.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -9,7 +10,7 @@ use axum::{Json, response::IntoResponse}; use axum_extra::typed_header::TypedHeader; use headers::{Authorization, authorization::Bearer}; use hyper::StatusCode; -use mas_axum_utils::record_error; +use mas_axum_utils::{RecordAsRequester, record_error}; use mas_data_model::{BoxClock, BoxRng, Clock, TokenType}; use mas_storage::{ BoxRepository, RepositoryAccess, @@ -177,6 +178,8 @@ pub(crate) async fn post( return Err(RouteError::InvalidUser(session.user_id)); } + user.maybe_record_as_requester(); + if !input.only_compat_is_fine { return Err(RouteError::NotSupported); } diff --git a/crates/handlers/src/compat/refresh.rs b/crates/handlers/src/compat/refresh.rs index dabdaefe7..29778b714 100644 --- a/crates/handlers/src/compat/refresh.rs +++ b/crates/handlers/src/compat/refresh.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -7,7 +8,7 @@ use axum::{Json, extract::State, response::IntoResponse}; use chrono::Duration; use hyper::StatusCode; -use mas_axum_utils::record_error; +use mas_axum_utils::{RecordAsRequester, record_error}; use mas_data_model::{BoxClock, BoxRng, Clock, SiteConfig, TokenFormatError, TokenType}; use mas_storage::{ BoxRepository, @@ -120,6 +121,8 @@ pub(crate) async fn post( return Err(RouteError::InvalidSession(refresh_token.session_id)); } + session.maybe_record_as_requester(); + activity_tracker .record_compat_session(&clock, &session) .await; diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index ef7169d4f..ef211019b 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -26,7 +27,8 @@ use futures_util::TryStreamExt; use headers::{Authorization, ContentType, HeaderValue, authorization::Bearer}; use hyper::header::CACHE_CONTROL; use mas_axum_utils::{ - InternalError, SessionInfo, SessionInfoExt, cookies::CookieJar, sentry::SentryEventID, + InternalError, RecordAsRequester, SessionInfo, SessionInfoExt, cookies::CookieJar, + sentry::SentryEventID, }; use mas_data_model::{ BoxClock, BoxRng, BrowserSession, Clock, Session, SiteConfig, SystemClock, User, @@ -283,6 +285,10 @@ async fn get_requester( return Err(RouteError::MissingScope); } + if let Some(user) = &user { + user.maybe_record_as_requester(); + } + RequestingEntity::OAuth2Session(Box::new((session, user))) } else { let maybe_session = session_info.load_active_session(&mut repo).await?; diff --git a/crates/handlers/src/oauth2/device/authorize.rs b/crates/handlers/src/oauth2/device/authorize.rs index 2ae28cb39..3fb711192 100644 --- a/crates/handlers/src/oauth2/device/authorize.rs +++ b/crates/handlers/src/oauth2/device/authorize.rs @@ -10,6 +10,7 @@ use chrono::Duration; use headers::{CacheControl, Pragma}; use hyper::StatusCode; use mas_axum_utils::{ + RecordAsRequester, client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; @@ -141,6 +142,8 @@ pub(crate) async fn post( } })?; + client.maybe_record_as_requester(); + if !client.grant_types.contains(&GrantType::DeviceCode) { return Err(RouteError::ClientNotAllowed(client.id)); } diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index c093ed7bc..04ff851e6 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -12,9 +13,11 @@ use std::{ use axum::{Json, extract::State, http::HeaderValue, response::IntoResponse}; use hyper::{HeaderMap, StatusCode}; use mas_axum_utils::{ + RecordAsRequester, client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; +use mas_context::{LogContext, Requester}; use mas_data_model::{ BoxClock, Clock, Device, TokenFormatError, TokenType, personal::session::PersonalSessionOwner, }; @@ -278,6 +281,10 @@ pub(crate) async fn post( { return Err(RouteError::InvalidBearerToken); } + + // The caller is the homeserver itself — not the user whose token is + // being introspected below. + LogContext::maybe_set_requester(Requester::Homeserver); } else { // Otherwise, it presented regular client credentials, so we verify them let client = credentials @@ -296,6 +303,10 @@ pub(crate) async fn post( credentials .verify(&http_client, &encrypter, method, &client) .await?; + + // The introspecting client is the caller — not the introspected token's + // subject. + client.maybe_record_as_requester(); } let Some(form) = form else { @@ -764,6 +775,115 @@ mod tests { test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}, }; + /// Introspection records the *caller* (the introspecting client, or the + /// homeserver) as the requester — never the subject of the introspected + /// token. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_introspection_records_caller_as_requester(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // A confidential client allowed to introspect. + let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({ + "client_uri": "https://introspecting.com/", + "grant_types": [], + "token_endpoint_auth_method": "client_secret_basic", + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let client: ClientRegistrationResponse = response.json(); + let introspecting_client_id = client.client_id; + let introspecting_client_secret = client.client_secret.unwrap(); + + // A separate client + user + oauth2 session, to mint a token to introspect. + let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({ + "client_uri": "https://client.com/", + "redirect_uris": ["https://client.com/"], + "response_types": ["code"], + "grant_types": ["authorization_code", "refresh_token"], + "token_endpoint_auth_method": "none", + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let ClientRegistrationResponse { client_id, .. } = response.json(); + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + state + .homeserver_connection + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) + .await + .unwrap(); + let client = repo + .oauth2_client() + .find_by_client_id(&client_id) + .await + .unwrap() + .unwrap(); + let browser_session = repo + .browser_session() + .add(&mut state.rng(), &state.clock, &user, None) + .await + .unwrap(); + let session = repo + .oauth2_session() + .add_from_browser_session( + &mut state.rng(), + &state.clock, + &client, + &browser_session, + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + let (AccessToken { access_token, .. }, _) = generate_token_pair( + &mut state.rng(), + &state.clock, + &mut repo, + &session, + Duration::microseconds(5 * 60 * 1000 * 1000), + ) + .await + .unwrap(); + + // The internal id of the introspecting client, for the assertion below. + let introspecting_client = repo + .oauth2_client() + .find_by_client_id(&introspecting_client_id) + .await + .unwrap() + .unwrap(); + repo.save().await.unwrap(); + + // (1) Introspecting with client credentials: the introspecting client is + // the requester — not alice, who owns the token. + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({ "token": access_token })); + let (response, requester) = state.request_capturing_requester(request).await; + response.assert_status(StatusCode::OK); + assert_eq!( + requester, + Some(format!("oauth2-client:{}", introspecting_client.id)) + ); + + // (2) Introspecting with the homeserver's bearer token: the homeserver is + // the requester. + let request = Request::post(OAuth2Introspection::PATH) + .header( + "authorization", + format!("Bearer {}", MockHomeserverConnection::VALID_BEARER_TOKEN), + ) + .form(json!({ "token": access_token })); + let (response, requester) = state.request_capturing_requester(request).await; + response.assert_status(StatusCode::OK); + assert_eq!(requester, Some("homeserver".to_owned())); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_introspect_oauth_tokens(pool: PgPool) { setup(); diff --git a/crates/handlers/src/oauth2/revoke.rs b/crates/handlers/src/oauth2/revoke.rs index fa11216dc..3b666697b 100644 --- a/crates/handlers/src/oauth2/revoke.rs +++ b/crates/handlers/src/oauth2/revoke.rs @@ -7,6 +7,7 @@ use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::{ + RecordAsRequester, client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; @@ -157,6 +158,8 @@ pub(crate) async fn post( } })?; + client.maybe_record_as_requester(); + let Some(form) = client_authorization.form else { return Err(RouteError::BadRequest); }; diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 925f4209d..34f37286a 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -13,6 +13,7 @@ use chrono::Duration; use headers::{CacheControl, HeaderMap, HeaderMapExt, Pragma}; use hyper::StatusCode; use mas_axum_utils::{ + RecordAsRequester, client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; @@ -319,6 +320,10 @@ pub(crate) async fn post( } })?; + // The authenticated client is the entity making this request, regardless of + // the grant type or whether a user session is later minted. + client.maybe_record_as_requester(); + let form = client_authorization.form.ok_or(RouteError::BadRequest)?; let grant_type = form.grant_type(); diff --git a/crates/handlers/src/session.rs b/crates/handlers/src/session.rs index 5aed909a8..cbc18b121 100644 --- a/crates/handlers/src/session.rs +++ b/crates/handlers/src/session.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -7,7 +8,7 @@ //! locked or deactivated use axum::response::{Html, IntoResponse as _, Response}; -use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, csrf::CsrfExt}; +use mas_axum_utils::{RecordAsRequester, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt}; use mas_data_model::{BrowserSession, Clock, User}; use mas_i18n::DataLocale; use mas_policy::model::SessionCounts; @@ -65,6 +66,10 @@ pub async fn load_session_or_fallback( }); }; + // Record the user as the requester even when we return a fallback page + // below — the request is still attributable to that user. + session.maybe_record_as_requester(); + if session.user.deactivated_at.is_some() { // The account is deactivated, show the 'account deactivated' fallback let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 3b8894991..ceeb9797e 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -29,6 +29,7 @@ use mas_axum_utils::{ cookies::{CookieJar, CookieManager}, }; use mas_config::RateLimitingConfig; +use mas_context::LogContext; use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, clock::MockClock}; use mas_email::{MailTransport, Mailer}; use mas_i18n::Translator; @@ -330,7 +331,10 @@ impl TestState { state } - pub async fn request(&self, request: Request) -> Response + /// Dispatch a request through the router, without setting up a log context. + /// Use [`TestState::request`] instead — it mirrors production by running + /// inside a [`LogContext`]. + async fn dispatch(&self, request: Request) -> Response where B: HttpBody + Send + 'static, ::Error: std::error::Error + Send + Sync, @@ -365,6 +369,40 @@ impl TestState { Response::from_parts(parts, body) } + pub async fn request(&self, request: Request) -> Response + where + B: HttpBody + Send + 'static, + ::Error: std::error::Error + Send + Sync, + B::Error: std::error::Error + Send + Sync, + B::Data: Send, + { + // Mirror production: every request is served inside a `LogContext` + // (set up by the `LogContextLayer` in the real server). + LogContext::new("test-request") + .run(|| self.dispatch(request)) + .await + } + + /// Like [`TestState::request`], but additionally returns the requester that + /// the handlers recorded on the request's [`LogContext`] (formatted via its + /// `Display`), so tests can assert how a request was attributed. `None` + /// means no requester was recorded. + pub async fn request_capturing_requester( + &self, + request: Request, + ) -> (Response, Option) + where + B: HttpBody + Send + 'static, + ::Error: std::error::Error + Send + Sync, + B::Error: std::error::Error + Send + Sync, + B::Data: Send, + { + let log_context = LogContext::new("test-request"); + let response = log_context.run(|| self.dispatch(request)).await; + let requester = log_context.requester().map(ToString::to_string); + (response, requester) + } + /// Get a token with the given scope pub async fn token_with_scope(&mut self, scope: &str) -> String { // Provision a client diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 0f27b5cba..3cc7955b6 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -17,7 +17,7 @@ use axum::{ use axum_extra::typed_header::TypedHeader; use hyper::StatusCode; use mas_axum_utils::{ - GenericError, SessionInfoExt, + GenericError, RecordAsRequester, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, record_error, @@ -356,6 +356,10 @@ pub(crate) async fn get( .add(&mut rng, &clock, &user, user_agent) .await?; + // Attribute this request (and its log line) to the user being + // logged in through the upstream provider. + user.maybe_record_as_requester(); + let upstream_session = repo .upstream_oauth_session() .consume(&clock, upstream_session, &session) @@ -648,6 +652,10 @@ pub(crate) async fn get( .add(&mut rng, &clock, &existing_user, user_agent) .await?; + // Attribute this request (and its log line) to the user + // being logged in through the upstream provider. + existing_user.maybe_record_as_requester(); + let upstream_session = repo .upstream_oauth_session() .consume(&clock, upstream_session, &session) diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 8493d06a1..3b1bc0360 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -13,7 +13,7 @@ use axum::{ use axum_extra::{extract::Query, typed_header::TypedHeader}; use hyper::StatusCode; use mas_axum_utils::{ - InternalError, SessionInfoExt, + InternalError, RecordAsRequester, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -347,6 +347,10 @@ pub(crate) async fn post( repo.save().await?; + // Attribute the rest of this request (and its log line) to the user that + // just logged in. + user.maybe_record_as_requester(); + PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]); activity_tracker diff --git a/crates/handlers/src/views/logout.rs b/crates/handlers/src/views/logout.rs index 8acb9d6d0..f7e4471a6 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, }; use mas_axum_utils::{ - InternalError, SessionInfoExt, + InternalError, RecordAsRequester, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; @@ -37,6 +37,10 @@ pub(crate) async fn post( if let Some(session) = maybe_session && session.finished_at.is_none() { + // Attribute this request (and its log line) to the user being + // logged out. + session.maybe_record_as_requester(); + activity_tracker .record_browser_session(&clock, &session) .await; diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index a6c699ac6..3c99c644b 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -12,7 +12,7 @@ use axum::{ }; use axum_extra::TypedHeader; use chrono::Duration; -use mas_axum_utils::{InternalError, SessionInfoExt as _, cookies::CookieJar}; +use mas_axum_utils::{InternalError, RecordAsRequester, SessionInfoExt as _, cookies::CookieJar}; use mas_data_model::{BoxClock, BoxRng, SiteConfig}; use mas_matrix::HomeserverConnection; use mas_router::{PostAuthAction, UrlBuilder}; @@ -307,6 +307,9 @@ pub(crate) async fn get( .user() .add(&mut rng, &clock, registration.username) .await?; + // Attribute this request (and its log line) to the user that was just + // registered and logged in. + user.maybe_record_as_requester(); // Also create a browser session which will log the user in let user_session = repo .browser_session() From 9e60371aeb9f46acbcf45fb225ebecbfbcda028e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 4 Jun 2026 12:31:17 +0200 Subject: [PATCH 4/5] mas-cli: log the requester on the request log line Add `requester=...` to the `http.server.response` log line, reading the `Requester` recorded on the request's `LogContext`. --- crates/cli/src/server.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 15114a5d9..917c255d7 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -191,7 +191,12 @@ async fn log_response_middleware( let response = next.run(request).await; - let Some(stats) = LogContext::maybe_with(LogContext::stats) else { + let Some((stats, requester)) = LogContext::maybe_with(|ctx| { + ( + ctx.stats(), + ctx.requester().cloned().map(tracing::field::display), + ) + }) else { tracing::error!("Missing log context for request, this is a bug!"); return response; }; @@ -200,14 +205,17 @@ async fn log_response_middleware( match status_code.as_u16() { 100..=399 => tracing::info!( name: "http.server.response", + { requester = requester }, "\"{method} {path} HTTP/{version}\" {status_code} {user_agent:?} [{stats}]", ), 400..=499 => tracing::warn!( name: "http.server.response", + { requester = requester }, "\"{method} {path} HTTP/{version}\" {status_code} {user_agent:?} [{stats}]", ), 500..=599 => tracing::error!( name: "http.server.response", + { requester = requester }, "\"{method} {path} HTTP/{version}\" {status_code} {user_agent:?} [{stats}]", ), _ => { /* This shouldn't happen */ } From fc798b2831765c7cdb9901c73c273fd6b87d257e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 18 Jun 2026 13:40:43 +0200 Subject: [PATCH 5/5] mas-axum-utils: move the RecordAsRequester doc comment onto its method Addresses review feedback on #5735: the doc describing `maybe_record_as_requester` belonged on the method, not the trait. --- crates/axum-utils/src/log_context.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/axum-utils/src/log_context.rs b/crates/axum-utils/src/log_context.rs index 53d845d47..0728cab70 100644 --- a/crates/axum-utils/src/log_context.rs +++ b/crates/axum-utils/src/log_context.rs @@ -15,11 +15,12 @@ use mas_context::{LogContext, Requester}; use mas_data_model::{BrowserSession, Client, CompatSession, Session, User}; -/// Record `self` as the [`Requester`] on the current [`LogContext`], if any. -/// -/// First writer wins — calling on a request that already has a requester -/// recorded is a no-op. pub trait RecordAsRequester { + /// Record `self` as the [`Requester`] on the current [`LogContext`], if + /// any. + /// + /// First writer wins — calling on a request that already has a requester + /// recorded is a no-op. fn maybe_record_as_requester(&self); }