diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index 05b5b405c..dcd380856 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -7,6 +7,7 @@ use aide::{ axum::ApiRouter, openapi::{OAuth2Flow, OAuth2Flows, OpenApi, SecurityScheme, Server, Tag}, + transform::TransformOpenApi, }; use axum::{ extract::{FromRef, FromRequestParts, State}, @@ -37,6 +38,72 @@ mod v1; use self::call_context::CallContext; use crate::passwords::PasswordManager; +fn finish(t: TransformOpenApi) -> TransformOpenApi { + 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()), + ..Tag::default() + }) + .tag(Tag { + name: "user".to_owned(), + description: Some("Manage users".to_owned()), + ..Tag::default() + }) + .tag(Tag { + name: "user-email".to_owned(), + description: Some("Manage emails associated with users".to_owned()), + ..Tag::default() + }) + .tag(Tag { + name: "user-session".to_owned(), + description: Some("Manage browser sessions of users".to_owned()), + ..Tag::default() + }) + .tag(Tag { + name: "upstream-oauth-link".to_owned(), + description: Some( + "Manage links between local users and identities from upstream OAuth 2.0 providers" + .to_owned(), + ), + ..Default::default() + }) + .security_scheme( + "oauth2", + SecurityScheme::OAuth2 { + flows: OAuth2Flows { + client_credentials: Some(OAuth2Flow::ClientCredentials { + refresh_url: Some(OAuth2TokenEndpoint::PATH.to_owned()), + token_url: OAuth2TokenEndpoint::PATH.to_owned(), + scopes: IndexMap::from([( + "urn:mas:admin".to_owned(), + "Grant access to the admin API".to_owned(), + )]), + }), + authorization_code: Some(OAuth2Flow::AuthorizationCode { + authorization_url: OAuth2AuthorizationEndpoint::PATH.to_owned(), + refresh_url: Some(OAuth2TokenEndpoint::PATH.to_owned()), + token_url: OAuth2TokenEndpoint::PATH.to_owned(), + scopes: IndexMap::from([( + "urn:mas:admin".to_owned(), + "Grant access to the admin API".to_owned(), + )]), + }), + implicit: None, + password: None, + }, + description: None, + extensions: IndexMap::default(), + }, + ) + .security_requirement_scopes("oauth2", ["urn:mas:admin"]) +} + pub fn router() -> (OpenApi, Router) where S: Clone + Send + Sync + 'static, @@ -58,65 +125,7 @@ where let mut api = OpenApi::default(); let router = ApiRouter::::new() .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()), - ..Tag::default() - }) - .tag(Tag { - name: "user".to_owned(), - description: Some("Manage users".to_owned()), - ..Tag::default() - }) - .tag(Tag { - name: "user-email".to_owned(), - description: Some("Manage emails associated with users".to_owned()), - ..Tag::default() - }) - .tag(Tag { - name: "user-session".to_owned(), - description: Some("Manage browser sessions of users".to_owned()), - ..Tag::default() - }) - .security_scheme( - "oauth2", - SecurityScheme::OAuth2 { - flows: OAuth2Flows { - client_credentials: Some(OAuth2Flow::ClientCredentials { - refresh_url: Some(OAuth2TokenEndpoint::PATH.to_owned()), - token_url: OAuth2TokenEndpoint::PATH.to_owned(), - scopes: IndexMap::from([( - "urn:mas:admin".to_owned(), - "Grant access to the admin API".to_owned(), - )]), - }), - authorization_code: Some(OAuth2Flow::AuthorizationCode { - authorization_url: OAuth2AuthorizationEndpoint::PATH.to_owned(), - refresh_url: Some(OAuth2TokenEndpoint::PATH.to_owned()), - token_url: OAuth2TokenEndpoint::PATH.to_owned(), - scopes: IndexMap::from([( - "urn:mas:admin".to_owned(), - "Grant access to the admin API".to_owned(), - )]), - }), - implicit: None, - password: None, - }, - description: None, - extensions: IndexMap::default(), - }, - ) - .security_requirement_scopes("oauth2", ["urn:mas:admin"]) - }); + .finish_api_with(&mut api, finish); let router = router // Serve the OpenAPI spec as JSON diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index 84e993e34..b98770379 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -456,3 +456,81 @@ impl Resource for UserSession { self.id } } + +/// An upstream OAuth 2.0 link +#[derive(Serialize, JsonSchema)] +pub struct UpstreamOAuthLink { + #[serde(skip)] + id: Ulid, + + /// When the object was created + created_at: DateTime, + + /// The ID of the provider + #[schemars(with = "super::schema::Ulid")] + provider_id: Ulid, + + /// The subject of the upstream account, unique per provider + subject: String, + + /// The ID of the user who owns this link, if any + #[schemars(with = "Option")] + user_id: Option, + + /// A human-readable name of the upstream account + human_account_name: Option, +} + +impl Resource for UpstreamOAuthLink { + const KIND: &'static str = "upstream-oauth-link"; + const PATH: &'static str = "/api/admin/v1/upstream-oauth-links"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl From for UpstreamOAuthLink { + fn from(value: mas_data_model::UpstreamOAuthLink) -> Self { + Self { + id: value.id, + created_at: value.created_at, + provider_id: value.provider_id, + subject: value.subject, + user_id: value.user_id, + human_account_name: value.human_account_name, + } + } +} + +impl UpstreamOAuthLink { + /// Samples of upstream OAuth 2.0 links + pub fn samples() -> [Self; 3] { + [ + Self { + id: Ulid::from_bytes([0x01; 16]), + created_at: DateTime::default(), + provider_id: Ulid::from_bytes([0x02; 16]), + subject: "john-42".to_owned(), + user_id: Some(Ulid::from_bytes([0x03; 16])), + human_account_name: Some("john.doe@example.com".to_owned()), + }, + Self { + id: Ulid::from_bytes([0x02; 16]), + created_at: DateTime::default(), + provider_id: Ulid::from_bytes([0x03; 16]), + subject: "jane-123".to_owned(), + user_id: None, + human_account_name: None, + }, + Self { + id: Ulid::from_bytes([0x03; 16]), + created_at: DateTime::default(), + provider_id: Ulid::from_bytes([0x04; 16]), + subject: "bob@social.example.com".to_owned(), + user_id: Some(Ulid::from_bytes([0x05; 16])), + human_account_name: Some("bob".to_owned()), + }, + ] + } +} diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 27273c9a8..a7e22ac1a 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -17,6 +17,7 @@ use crate::passwords::PasswordManager; mod compat_sessions; mod oauth2_sessions; +mod upstream_oauth_links; mod user_emails; mod user_sessions; mod users; @@ -95,4 +96,18 @@ where "/user-sessions/{id}", get_with(self::user_sessions::get, self::user_sessions::get_doc), ) + .api_route( + "/upstream-oauth-links", + get_with( + self::upstream_oauth_links::list, + self::upstream_oauth_links::list_doc, + ), + ) + .api_route( + "/upstream-oauth-links/{id}", + get_with( + self::upstream_oauth_links::get, + self::upstream_oauth_links::get_doc, + ), + ) } diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs new file mode 100644 index 000000000..af7b20bc1 --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/get.rs @@ -0,0 +1,171 @@ +// 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::UpstreamOAuthLink, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Upstream OAuth 2.0 Link 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("getUpstreamOAuthLink") + .summary("Get an upstream OAuth 2.0 link") + .tag("upstream-oauth-link") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = UpstreamOAuthLink::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Upstream OAuth 2.0 link was found") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Upstream OAuth 2.0 link was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.get", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let link = repo + .upstream_oauth_link() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound(*id))?; + + Ok(Json(SingleResponse::new_canonical( + UpstreamOAuthLink::from(link), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + use ulid::Ulid; + + use super::super::test_utils; + 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 provider and a link + let mut repo = state.repository().await.unwrap(); + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + test_utils::oidc_provider_params("provider1"), + ) + .await + .unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let link = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider, + "subject1".to_owned(), + None, + ) + .await + .unwrap(); + repo.upstream_oauth_link() + .associate_to_user(&link, &user) + .await + .unwrap(); + repo.save().await.unwrap(); + + let link_id = link.id; + let request = Request::get(format!("/api/admin/v1/upstream-oauth-links/{link_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": "upstream-oauth-link", + "id": "01FSHN9AG09NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "subject": "subject1", + "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "human_account_name": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG09NMZYX8MFYH578R9" + } + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG09NMZYX8MFYH578R9" + } + } + "###); + } + + #[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 link_id = Ulid::nil(); + let request = Request::get(format!("/api/admin/v1/upstream-oauth-links/{link_id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs new file mode 100644 index 000000000..9ba989f3c --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs @@ -0,0 +1,502 @@ +// 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::{ + extract::{rejection::QueryRejection, Query}, + response::IntoResponse, + Json, +}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_storage::{upstream_oauth2::UpstreamOAuthLinkFilter, Page}; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UpstreamOAuthLink}, + params::Pagination, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "UpstreamOAuthLinkFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Retrieve the items for the given user + #[serde(rename = "filter[user]")] + #[schemars(with = "Option")] + user: Option, + + /// Retrieve the items for the given provider + #[serde(rename = "filter[provider]")] + #[schemars(with = "Option")] + provider: Option, + + /// Retrieve the items with the given subject + #[serde(rename = "filter[subject]")] + subject: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(user) = self.user { + write!(f, "{sep}filter[user]={user}")?; + sep = '&'; + } + + if let Some(provider) = self.provider { + write!(f, "{sep}filter[provider]={provider}")?; + sep = '&'; + } + + if let Some(subject) = &self.subject { + write!(f, "{sep}filter[subject]={subject}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User ID {0} not found")] + UserNotFound(Ulid), + + #[error("Provider ID {0} not found")] + ProviderNotFound(Ulid), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), +} + +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::UserNotFound(_) | Self::ProviderNotFound(_) => StatusCode::NOT_FOUND, + Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listUpstreamOAuthLinks") + .summary("List upstream OAuth 2.0 links") + .description("Retrieve a list of upstream OAuth 2.0 links.") + .tag("upstream-oauth-link") + .response_with::<200, Json>, _>(|t| { + let links = UpstreamOAuthLink::samples(); + let pagination = mas_storage::Pagination::first(links.len()); + let page = Page { + edges: links.into(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of upstream OAuth 2.0 links") + .example(PaginatedResponse::new( + page, + pagination, + 42, + UpstreamOAuthLink::PATH, + )) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User or provider was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.list", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = UpstreamOAuthLink::PATH); + let filter = UpstreamOAuthLinkFilter::default(); + + // Load the user from the filter + let maybe_user = if let Some(user_id) = params.user { + let user = repo + .user() + .lookup(user_id) + .await? + .ok_or(RouteError::UserNotFound(user_id))?; + Some(user) + } else { + None + }; + + let filter = if let Some(user) = &maybe_user { + filter.for_user(user) + } else { + filter + }; + + // Load the provider from the filter + let maybe_provider = if let Some(provider_id) = params.provider { + let provider = repo + .upstream_oauth_provider() + .lookup(provider_id) + .await? + .ok_or(RouteError::ProviderNotFound(provider_id))?; + Some(provider) + } else { + None + }; + + let filter = if let Some(provider) = &maybe_provider { + filter.for_provider(provider) + } else { + filter + }; + + let filter = if let Some(subject) = ¶ms.subject { + filter.for_subject(subject) + } else { + filter + }; + + let page = repo.upstream_oauth_link().list(filter, pagination).await?; + let count = repo.upstream_oauth_link().count(filter).await?; + + Ok(Json(PaginatedResponse::new( + page.map(UpstreamOAuthLink::from), + pagination, + count, + &base, + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + + use super::super::test_utils; + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list(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 users and providers + let mut repo = state.repository().await.unwrap(); + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let bob = repo + .user() + .add(&mut rng, &state.clock, "bob".to_owned()) + .await + .unwrap(); + let provider1 = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + test_utils::oidc_provider_params("acme"), + ) + .await + .unwrap(); + let provider2 = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + test_utils::oidc_provider_params("example"), + ) + .await + .unwrap(); + + // Create some links + let link1 = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider1, + "subject1".to_owned(), + Some("alice@acme".to_owned()), + ) + .await + .unwrap(); + repo.upstream_oauth_link() + .associate_to_user(&link1, &alice) + .await + .unwrap(); + let link2 = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider2, + "subject2".to_owned(), + Some("alice@example".to_owned()), + ) + .await + .unwrap(); + repo.upstream_oauth_link() + .associate_to_user(&link2, &alice) + .await + .unwrap(); + let link3 = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider1, + "subject3".to_owned(), + Some("bob@acme".to_owned()), + ) + .await + .unwrap(); + repo.upstream_oauth_link() + .associate_to_user(&link3, &bob) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::get("/api/admin/v1/upstream-oauth-links") + .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###" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0AQZQP8DX40GD59PW", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject3", + "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "human_account_name": "bob@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4" + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0QHEHKX2JNQ2A2D07", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "subject": "subject2", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@example" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?page[first]=10", + "first": "/api/admin/v1/upstream-oauth-links?page[first]=10", + "last": "/api/admin/v1/upstream-oauth-links?page[last]=10" + } + } + "###); + + // Filter by user ID + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-links?filter[user]={}", + alice.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###" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0AQZQP8DX40GD59PW", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0QHEHKX2JNQ2A2D07", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "subject": "subject2", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@example" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" + } + } + "###); + + // Filter by provider + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-links?filter[provider]={}", + provider1.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###" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0AQZQP8DX40GD59PW", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject3", + "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "human_account_name": "bob@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[last]=10" + } + } + "###); + + // Filter by subject + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-links?filter[subject]={}", + "subject1" + )) + .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###" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0AQZQP8DX40GD59PW", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[last]=10" + } + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs new file mode 100644 index 000000000..f6e35476c --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -0,0 +1,48 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +mod get; +mod list; + +pub use self::{ + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, +}; + +#[cfg(test)] +mod test_utils { + use mas_data_model::{ + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, + }; + use mas_iana::jose::JsonWebSignatureAlg; + use mas_storage::upstream_oauth2::UpstreamOAuthProviderParams; + use oauth2_types::scope::{Scope, OPENID}; + + pub(crate) fn oidc_provider_params(name: &str) -> UpstreamOAuthProviderParams { + UpstreamOAuthProviderParams { + issuer: Some(format!("https://{name}.example.com")), + human_name: Some(name.to_owned()), + brand_name: Some(name.to_owned()), + scope: Scope::from_iter([OPENID]), + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretBasic, + token_endpoint_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + client_id: format!("client_{name}"), + encrypted_client_secret: Some("secret".to_owned()), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::default(), + pkce_mode: UpstreamOAuthProviderPkceMode::default(), + response_mode: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + jwks_uri_override: None, + additional_authorization_parameters: Vec::new(), + } + } +} diff --git a/crates/storage-pg/src/upstream_oauth2/link.rs b/crates/storage-pg/src/upstream_oauth2/link.rs index 8ab46d730..198aae5ad 100644 --- a/crates/storage-pg/src/upstream_oauth2/link.rs +++ b/crates/storage-pg/src/upstream_oauth2/link.rs @@ -101,6 +101,9 @@ impl Filter for UpstreamOAuthLinkFilter<'_> { .take(), )) })) + .add_option(self.subject().map(|subject| { + Expr::col((UpstreamOAuthLinks::Table, UpstreamOAuthLinks::Subject)).eq(subject) + })) } } diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index 342f8f44d..918154491 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -192,6 +192,7 @@ mod tests { let filter = UpstreamOAuthLinkFilter::new() .for_user(&user) .for_provider(&provider) + .for_subject("a-subject") .enabled_providers_only(); let links = repo diff --git a/crates/storage/src/upstream_oauth2/link.rs b/crates/storage/src/upstream_oauth2/link.rs index 18f5963d8..b281da828 100644 --- a/crates/storage/src/upstream_oauth2/link.rs +++ b/crates/storage/src/upstream_oauth2/link.rs @@ -18,6 +18,7 @@ pub struct UpstreamOAuthLinkFilter<'a> { user: Option<&'a User>, provider: Option<&'a UpstreamOAuthProvider>, provider_enabled: Option, + subject: Option<&'a str>, } impl<'a> UpstreamOAuthLinkFilter<'a> { @@ -76,6 +77,19 @@ impl<'a> UpstreamOAuthLinkFilter<'a> { pub const fn provider_enabled(&self) -> Option { self.provider_enabled } + + /// Set the subject filter + #[must_use] + pub const fn for_subject(mut self, subject: &'a str) -> Self { + self.subject = Some(subject); + self + } + + /// Get the subject filter + #[must_use] + pub const fn subject(&self) -> Option<&str> { + self.subject + } } /// An [`UpstreamOAuthLinkRepository`] helps interacting with diff --git a/docs/api/spec.json b/docs/api/spec.json index f8be2a861..258068bb0 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -1758,6 +1758,252 @@ } } } + }, + "/api/admin/v1/upstream-oauth-links": { + "get": { + "tags": [ + "upstream-oauth-link" + ], + "summary": "List upstream OAuth 2.0 links", + "description": "Retrieve a list of upstream OAuth 2.0 links.", + "operationId": "listUpstreamOAuthLinks", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[user]", + "description": "Retrieve the items for the given user", + "schema": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[provider]", + "description": "Retrieve the items for the given provider", + "schema": { + "description": "Retrieve the items for the given provider", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[subject]", + "description": "Retrieve the items with the given subject", + "schema": { + "description": "Retrieve the items with the given subject", + "type": "string", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of upstream OAuth 2.0 links", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_UpstreamOAuthLink" + }, + "example": { + "meta": { + "count": 42 + }, + "data": [ + { + "type": "upstream-oauth-link", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "provider_id": "02081040G2081040G2081040G2", + "subject": "john-42", + "user_id": "030C1G60R30C1G60R30C1G60R3", + "human_account_name": "john.doe@example.com" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01040G2081040G2081040G2081" + } + }, + { + "type": "upstream-oauth-link", + "id": "02081040G2081040G2081040G2", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "provider_id": "030C1G60R30C1G60R30C1G60R3", + "subject": "jane-123", + "user_id": null, + "human_account_name": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/02081040G2081040G2081040G2" + } + }, + { + "type": "upstream-oauth-link", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "provider_id": "040G2081040G2081040G208104", + "subject": "bob@social.example.com", + "user_id": "050M2GA1850M2GA1850M2GA185", + "human_account_name": "bob" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/030C1G60R30C1G60R30C1G60R3" + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?page[first]=3", + "first": "/api/admin/v1/upstream-oauth-links?page[first]=3", + "last": "/api/admin/v1/upstream-oauth-links?page[last]=3", + "next": "/api/admin/v1/upstream-oauth-links?page[after]=030C1G60R30C1G60R30C1G60R3&page[first]=3" + } + } + } + } + }, + "404": { + "description": "User or provider was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/upstream-oauth-links/{id}": { + "get": { + "tags": [ + "upstream-oauth-link" + ], + "summary": "Get an upstream OAuth 2.0 link", + "operationId": "getUpstreamOAuthLink", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Upstream OAuth 2.0 link was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UpstreamOAuthLink" + }, + "example": { + "data": { + "type": "upstream-oauth-link", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "provider_id": "02081040G2081040G2081040G2", + "subject": "john-42", + "user_id": "030C1G60R30C1G60R30C1G60R3", + "human_account_name": "john.doe@example.com" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "Upstream OAuth 2.0 link was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Upstream OAuth 2.0 Link ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } } }, "components": { @@ -2683,6 +2929,130 @@ "$ref": "#/components/schemas/SelfLinks" } } + }, + "UpstreamOAuthLinkFilter": { + "type": "object", + "properties": { + "filter[user]": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[provider]": { + "description": "Retrieve the items for the given provider", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[subject]": { + "description": "Retrieve the items with the given subject", + "type": "string", + "nullable": true + } + } + }, + "PaginatedResponse_for_UpstreamOAuthLink": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "data", + "links", + "meta" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta" + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthLink" + } + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_UpstreamOAuthLink": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/UpstreamOAuthLink" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "UpstreamOAuthLink": { + "description": "An upstream OAuth 2.0 link", + "type": "object", + "required": [ + "created_at", + "provider_id", + "subject" + ], + "properties": { + "created_at": { + "description": "When the object was created", + "type": "string", + "format": "date-time" + }, + "provider_id": { + "description": "The ID of the provider", + "$ref": "#/components/schemas/ULID" + }, + "subject": { + "description": "The subject of the upstream account, unique per provider", + "type": "string" + }, + "user_id": { + "description": "The ID of the user who owns this link, if any", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "human_account_name": { + "description": "A human-readable name of the upstream account", + "type": "string", + "nullable": true + } + } + }, + "SingleResponse_for_UpstreamOAuthLink": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthLink" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } } } }, @@ -2713,6 +3083,10 @@ { "name": "user-session", "description": "Manage browser sessions of users" + }, + { + "name": "upstream-oauth-link", + "description": "Manage links between local users and identities from upstream OAuth 2.0 providers" } ] }