Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/handlers/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ where
.nest("/api/admin/v1", self::v1::router())
.finish_api_with(&mut api, |t| {
t.title("Matrix Authentication Service admin API")
.tag(Tag {
name: "compat-session".to_owned(),
description: Some(
"Manage compatibility sessions from legacy clients".to_owned(),
),
..Tag::default()
})
.tag(Tag {
name: "oauth2-session".to_owned(),
description: Some("Manage OAuth2 sessions".to_owned()),
Expand Down
119 changes: 119 additions & 0 deletions crates/handlers/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
use std::net::IpAddr;

use chrono::{DateTime, Utc};
use mas_data_model::Device;
use schemars::JsonSchema;
use serde::Serialize;
use ulid::Ulid;
use url::Url;

/// A resource, with a type and an ID
pub trait Resource {
Expand Down Expand Up @@ -147,6 +149,123 @@ impl UserEmail {
}
}

/// A compatibility session for legacy clients
#[derive(Serialize, JsonSchema)]
pub struct CompatSession {
#[serde(skip)]
pub id: Ulid,

/// The ID of the user that owns this session
#[schemars(with = "super::schema::Ulid")]
pub user_id: Ulid,

/// The Matrix device ID of this session
#[schemars(with = "super::schema::Device")]
pub device_id: Option<Device>,

/// The ID of the user session that started this session, if any
#[schemars(with = "super::schema::Ulid")]
pub user_session_id: Option<Ulid>,

/// The redirect URI used to login in the client, if it was an SSO login
pub redirect_uri: Option<Url>,

/// The time this session was created
pub created_at: DateTime<Utc>,

/// The user agent string that started this session, if any
pub user_agent: Option<String>,

/// The time this session was last active
pub last_active_at: Option<DateTime<Utc>>,

/// The last IP address recorded for this session
pub last_active_ip: Option<std::net::IpAddr>,

/// The time this session was finished
pub finished_at: Option<DateTime<Utc>>,
}

impl
From<(
mas_data_model::CompatSession,
Option<mas_data_model::CompatSsoLogin>,
)> for CompatSession
{
fn from(
(session, sso_login): (
mas_data_model::CompatSession,
Option<mas_data_model::CompatSsoLogin>,
),
) -> Self {
let finished_at = session.finished_at();
Self {
id: session.id,
user_id: session.user_id,
device_id: session.device,
user_session_id: session.user_session_id,
redirect_uri: sso_login.map(|sso| sso.redirect_uri),
created_at: session.created_at,
user_agent: session.user_agent.map(|ua| ua.raw),
last_active_at: session.last_active_at,
last_active_ip: session.last_active_ip,
finished_at,
}
}
}

impl Resource for CompatSession {
const KIND: &'static str = "compat-session";
const PATH: &'static str = "/api/admin/v1/compat-sessions";

fn id(&self) -> Ulid {
self.id
}
}

impl CompatSession {
pub fn samples() -> [Self; 3] {
[
Self {
id: Ulid::from_bytes([0x01; 16]),
user_id: Ulid::from_bytes([0x01; 16]),
device_id: Some("AABBCCDDEE".to_owned().try_into().unwrap()),
user_session_id: Some(Ulid::from_bytes([0x11; 16])),
redirect_uri: Some("https://example.com/redirect".parse().unwrap()),
created_at: DateTime::default(),
user_agent: Some("Mozilla/5.0".to_owned()),
last_active_at: Some(DateTime::default()),
last_active_ip: Some([1, 2, 3, 4].into()),
finished_at: None,
},
Self {
id: Ulid::from_bytes([0x02; 16]),
user_id: Ulid::from_bytes([0x01; 16]),
device_id: Some("FFGGHHIIJJ".to_owned().try_into().unwrap()),
user_session_id: Some(Ulid::from_bytes([0x12; 16])),
redirect_uri: None,
created_at: DateTime::default(),
user_agent: Some("Mozilla/5.0".to_owned()),
last_active_at: Some(DateTime::default()),
last_active_ip: Some([1, 2, 3, 4].into()),
finished_at: Some(DateTime::default()),
},
Self {
id: Ulid::from_bytes([0x03; 16]),
user_id: Ulid::from_bytes([0x01; 16]),
device_id: None,
user_session_id: None,
redirect_uri: None,
created_at: DateTime::default(),
user_agent: None,
last_active_at: None,
last_active_ip: None,
finished_at: None,
},
]
}
}

/// A OAuth 2.0 session
#[derive(Serialize, JsonSchema)]
pub struct OAuth2Session {
Expand Down
31 changes: 31 additions & 0 deletions crates/handlers/src/admin/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,34 @@ impl JsonSchema for Ulid {
.into()
}
}

/// A type to use for schema definitions of device IDs
///
/// Use with `#[schemars(with = "crate::admin::schema::Device")]`
pub struct Device;

impl JsonSchema for Device {
fn schema_name() -> String {
"DeviceID".to_owned()
}

fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
SchemaObject {
instance_type: Some(InstanceType::String.into()),

metadata: Some(Box::new(Metadata {
title: Some("Device ID".into()),
examples: vec!["AABBCCDDEE".into(), "FFGGHHIIJJ".into()],
..Metadata::default()
})),

string: Some(Box::new(StringValidation {
pattern: Some(r"^[A-Za-z0-9._~!$&'()*+,;=:&/-]+$".into()),
..StringValidation::default()
})),

..SchemaObject::default()
}
.into()
}
}
159 changes: 159 additions & 0 deletions crates/handlers/src/admin/v1/compat_sessions/get.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.

use aide::{transform::TransformOperation, OperationIo};
use axum::{response::IntoResponse, Json};
use hyper::StatusCode;
use ulid::Ulid;

use crate::{
admin::{
call_context::CallContext,
model::CompatSession,
params::UlidPathParam,
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};

#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),

#[error("Compatibility session ID {0} not found")]
NotFound(Ulid),
}

impl_from_error_for_route!(mas_storage::RepositoryError);

impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
let error = ErrorResponse::from_error(&self);
let status = match self {
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound(_) => StatusCode::NOT_FOUND,
};
(status, Json(error)).into_response()
}
}

pub fn doc(operation: TransformOperation) -> TransformOperation {
operation
.id("getCompatSession")
.summary("Get a compatibility session")
.tag("compat-session")
.response_with::<200, Json<SingleResponse<CompatSession>>, _>(|t| {
let [sample, ..] = CompatSession::samples();
let response = SingleResponse::new_canonical(sample);
t.description("Compatibility session was found")
.example(response)
})
.response_with::<404, RouteError, _>(|t| {
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
t.description("Compatibility session was not found")
.example(response)
})
}

#[tracing::instrument(name = "handler.admin.v1.compat_sessions.get", skip_all, err)]
pub async fn handler(
CallContext { mut repo, .. }: CallContext,
id: UlidPathParam,
) -> Result<Json<SingleResponse<CompatSession>>, RouteError> {
let session = repo
.compat_session()
.lookup(*id)
.await?
.ok_or(RouteError::NotFound(*id))?;

let sso_login = repo.compat_sso_login().find_for_session(&session).await?;

Ok(Json(SingleResponse::new_canonical(CompatSession::from((
session, sso_login,
)))))
}

#[cfg(test)]
mod tests {
use hyper::{Request, StatusCode};
use insta::assert_json_snapshot;
use mas_data_model::Device;
use sqlx::PgPool;
use ulid::Ulid;

use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_get(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;
let mut rng = state.rng();

// Provision a user and a compat session
let mut repo = state.repository().await.unwrap();
let user = repo
.user()
.add(&mut rng, &state.clock, "alice".to_owned())
.await
.unwrap();
let device = Device::generate(&mut rng);
let session = repo
.compat_session()
.add(&mut rng, &state.clock, &user, device, None, false)
.await
.unwrap();
repo.save().await.unwrap();

let session_id = session.id;
let request = Request::get(format!("/api/admin/v1/compat-sessions/{session_id}"))
.bearer(&token)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_json_snapshot!(body, @r###"
{
"data": {
"type": "compat-session",
"id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
"attributes": {
"user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
"device_id": "TpLoieH5Ie",
"user_session_id": null,
"redirect_uri": null,
"created_at": "2022-01-16T14:40:00Z",
"user_agent": null,
"last_active_at": null,
"last_active_ip": null,
"finished_at": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07"
}
},
"links": {
"self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07"
}
}
"###);
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_not_found(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;

let session_id = Ulid::nil();
let request = Request::get(format!("/api/admin/v1/compat-sessions/{session_id}"))
.bearer(&token)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::NOT_FOUND);
}
}
Loading
Loading