mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-23 13:03:35 +00:00
Record and log the requester on the request log line (#5735)
This commit is contained in:
25 files changed
+467
-15
No files matched your search
Generated
+2
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<F> {
|
||||
#[serde(default)]
|
||||
@@ -62,6 +65,8 @@ impl AccessToken {
|
||||
.await?
|
||||
.ok_or(AuthorizationVerificationError::InvalidToken)?;
|
||||
|
||||
session.maybe_record_as_requester();
|
||||
|
||||
Ok((token, session))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,3 +27,4 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
ulid.workspace = true
|
||||
@@ -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<String>,
|
||||
},
|
||||
|
||||
/// 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<String>) -> 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<Requester>,
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<B>(&self, request: Request<B>) -> Response<String>
|
||||
/// 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<B>(&self, request: Request<B>) -> Response<String>
|
||||
where
|
||||
B: HttpBody<Data = Bytes> + Send + 'static,
|
||||
<B as HttpBody>::Error: std::error::Error + Send + Sync,
|
||||
@@ -366,6 +370,40 @@ impl TestState {
|
||||
Response::from_parts(parts, body)
|
||||
}
|
||||
|
||||
pub async fn request<B>(&self, request: Request<B>) -> Response<String>
|
||||
where
|
||||
B: HttpBody<Data = Bytes> + Send + 'static,
|
||||
<B as HttpBody>::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<B>(
|
||||
&self,
|
||||
request: Request<B>,
|
||||
) -> (Response<String>, Option<String>)
|
||||
where
|
||||
B: HttpBody<Data = Bytes> + Send + 'static,
|
||||
<B as HttpBody>::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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user