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

data-model: Make the user_id optional in the OAuth 2.0 sessions

This commit is contained in:
Quentin Gliech
2023-09-04 16:32:43 +02:00
parent 3691090757
commit 7e247830c9
16 changed files with 133 additions and 85 deletions

View File

@ -66,7 +66,7 @@ pub struct Session {
pub id: Ulid, pub id: Ulid,
pub state: SessionState, pub state: SessionState,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub user_id: Ulid, pub user_id: Option<Ulid>,
pub user_session_id: Option<Ulid>, pub user_session_id: Option<Ulid>,
pub client_id: Ulid, pub client_id: Ulid,
pub scope: Scope, pub scope: Scope,

View File

@ -63,7 +63,7 @@ pub enum Requester {
BrowserSession(BrowserSession), BrowserSession(BrowserSession),
/// The requester is a OAuth2 session, with an access token. /// The requester is a OAuth2 session, with an access token.
OAuth2Session(Session, User), OAuth2Session(Session, Option<User>),
} }
trait OwnerId { trait OwnerId {
@ -90,7 +90,7 @@ impl OwnerId for mas_data_model::UserEmail {
impl OwnerId for Session { impl OwnerId for Session {
fn owner_id(&self) -> Option<Ulid> { fn owner_id(&self) -> Option<Ulid> {
Some(self.user_id) self.user_id
} }
} }
@ -126,7 +126,7 @@ impl Requester {
fn user(&self) -> Option<&User> { fn user(&self) -> Option<&User> {
match self { match self {
Self::BrowserSession(session) => Some(&session.user), Self::BrowserSession(session) => Some(&session.user),
Self::OAuth2Session(_session, user) => Some(user), Self::OAuth2Session(_session, user) => user.as_ref(),
Self::Anonymous => None, Self::Anonymous => None,
} }
} }

View File

@ -22,7 +22,7 @@ use ulid::Ulid;
use url::Url; use url::Url;
use super::{BrowserSession, NodeType, User}; use super::{BrowserSession, NodeType, User};
use crate::state::ContextExt; use crate::{state::ContextExt, UserId};
/// The state of an OAuth 2.0 session. /// The state of an OAuth 2.0 session.
#[derive(Enum, Copy, Clone, Eq, PartialEq)] #[derive(Enum, Copy, Clone, Eq, PartialEq)]
@ -108,17 +108,25 @@ impl OAuth2Session {
} }
/// User authorized for this session. /// User authorized for this session.
pub async fn user(&self, ctx: &Context<'_>) -> Result<User, async_graphql::Error> { pub async fn user(&self, ctx: &Context<'_>) -> Result<Option<User>, async_graphql::Error> {
let state = ctx.state(); let state = ctx.state();
let Some(user_id) = self.0.user_id else {
return Ok(None);
};
if !ctx.requester().is_owner_or_admin(&UserId(user_id)) {
return Err(async_graphql::Error::new("Unauthorized"));
}
let mut repo = state.repository().await?; let mut repo = state.repository().await?;
let user = repo let user = repo
.user() .user()
.lookup(self.0.user_id) .lookup(user_id)
.await? .await?
.context("Could not load user")?; .context("Could not load user")?;
repo.cancel().await?; repo.cancel().await?;
Ok(User(user)) Ok(Some(User(user)))
} }
} }

View File

@ -96,9 +96,10 @@ impl OAuth2SessionMutations {
return Ok(EndOAuth2SessionPayload::NotFound); return Ok(EndOAuth2SessionPayload::NotFound);
} }
if let Some(user_id) = session.user_id {
let user = repo let user = repo
.user() .user()
.lookup(session.user_id) .lookup(user_id)
.await? .await?
.context("Could not load user")?; .context("Could not load user")?;
@ -116,6 +117,7 @@ impl OAuth2SessionMutations {
.await?; .await?;
} }
} }
}
let session = repo.oauth2_session().finish(&clock, session).await?; let session = repo.oauth2_session().finish(&clock, session).await?;

View File

@ -31,8 +31,8 @@ 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::OAuth2Session(_session, Some(user)) => Viewer::user(user.clone()),
Requester::Anonymous => Viewer::anonymous(), Requester::OAuth2Session(_, None) | Requester::Anonymous => Viewer::anonymous(),
} }
} }

View File

@ -29,6 +29,7 @@ use futures_util::TryStreamExt;
use headers::{authorization::Bearer, Authorization, ContentType, HeaderValue}; use headers::{authorization::Bearer, Authorization, ContentType, HeaderValue};
use hyper::header::CACHE_CONTROL; use hyper::header::CACHE_CONTROL;
use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfo, SessionInfoExt}; use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfo, SessionInfoExt};
use mas_data_model::User;
use mas_graphql::{Requester, Schema}; use mas_graphql::{Requester, Schema};
use mas_matrix::HomeserverConnection; use mas_matrix::HomeserverConnection;
use mas_policy::{InstantiateError, Policy, PolicyFactory}; use mas_policy::{InstantiateError, Policy, PolicyFactory};
@ -204,13 +205,22 @@ async fn get_requester(
.await? .await?
.ok_or(RouteError::LoadFailed)?; .ok_or(RouteError::LoadFailed)?;
// Load the user if there is one
let user = if let Some(user_id) = session.user_id {
let user = repo let user = repo
.user() .user()
.lookup(session.user_id) .lookup(user_id)
.await? .await?
.ok_or(RouteError::LoadFailed)?; .ok_or(RouteError::LoadFailed)?;
Some(user)
} else {
None
};
if !token.is_valid(clock.now()) || !session.is_valid() || !user.is_valid() { // If there is a user for this session, check that it is not locked
let user_valid = user.as_ref().map_or(false, User::is_valid);
if !token.is_valid(clock.now()) || !session.is_valid() || !user_valid {
return Err(RouteError::InvalidToken); return Err(RouteError::InvalidToken);
} }

View File

@ -184,24 +184,32 @@ pub(crate) async fn post(
// XXX: is that the right error to bubble up? // XXX: is that the right error to bubble up?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;
// The session might not have a user on it (for Client Credentials grants for
// example), so we're optionally fetching the user
let (sub, username) = if let Some(user_id) = session.user_id {
let user = repo let user = repo
.user() .user()
.lookup(session.user_id) .lookup(user_id)
.await? .await?
// Fail if the user is not valid (e.g. locked)
.filter(User::is_valid) .filter(User::is_valid)
// XXX: is that the right error to bubble up?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;
(Some(user.sub), Some(user.username))
} else {
(None, None)
};
IntrospectionResponse { IntrospectionResponse {
active: true, active: true,
scope: Some(session.scope), scope: Some(session.scope),
client_id: Some(session.client_id.to_string()), client_id: Some(session.client_id.to_string()),
username: Some(user.username), username,
token_type: Some(OAuthTokenTypeHint::AccessToken), token_type: Some(OAuthTokenTypeHint::AccessToken),
exp: Some(token.expires_at), exp: Some(token.expires_at),
iat: Some(token.created_at), iat: Some(token.created_at),
nbf: Some(token.created_at), nbf: Some(token.created_at),
sub: Some(user.sub), sub,
aud: None, aud: None,
iss: None, iss: None,
jti: Some(token.jti()), jti: Some(token.jti()),
@ -224,24 +232,32 @@ pub(crate) async fn post(
// XXX: is that the right error to bubble up? // XXX: is that the right error to bubble up?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;
// The session might not have a user on it (for Client Credentials grants for
// example), so we're optionally fetching the user
let (sub, username) = if let Some(user_id) = session.user_id {
let user = repo let user = repo
.user() .user()
.lookup(session.user_id) .lookup(user_id)
.await? .await?
// Fail if the user is not valid (e.g. locked)
.filter(User::is_valid) .filter(User::is_valid)
// XXX: is that the right error to bubble up?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;
(Some(user.sub), Some(user.username))
} else {
(None, None)
};
IntrospectionResponse { IntrospectionResponse {
active: true, active: true,
scope: Some(session.scope), scope: Some(session.scope),
client_id: Some(session.client_id.to_string()), client_id: Some(session.client_id.to_string()),
username: Some(user.username), username,
token_type: Some(OAuthTokenTypeHint::RefreshToken), token_type: Some(OAuthTokenTypeHint::RefreshToken),
exp: None, exp: None,
iat: Some(token.created_at), iat: Some(token.created_at),
nbf: Some(token.created_at), nbf: Some(token.created_at),
sub: Some(user.sub), sub,
aud: None, aud: None,
iss: None, iss: None,
jti: Some(token.jti()), jti: Some(token.jti()),

View File

@ -199,10 +199,13 @@ pub(crate) async fn post(
return Err(RouteError::UnauthorizedClient); return Err(RouteError::UnauthorizedClient);
} }
// If the session is associated with a user, make sure we schedule a device
// deletion job for all the devices associated with the session.
if let Some(user_id) = session.user_id {
// Fetch the user // Fetch the user
let user = repo let user = repo
.user() .user()
.lookup(session.user_id) .lookup(user_id)
.await? .await?
.ok_or(RouteError::UnknownToken)?; .ok_or(RouteError::UnknownToken)?;
@ -220,6 +223,7 @@ pub(crate) async fn post(
.await?; .await?;
} }
} }
}
// Now that we checked everything, we can end the session. // Now that we checked everything, we can end the session.
repo.oauth2_session().finish(&clock, session).await?; repo.oauth2_session().finish(&clock, session).await?;

View File

@ -65,6 +65,9 @@ pub enum RouteError {
#[from] AuthorizationVerificationError<mas_storage::RepositoryError>, #[from] AuthorizationVerificationError<mas_storage::RepositoryError>,
), ),
#[error("session is not allowed to access the userinfo endpoint")]
Unauthorized,
#[error("no suitable key found for signing")] #[error("no suitable key found for signing")]
InvalidSigningKey, InvalidSigningKey,
@ -86,7 +89,9 @@ impl IntoResponse for RouteError {
Self::Internal(_) | Self::InvalidSigningKey | Self::NoSuchClient | Self::NoSuchUser => { Self::Internal(_) | Self::InvalidSigningKey | Self::NoSuchClient | Self::NoSuchUser => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
} }
Self::AuthorizationVerificationError(_e) => StatusCode::UNAUTHORIZED.into_response(), Self::AuthorizationVerificationError(_) | Self::Unauthorized => {
StatusCode::UNAUTHORIZED.into_response()
}
} }
} }
} }
@ -102,9 +107,19 @@ pub async fn get(
) -> Result<Response, RouteError> { ) -> Result<Response, RouteError> {
let session = user_authorization.protected(&mut repo, &clock).await?; let session = user_authorization.protected(&mut repo, &clock).await?;
// This endpoint requires the `openid` scope.
if !session.scope.contains("openid") {
return Err(RouteError::Unauthorized);
}
// Fail if the session is not associated with a user.
let Some(user_id) = session.user_id else {
return Err(RouteError::Unauthorized);
};
let user = repo let user = repo
.user() .user()
.lookup(session.user_id) .lookup(user_id)
.await? .await?
.ok_or(RouteError::NoSuchUser)?; .ok_or(RouteError::NoSuchUser)?;

View File

@ -135,7 +135,6 @@ impl<'c> OAuth2AccessTokenRepository for PgOAuth2AccessTokenRepository<'c> {
fields( fields(
db.statement, db.statement,
%session.id, %session.id,
user.id = %session.user_id,
client.id = %session.client_id, client.id = %session.client_id,
access_token.id, access_token.id,
), ),

View File

@ -406,7 +406,6 @@ impl<'c> OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantReposi
%grant.id, %grant.id,
client.id = %grant.client_id, client.id = %grant.client_id,
%session.id, %session.id,
user.id = %session.user_id,
), ),
err, err,
)] )]

View File

@ -143,7 +143,6 @@ impl<'c> OAuth2RefreshTokenRepository for PgOAuth2RefreshTokenRepository<'c> {
fields( fields(
db.statement, db.statement,
%session.id, %session.id,
user.id = %session.user_id,
client.id = %session.client_id, client.id = %session.client_id,
refresh_token.id, refresh_token.id,
), ),

View File

@ -86,7 +86,7 @@ impl TryFrom<OAuthSessionLookup> for Session {
state, state,
created_at: value.created_at, created_at: value.created_at,
client_id: value.oauth2_client_id.into(), client_id: value.oauth2_client_id.into(),
user_id: value.user_id.into(), user_id: Some(value.user_id.into()),
user_session_id: value.user_session_id.map(Ulid::from), user_session_id: value.user_session_id.map(Ulid::from),
scope, scope,
}) })
@ -186,7 +186,7 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> {
id, id,
state: SessionState::Valid, state: SessionState::Valid,
created_at, created_at,
user_id: user_session.user.id, user_id: Some(user_session.user.id),
user_session_id: Some(user_session.id), user_session_id: Some(user_session.id),
client_id: client.id, client_id: client.id,
scope, scope,
@ -200,7 +200,6 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> {
db.statement, db.statement,
%session.id, %session.id,
%session.scope, %session.scope,
user.id = %session.user_id,
client.id = %session.client_id, client.id = %session.client_id,
), ),
err, err,

View File

@ -695,7 +695,7 @@ type Oauth2Session implements Node & CreationEvent {
""" """
User authorized for this session. User authorized for this session.
""" """
user: User! user: User
} }
type Oauth2SessionConnection { type Oauth2SessionConnection {

View File

@ -517,7 +517,7 @@ export type Oauth2Session = CreationEvent &
/** The state of the session. */ /** The state of the session. */
state: Oauth2SessionState; state: Oauth2SessionState;
/** User authorized for this session. */ /** User authorized for this session. */
user: User; user?: Maybe<User>;
}; };
export type Oauth2SessionConnection = { export type Oauth2SessionConnection = {

View File

@ -1390,13 +1390,10 @@ export default {
{ {
name: "user", name: "user",
type: { type: {
kind: "NON_NULL",
ofType: {
kind: "OBJECT", kind: "OBJECT",
name: "User", name: "User",
ofType: null, ofType: null,
}, },
},
args: [], args: [],
}, },
], ],