1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

Make the GraphQL interface accessible for OAuth clients

This commit is contained in:
Quentin Gliech
2023-08-10 15:05:44 +02:00
parent 9c7f6c2d4e
commit 4ef3bcf336
8 changed files with 203 additions and 20 deletions

View File

@ -26,8 +26,10 @@
clippy::unused_async clippy::unused_async
)] )]
use anyhow::Context;
use async_graphql::EmptySubscription; use async_graphql::EmptySubscription;
use mas_data_model::{BrowserSession, User}; use mas_data_model::{BrowserSession, Session, User};
use ulid::Ulid;
mod model; mod model;
mod mutations; mod mutations;
@ -60,18 +62,51 @@ pub enum Requester {
/// The requester is a browser session, stored in a cookie. /// The requester is a browser session, stored in a cookie.
BrowserSession(BrowserSession), BrowserSession(BrowserSession),
/// The requester is a OAuth2 session, with an access token.
OAuth2Session(Session, User),
} }
impl Requester { impl Requester {
fn browser_session(&self) -> Option<&BrowserSession> { fn browser_session(&self) -> Option<&BrowserSession> {
match self { match self {
Self::BrowserSession(session) => Some(session), Self::BrowserSession(session) => Some(session),
Self::Anonymous => None, Self::OAuth2Session(_, _) | Self::Anonymous => None,
} }
} }
fn user(&self) -> Option<&User> { fn user(&self) -> Option<&User> {
self.browser_session().map(|session| &session.user) match self {
Self::BrowserSession(session) => Some(&session.user),
Self::OAuth2Session(_session, user) => Some(user),
Self::Anonymous => None,
}
}
fn ensure_owner_or_admin(&self, user_id: Ulid) -> Result<(), async_graphql::Error> {
// If the requester is an admin, they can do anything.
if self.is_admin() {
return Ok(());
}
// Else check that they are the owner.
let user = self.user().context("Unauthorized")?;
if user.id == user_id {
Ok(())
} else {
Err(async_graphql::Error::new("Unauthorized"))
}
}
fn is_admin(&self) -> bool {
match self {
Self::OAuth2Session(session, _user) => {
// TODO: is this the right scope?
// This has to be in sync with the policy
session.scope.contains("urn:mas:admin")
}
Self::BrowserSession(_) | Self::Anonymous => false,
}
} }
} }

View File

@ -14,7 +14,7 @@
use async_graphql::Union; use async_graphql::Union;
use crate::model::{BrowserSession, User}; use crate::model::{BrowserSession, OAuth2Session, User};
mod anonymous; mod anonymous;
pub use self::anonymous::Anonymous; pub use self::anonymous::Anonymous;
@ -40,6 +40,7 @@ impl Viewer {
#[derive(Union)] #[derive(Union)]
pub enum ViewerSession { pub enum ViewerSession {
BrowserSession(BrowserSession), BrowserSession(BrowserSession),
OAuth2Session(OAuth2Session),
Anonymous(Anonymous), Anonymous(Anonymous),
} }
@ -48,6 +49,10 @@ impl ViewerSession {
Self::BrowserSession(BrowserSession(session)) Self::BrowserSession(BrowserSession(session))
} }
pub fn oauth2_session(session: mas_data_model::Session) -> Self {
Self::OAuth2Session(OAuth2Session(session))
}
pub fn anonymous() -> Self { pub fn anonymous() -> Self {
Self::Anonymous(Anonymous) Self::Anonymous(Anonymous)
} }

View File

@ -88,6 +88,13 @@ impl BaseQuery {
if current_user.id == id { if current_user.id == id {
Ok(Some(User(current_user.clone()))) Ok(Some(User(current_user.clone())))
} else if requester.is_admin() {
// An admin can fetch any user, not just themselves
let state = ctx.state();
let mut repo = state.repository().await?;
let user = repo.user().lookup(id).await?;
repo.cancel().await?;
Ok(user.map(User))
} else { } else {
Ok(None) Ok(None)
} }
@ -113,7 +120,7 @@ impl BaseQuery {
repo.cancel().await?; repo.cancel().await?;
let ret = browser_session.and_then(|browser_session| { let ret = browser_session.and_then(|browser_session| {
if browser_session.user.id == current_user.id { if browser_session.user.id == current_user.id || requester.is_admin() {
Some(BrowserSession(browser_session)) Some(BrowserSession(browser_session))
} else { } else {
None None
@ -142,7 +149,7 @@ impl BaseQuery {
.user_email() .user_email()
.lookup(id) .lookup(id)
.await? .await?
.filter(|e| e.user_id == current_user.id); .filter(|e| e.user_id == current_user.id || requester.is_admin());
repo.cancel().await?; repo.cancel().await?;

View File

@ -49,7 +49,8 @@ impl UpstreamOAuthQuery {
let link = repo.upstream_oauth_link().lookup(id).await?; let link = repo.upstream_oauth_link().lookup(id).await?;
// Ensure that the link belongs to the current user // Ensure that the link belongs to the current user
let link = link.filter(|link| link.user_id == Some(current_user.id)); let link =
link.filter(|link| link.user_id == Some(current_user.id) || requester.is_admin());
Ok(link.map(UpstreamOAuth2Link::new)) Ok(link.map(UpstreamOAuth2Link::new))
} }

View File

@ -31,6 +31,7 @@ impl ViewerQuery {
match requester { match requester {
Requester::BrowserSession(session) => Viewer::user(session.user.clone()), Requester::BrowserSession(session) => Viewer::user(session.user.clone()),
Requester::OAuth2Session(_session, user) => Viewer::user(user.clone()),
Requester::Anonymous => Viewer::anonymous(), Requester::Anonymous => Viewer::anonymous(),
} }
} }
@ -41,6 +42,9 @@ impl ViewerQuery {
match requester { match requester {
Requester::BrowserSession(session) => ViewerSession::browser_session(session.clone()), Requester::BrowserSession(session) => ViewerSession::browser_session(session.clone()),
Requester::OAuth2Session(session, _user) => {
ViewerSession::oauth2_session(session.clone())
}
Requester::Anonymous => ViewerSession::anonymous(), Requester::Anonymous => ViewerSession::anonymous(),
} }
} }

View File

@ -21,24 +21,29 @@ use async_graphql::{
use axum::{ use axum::{
async_trait, async_trait,
extract::{BodyStream, RawQuery, State}, extract::{BodyStream, RawQuery, State},
response::{Html, IntoResponse}, http::StatusCode,
response::{Html, IntoResponse, Response},
Json, TypedHeader, Json, TypedHeader,
}; };
use axum_extra::extract::PrivateCookieJar; use axum_extra::extract::PrivateCookieJar;
use futures_util::TryStreamExt; use futures_util::TryStreamExt;
use headers::{ContentType, HeaderValue}; use headers::{authorization::Bearer, Authorization, ContentType, HeaderValue};
use hyper::header::CACHE_CONTROL; use hyper::header::CACHE_CONTROL;
use mas_axum_utils::{FancyError, SessionInfoExt}; use mas_axum_utils::{FancyError, SessionInfo, SessionInfoExt};
use mas_graphql::{Requester, Schema}; use mas_graphql::{Requester, Schema};
use mas_keystore::Encrypter; use mas_keystore::Encrypter;
use mas_matrix::HomeserverConnection; use mas_matrix::HomeserverConnection;
use mas_storage::{BoxClock, BoxRepository, BoxRng, Repository, RepositoryError, SystemClock}; use mas_storage::{
BoxClock, BoxRepository, BoxRng, Clock, Repository, RepositoryError, SystemClock,
};
use mas_storage_pg::PgRepository; use mas_storage_pg::PgRepository;
use rand::{thread_rng, SeedableRng}; use rand::{thread_rng, SeedableRng};
use rand_chacha::ChaChaRng; use rand_chacha::ChaChaRng;
use sqlx::PgPool; use sqlx::PgPool;
use tracing::{info_span, Instrument}; use tracing::{info_span, Instrument};
use crate::impl_from_error_for_route;
struct GraphQLState { struct GraphQLState {
pool: PgPool, pool: PgPool,
homeserver_connection: Arc<dyn HomeserverConnection<Error = anyhow::Error>>, homeserver_connection: Arc<dyn HomeserverConnection<Error = anyhow::Error>>,
@ -107,17 +112,129 @@ fn span_for_graphql_request(request: &async_graphql::Request) -> tracing::Span {
span span
} }
#[derive(thiserror::Error, Debug)]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("Loading of some database objects failed")]
LoadFailed,
#[error("Invalid access token")]
InvalidToken,
#[error("Missing scope")]
MissingScope,
#[error(transparent)]
ParseRequest(#[from] async_graphql::ParseRequestError),
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl IntoResponse for RouteError {
fn into_response(self) -> Response {
sentry::capture_error(&self);
match self {
e @ (Self::Internal(_) | Self::LoadFailed) => {
let error = async_graphql::Error::new_with_source(e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"errors": [error]})),
)
.into_response()
}
Self::InvalidToken => {
let error = async_graphql::Error::new("Invalid token");
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"errors": [error]})),
)
.into_response()
}
Self::MissingScope => {
let error = async_graphql::Error::new("Missing urn:mas:graphql:* scope");
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"errors": [error]})),
)
.into_response()
}
Self::ParseRequest(e) => {
let error = async_graphql::Error::new_with_source(e);
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"errors": [error]})),
)
.into_response()
}
}
}
}
async fn get_requester(
clock: &impl Clock,
mut repo: BoxRepository,
session_info: SessionInfo,
token: Option<&str>,
) -> Result<Requester, RouteError> {
let requester = if let Some(token) = token {
let token = repo
.oauth2_access_token()
.find_by_token(token)
.await?
.ok_or(RouteError::InvalidToken)?;
let session = repo
.oauth2_session()
.lookup(token.session_id)
.await?
.ok_or(RouteError::LoadFailed)?;
// XXX: The user_id should really be directly on the OAuth session
let browser_session = repo
.browser_session()
.lookup(session.user_session_id)
.await?
.ok_or(RouteError::LoadFailed)?;
let user = browser_session.user;
if !token.is_valid(clock.now()) || !session.is_valid() || !user.is_valid() {
return Err(RouteError::InvalidToken);
}
if !session.scope.contains("urn:mas:graphql:*") {
return Err(RouteError::MissingScope);
}
Requester::OAuth2Session(session, user)
} else {
let maybe_session = session_info.load_session(&mut repo).await?;
Requester::from(maybe_session)
};
repo.cancel().await?;
Ok(requester)
}
pub async fn post( pub async fn post(
State(schema): State<Schema>, State(schema): State<Schema>,
mut repo: BoxRepository, clock: BoxClock,
repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
content_type: Option<TypedHeader<ContentType>>, content_type: Option<TypedHeader<ContentType>>,
authorization: Option<TypedHeader<Authorization<Bearer>>>,
body: BodyStream, body: BodyStream,
) -> Result<impl IntoResponse, FancyError> { ) -> Result<impl IntoResponse, RouteError> {
let token = authorization
.as_ref()
.map(|TypedHeader(Authorization(bearer))| bearer.token());
let (session_info, _cookie_jar) = cookie_jar.session_info(); let (session_info, _cookie_jar) = cookie_jar.session_info();
let maybe_session = session_info.load_session(&mut repo).await?; let requester = get_requester(&clock, repo, session_info, token).await?;
let requester = Requester::from(maybe_session);
repo.cancel().await?;
let content_type = content_type.map(|TypedHeader(h)| h.to_string()); let content_type = content_type.map(|TypedHeader(h)| h.to_string());
@ -146,14 +263,17 @@ pub async fn post(
pub async fn get( pub async fn get(
State(schema): State<Schema>, State(schema): State<Schema>,
mut repo: BoxRepository, clock: BoxClock,
repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
authorization: Option<TypedHeader<Authorization<Bearer>>>,
RawQuery(query): RawQuery, RawQuery(query): RawQuery,
) -> Result<impl IntoResponse, FancyError> { ) -> Result<impl IntoResponse, FancyError> {
let token = authorization
.as_ref()
.map(|TypedHeader(Authorization(bearer))| bearer.token());
let (session_info, _cookie_jar) = cookie_jar.session_info(); let (session_info, _cookie_jar) = cookie_jar.session_info();
let maybe_session = session_info.load_session(&mut repo).await?; let requester = get_requester(&clock, repo, session_info, token).await?;
let requester = Requester::from(maybe_session);
repo.cancel().await?;
let request = let request =
async_graphql::http::parse_query_string(&query.unwrap_or_default())?.data(requester); async_graphql::http::parse_query_string(&query.unwrap_or_default())?.data(requester);

View File

@ -108,6 +108,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
mas_graphql::Schema: FromRef<S>, mas_graphql::Schema: FromRef<S>,
BoxRepository: FromRequestParts<S>, BoxRepository: FromRequestParts<S>,
BoxClock: FromRequestParts<S>,
Encrypter: FromRef<S>, Encrypter: FromRef<S>,
{ {
let mut router = Router::new().route( let mut router = Router::new().route(

View File

@ -15,11 +15,21 @@ allowed_scope("openid") = true
allowed_scope("email") = true allowed_scope("email") = true
# This grants access to Synapse's admin API endpoints
allowed_scope("urn:synapse:admin:*") { allowed_scope("urn:synapse:admin:*") {
some user in data.admin_users some user in data.admin_users
input.user.username == user input.user.username == user
} }
# This grants access to the /graphql API endpoint
allowed_scope("urn:mas:graphql:*") = true
# This makes it possible to query and do anything in the GraphQL API as an admin
allowed_scope("urn:mas:admin") {
some user in data.admin_users
input.user.username == user
}
allowed_scope(scope) { allowed_scope(scope) {
regex.match("urn:matrix:org.matrix.msc2967.client:device:[A-Za-z0-9-]{10,}", scope) regex.match("urn:matrix:org.matrix.msc2967.client:device:[A-Za-z0-9-]{10,}", scope)
} }