From 08421b6fbea2e757189544d2091bf6eaf2266b42 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 9 Nov 2022 10:32:03 +0100 Subject: [PATCH] GraphQL API: query oauth2 sessions and clients --- Cargo.lock | 1 + crates/graphql/Cargo.toml | 1 + crates/graphql/src/model/cursor.rs | 1 + crates/graphql/src/{model.rs => model/mod.rs} | 2 + crates/graphql/src/model/oauth.rs | 98 ++++++++++++++ crates/graphql/src/model/users.rs | 48 ++++++- crates/storage/src/oauth2/client.rs | 50 ++++++- crates/storage/src/oauth2/mod.rs | 126 +++++++++++++++++- 8 files changed, 322 insertions(+), 5 deletions(-) rename crates/graphql/src/{model.rs => model/mod.rs} (92%) create mode 100644 crates/graphql/src/model/oauth.rs diff --git a/Cargo.lock b/Cargo.lock index 8191df16..615b6c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2602,6 +2602,7 @@ dependencies = [ "mas-axum-utils", "mas-data-model", "mas-storage", + "oauth2-types", "serde", "sqlx", "tokio", diff --git a/crates/graphql/Cargo.toml b/crates/graphql/Cargo.toml index 87c2706a..5678b6a6 100644 --- a/crates/graphql/Cargo.toml +++ b/crates/graphql/Cargo.toml @@ -14,6 +14,7 @@ tokio = { version = "1.21.2", features = ["time"] } ulid = "1.0.0" url = "2.3.1" +oauth2-types = { path = "../oauth2-types" } mas-axum-utils = { path = "../axum-utils" } mas-data-model = { path = "../data-model" } mas-storage = { path = "../storage" } diff --git a/crates/graphql/src/model/cursor.rs b/crates/graphql/src/model/cursor.rs index 43893758..14d6b58c 100644 --- a/crates/graphql/src/model/cursor.rs +++ b/crates/graphql/src/model/cursor.rs @@ -22,6 +22,7 @@ pub enum NodeType { UserEmail, BrowserSession, CompatSsoLogin, + OAuth2Session, } #[derive(Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/graphql/src/model.rs b/crates/graphql/src/model/mod.rs similarity index 92% rename from crates/graphql/src/model.rs rename to crates/graphql/src/model/mod.rs index c5d757d7..566c507a 100644 --- a/crates/graphql/src/model.rs +++ b/crates/graphql/src/model/mod.rs @@ -15,10 +15,12 @@ mod browser_sessions; mod compat_sessions; mod cursor; +mod oauth; mod users; pub use self::{ browser_sessions::{Authentication, BrowserSession}, cursor::{Cursor, NodeCursor, NodeType}, + oauth::{OAuth2Client, OAuth2Consent, OAuth2Session}, users::{User, UserEmail}, }; diff --git a/crates/graphql/src/model/oauth.rs b/crates/graphql/src/model/oauth.rs new file mode 100644 index 00000000..a6d38630 --- /dev/null +++ b/crates/graphql/src/model/oauth.rs @@ -0,0 +1,98 @@ +// Copyright 2022 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 async_graphql::{Context, Object, ID}; +use mas_storage::{oauth2::client::lookup_client, PostgresqlBackend}; +use oauth2_types::scope::Scope; +use sqlx::PgPool; +use ulid::Ulid; +use url::Url; + +use super::{BrowserSession, User}; + +pub struct OAuth2Session(pub mas_data_model::Session); + +#[Object] +impl OAuth2Session { + pub async fn id(&self) -> ID { + ID(self.0.data.to_string()) + } + + pub async fn client(&self) -> OAuth2Client { + OAuth2Client(self.0.client.clone()) + } + + pub async fn scope(&self) -> String { + self.0.scope.to_string() + } + + pub async fn browser_session(&self) -> BrowserSession { + BrowserSession(self.0.browser_session.clone()) + } + + pub async fn user(&self) -> User { + User(self.0.browser_session.user.clone()) + } +} + +pub struct OAuth2Client(pub mas_data_model::Client); + +#[Object] +impl OAuth2Client { + pub async fn id(&self) -> ID { + ID(self.0.data.to_string()) + } + + pub async fn client_id(&self) -> &str { + &self.0.client_id + } + + pub async fn client_name(&self) -> Option<&str> { + self.0.client_name.as_deref() + } + + pub async fn client_uri(&self) -> Option<&Url> { + self.0.client_uri.as_ref() + } + + pub async fn tos_uri(&self) -> Option<&Url> { + self.0.tos_uri.as_ref() + } + + pub async fn policy_uri(&self) -> Option<&Url> { + self.0.policy_uri.as_ref() + } + + pub async fn redirect_uris(&self) -> &[Url] { + &self.0.redirect_uris + } +} + +pub struct OAuth2Consent { + scope: Scope, + client_id: Ulid, +} + +#[Object] +impl OAuth2Consent { + pub async fn scope(&self) -> String { + self.scope.to_string() + } + + pub async fn client(&self, ctx: &Context<'_>) -> Result { + let mut conn = ctx.data::()?.acquire().await?; + let client = lookup_client(&mut conn, self.client_id).await?; + Ok(OAuth2Client(client)) + } +} diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index fb552f83..f8a8396b 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -20,7 +20,9 @@ use chrono::{DateTime, Utc}; use mas_storage::PostgresqlBackend; use sqlx::PgPool; -use super::{compat_sessions::CompatSsoLogin, BrowserSession, Cursor, NodeCursor, NodeType}; +use super::{ + compat_sessions::CompatSsoLogin, BrowserSession, Cursor, NodeCursor, NodeType, OAuth2Session, +}; pub struct User(pub mas_data_model::User); @@ -185,6 +187,50 @@ impl User { ) .await } + + async fn oauth2_sessions( + &self, + ctx: &Context<'_>, + after: Option, + before: Option, + first: Option, + last: Option, + ) -> Result, async_graphql::Error> { + let database = ctx.data::()?; + + query( + after, + before, + first, + last, + |after, before, first, last| async move { + let mut conn = database.acquire().await?; + let after_id = after + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::OAuth2Session)) + .transpose()?; + let before_id = before + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::OAuth2Session)) + .transpose()?; + + let (has_previous_page, has_next_page, edges) = + mas_storage::oauth2::get_paginated_user_oauth_sessions( + &mut conn, &self.0, before_id, after_id, first, last, + ) + .await?; + + let mut connection = Connection::new(has_previous_page, has_next_page); + connection.edges.extend(edges.into_iter().map(|s| { + Edge::new( + OpaqueCursor(NodeCursor(NodeType::OAuth2Session, s.data)), + OAuth2Session(s), + ) + })); + + Ok::<_, async_graphql::Error>(connection) + }, + ) + .await + } } pub struct UserEmail(mas_data_model::UserEmail); diff --git a/crates/storage/src/oauth2/client.rs b/crates/storage/src/oauth2/client.rs index 33c00b63..5f6f5015 100644 --- a/crates/storage/src/oauth2/client.rs +++ b/crates/storage/src/oauth2/client.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::string::ToString; +use std::{collections::HashMap, string::ToString}; use mas_data_model::{Client, JwksOrJwksUri}; use mas_iana::{ @@ -250,6 +250,54 @@ impl TryInto> for OAuth2ClientLookup { } } +#[tracing::instrument(skip_all, err)] +pub async fn lookup_clients( + executor: impl PgExecutor<'_>, + ids: impl IntoIterator + Send, +) -> Result>, ClientFetchError> { + let ids: Vec = ids.into_iter().map(Uuid::from).collect(); + let res = sqlx::query_as!( + OAuth2ClientLookup, + r#" + SELECT + c.oauth2_client_id, + c.encrypted_client_secret, + ARRAY( + SELECT redirect_uri + FROM oauth2_client_redirect_uris r + WHERE r.oauth2_client_id = c.oauth2_client_id + ) AS "redirect_uris!", + c.grant_type_authorization_code, + c.grant_type_refresh_token, + c.client_name, + c.logo_uri, + c.client_uri, + c.policy_uri, + c.tos_uri, + c.jwks_uri, + c.jwks, + c.id_token_signed_response_alg, + c.userinfo_signed_response_alg, + c.token_endpoint_auth_method, + c.token_endpoint_auth_signing_alg, + c.initiate_login_uri + FROM oauth2_clients c + + WHERE c.oauth2_client_id = ANY($1::uuid[]) + "#, + &ids, + ) + .fetch_all(executor) + .await?; + + let clients: Result>, _> = res + .into_iter() + .map(|r| r.try_into().map(|c: Client| (c.data, c))) + .collect(); + + clients +} + #[tracing::instrument( skip_all, fields(client.id = %id), diff --git a/crates/storage/src/oauth2/mod.rs b/crates/storage/src/oauth2/mod.rs index 6b5822b1..ddbc9285 100644 --- a/crates/storage/src/oauth2/mod.rs +++ b/crates/storage/src/oauth2/mod.rs @@ -12,11 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -use mas_data_model::Session; -use sqlx::PgExecutor; +use std::collections::{BTreeSet, HashMap}; + +use anyhow::Context; +use mas_data_model::{BrowserSession, Session, User}; +use sqlx::{postgres::PgArguments, Arguments, PgConnection, PgExecutor}; +use tracing::{info_span, Instrument}; +use ulid::Ulid; use uuid::Uuid; -use crate::{Clock, PostgresqlBackend}; +use self::client::lookup_clients; +use crate::{ + pagination::{generate_pagination, process_page}, + user::lookup_active_session, + Clock, PostgresqlBackend, +}; pub mod access_token; pub mod authorization_grant; @@ -56,3 +66,113 @@ pub async fn end_oauth_session( Ok(()) } + +#[derive(sqlx::FromRow)] +struct OAuthSessionLookup { + oauth2_session_id: Uuid, + user_session_id: Uuid, + oauth2_client_id: Uuid, + scope: String, +} + +#[tracing::instrument( + skip_all, + fields( + user.id = %user.data, + user.username = user.username, + ), + err(Display), +)] +pub async fn get_paginated_user_oauth_sessions( + conn: &mut PgConnection, + user: &User, + before: Option, + after: Option, + first: Option, + last: Option, +) -> Result<(bool, bool, Vec>), anyhow::Error> { + let mut query = String::from( + r#" + SELECT + os.oauth2_session_id, + os.user_session_id, + os.oauth2_client_id, + os.scope, + os.created_at, + os.finished_at + FROM oauth2_sessions os + LEFT JOIN user_sessions us + USING (user_session_id) + "#, + ); + + let mut arguments = PgArguments::default(); + + query += " WHERE us.user_id = "; + arguments.add(Uuid::from(user.data)); + arguments.format_placeholder(&mut query)?; + + generate_pagination( + &mut query, + "oauth2_session_id", + &mut arguments, + before, + after, + first, + last, + )?; + + let page: Vec = sqlx::query_as_with(&query, arguments) + .fetch_all(&mut *conn) + .instrument(info_span!( + "Fetch paginated user oauth sessions", + query = query + )) + .await?; + + let (has_previous_page, has_next_page, page) = process_page(page, first, last)?; + + let client_ids: BTreeSet = page + .iter() + .map(|i| Ulid::from(i.oauth2_client_id)) + .collect(); + + let browser_session_ids: BTreeSet = + page.iter().map(|i| Ulid::from(i.user_session_id)).collect(); + + let clients = lookup_clients(&mut *conn, client_ids).await?; + + // TODO: this can generate N queries instead of batching. This is less than + // ideal + let mut browser_sessions: HashMap> = HashMap::new(); + for id in browser_session_ids { + let v = lookup_active_session(&mut *conn, id).await?; + browser_sessions.insert(id, v); + } + + let page: Result, _> = page + .into_iter() + .map(|item| { + let client = clients + .get(&Ulid::from(item.oauth2_client_id)) + .context("client was not fetched")? + .clone(); + + let browser_session = browser_sessions + .get(&Ulid::from(item.user_session_id)) + .context("browser session was not fetched")? + .clone(); + + let scope = item.scope.parse()?; + + anyhow::Ok(Session { + data: Ulid::from(item.oauth2_session_id), + client, + browser_session, + scope, + }) + }) + .collect(); + + Ok((has_previous_page, has_next_page, page?)) +}