diff --git a/Cargo.lock b/Cargo.lock index 13ba9ea05..709a5ed99 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", @@ -3244,6 +3245,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "ulid", ] [[package]] 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..0728cab70 --- /dev/null +++ b/crates/axum-utils/src/log_context.rs @@ -0,0 +1,62 @@ +// 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}; + +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); +} + +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)) } } diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index bba18b527..34a50c831 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -217,7 +217,12 @@ async fn log_response_middleware( .and_then(|operation| operation.operation_name.as_deref()) .map(tracing::field::display); - 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; }; @@ -227,6 +232,7 @@ async fn log_response_middleware( 100..=399 => tracing::info!( name: "http.server.response", { + requester = requester, client.address = client_ip, graphql.operation.type = graphql_operation_type, graphql.operation.name = graphql_operation_name, @@ -236,6 +242,7 @@ async fn log_response_middleware( 400..=499 => tracing::warn!( name: "http.server.response", { + requester = requester, client.address = client_ip, graphql.operation.type = graphql_operation_type, graphql.operation.name = graphql_operation_name, @@ -245,6 +252,7 @@ async fn log_response_middleware( 500..=599 => tracing::error!( name: "http.server.response", { + requester = requester, client.address = client_ip, graphql.operation.type = graphql_operation_type, graphql.operation.name = graphql_operation_name, 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 { 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 ee7528c33..efa8358ae 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 the client IP injected on the request (as the IP-detection /// middleware would in production) is recorded against the session by the /// activity tracker. 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 c22a1010d..44503aa9b 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -28,7 +28,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, @@ -356,6 +357,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 7c26fe768..e93cad8ba 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 c6a47a68b..b24a22889 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -30,6 +30,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; @@ -331,7 +332,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, @@ -366,6 +370,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()