Skip to content
This repository has been archived by the owner on Sep 10, 2024. It is now read-only.

OAuth 2.0 sessions list and get admin APIs #3031

Merged
merged 6 commits into from
Aug 7, 2024
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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ version = "0.27.2"
features = ["http1", "http2"]
default-features = false

# Snapshot testing
[workspace.dependencies.insta]
version = "1.39.0"
features = ["yaml", "json"]

# Email sending
[workspace.dependencies.lettre]
version = "0.11.7"
Expand Down
13 changes: 13 additions & 0 deletions crates/data-model/src/oauth2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ impl SessionState {
Self::Finished { .. } => Err(InvalidTransitionError),
}
}

/// Returns the time the session was finished, if any
///
/// Returns `None` if the session is still [`Valid`].
///
/// [`Valid`]: SessionState::Valid
#[must_use]
pub fn finished_at(&self) -> Option<DateTime<Utc>> {
match self {
Self::Valid => None,
Self::Finished { finished_at } => Some(*finished_at),
}
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
Expand Down
2 changes: 1 addition & 1 deletion crates/handlers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ oauth2-types.workspace = true
zxcvbn = "3.1.0"

[dev-dependencies]
insta = "1.39.0"
insta.workspace = true
tracing-subscriber.workspace = true
cookie_store = "0.21.0"
sqlx.workspace = true
5 changes: 5 additions & 0 deletions crates/handlers/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ where
.nest("/api/admin/v1", self::v1::router())
.finish_api_with(&mut api, |t| {
t.title("Matrix Authentication Service admin API")
.tag(Tag {
name: "oauth2-session".to_owned(),
description: Some("Manage OAuth2 sessions".to_owned()),
..Tag::default()
})
.tag(Tag {
name: "user".to_owned(),
description: Some("Manage users".to_owned()),
Expand Down
109 changes: 109 additions & 0 deletions crates/handlers/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use std::net::IpAddr;

use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::Serialize;
Expand Down Expand Up @@ -104,3 +106,110 @@ impl Resource for User {
self.id
}
}

/// A OAuth 2.0 session
#[derive(Serialize, JsonSchema)]
pub struct OAuth2Session {
#[serde(skip)]
id: Ulid,

/// When the object was created
created_at: DateTime<Utc>,

/// When the session was finished
finished_at: Option<DateTime<Utc>>,

/// The ID of the user who owns the session
#[schemars(with = "Option<super::schema::Ulid>")]
user_id: Option<Ulid>,

/// The ID of the browser session which started this session
#[schemars(with = "Option<super::schema::Ulid>")]
user_session_id: Option<Ulid>,

/// The ID of the client which requested this session
#[schemars(with = "super::schema::Ulid")]
client_id: Ulid,

/// The scope granted for this session
scope: String,

/// The user agent string of the client which started this session
user_agent: Option<String>,

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

/// The last IP address used by the session
last_active_ip: Option<IpAddr>,
}

impl From<mas_data_model::Session> for OAuth2Session {
fn from(session: mas_data_model::Session) -> Self {
Self {
id: session.id,
created_at: session.created_at,
finished_at: session.finished_at(),
user_id: session.user_id,
user_session_id: session.user_session_id,
client_id: session.client_id,
scope: session.scope.to_string(),
user_agent: session.user_agent.map(|ua| ua.raw),
last_active_at: session.last_active_at,
last_active_ip: session.last_active_ip,
}
}
}

impl OAuth2Session {
/// Samples of OAuth 2.0 sessions
pub fn samples() -> [Self; 3] {
[
Self {
id: Ulid::from_bytes([0x01; 16]),
created_at: DateTime::default(),
finished_at: None,
user_id: Some(Ulid::from_bytes([0x02; 16])),
user_session_id: Some(Ulid::from_bytes([0x03; 16])),
client_id: Ulid::from_bytes([0x04; 16]),
scope: "openid".to_owned(),
user_agent: Some("Mozilla/5.0".to_owned()),
last_active_at: Some(DateTime::default()),
last_active_ip: Some("127.0.0.1".parse().unwrap()),
},
Self {
id: Ulid::from_bytes([0x02; 16]),
created_at: DateTime::default(),
finished_at: None,
user_id: None,
user_session_id: None,
client_id: Ulid::from_bytes([0x05; 16]),
scope: "urn:mas:admin".to_owned(),
user_agent: None,
last_active_at: None,
last_active_ip: None,
},
Self {
id: Ulid::from_bytes([0x03; 16]),
created_at: DateTime::default(),
finished_at: Some(DateTime::default()),
user_id: Some(Ulid::from_bytes([0x04; 16])),
user_session_id: Some(Ulid::from_bytes([0x05; 16])),
client_id: Ulid::from_bytes([0x06; 16]),
scope: "urn:matrix:org.matrix.msc2967.client:api:*".to_owned(),
user_agent: Some("Mozilla/5.0".to_owned()),
last_active_at: Some(DateTime::default()),
last_active_ip: Some("127.0.0.1".parse().unwrap()),
},
]
}
}

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

fn id(&self) -> Ulid {
self.id
}
}
9 changes: 9 additions & 0 deletions crates/handlers/src/admin/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use mas_storage::BoxRng;
use super::call_context::CallContext;
use crate::passwords::PasswordManager;

mod oauth2_sessions;
mod users;

pub fn router<S>() -> ApiRouter<S>
Expand All @@ -34,6 +35,14 @@ where
CallContext: FromRequestParts<S>,
{
ApiRouter::<S>::new()
.api_route(
"/oauth2-sessions",
get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc),
)
.api_route(
"/oauth2-sessions/:id",
get_with(self::oauth2_sessions::get, self::oauth2_sessions::get_doc),
)
.api_route(
"/users",
get_with(self::users::list, self::users::list_doc)
Expand Down
159 changes: 159 additions & 0 deletions crates/handlers/src/admin/v1/oauth2_sessions/get.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

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

use crate::{
admin::{
call_context::CallContext,
model::OAuth2Session,
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("OAuth 2.0 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("getOAuth2Session")
.summary("Get an OAuth 2.0 session")
.tag("oauth2-session")
.response_with::<200, Json<SingleResponse<OAuth2Session>>, _>(|t| {
let [sample, ..] = OAuth2Session::samples();
let response = SingleResponse::new_canonical(sample);
t.description("OAuth 2.0 session was found")
.example(response)
})
.response_with::<404, RouteError, _>(|t| {
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
t.description("OAuth 2.0 session was not found")
.example(response)
})
}

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

Ok(Json(SingleResponse::new_canonical(OAuth2Session::from(
session,
))))
}

#[cfg(test)]
mod tests {
use hyper::{Request, StatusCode};
use mas_data_model::AccessToken;
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;

// state.token_with_scope did create a session, so we can get it here
let mut repo = state.repository().await.unwrap();
let AccessToken { session_id, .. } = repo
.oauth2_access_token()
.find_by_token(&token)
.await
.unwrap()
.unwrap();
repo.save().await.unwrap();

let request = Request::get(format!("/api/admin/v1/oauth2-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_eq!(body["data"]["type"], "oauth2-session");
insta::assert_json_snapshot!(body, @r###"
{
"data": {
"type": "oauth2-session",
"id": "01FSHN9AG0MKGTBNZ16RDR3PVY",
"attributes": {
"created_at": "2022-01-16T14:40:00Z",
"finished_at": null,
"user_id": null,
"user_session_id": null,
"client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR",
"scope": "urn:mas:admin",
"user_agent": null,
"last_active_at": null,
"last_active_ip": null
},
"links": {
"self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY"
}
},
"links": {
"self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY"
}
}
"###);
}

#[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/oauth2-sessions/{session_id}"))
.bearer(&token)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::NOT_FOUND);
}
}
Loading
Loading