compat login (sso): support using client-provided device_id
This commit is contained in:
@@ -10,7 +10,7 @@ use ulid::Ulid;
|
||||
use url::Url;
|
||||
|
||||
use super::CompatSession;
|
||||
use crate::InvalidTransitionError;
|
||||
use crate::{BrowserSession, InvalidTransitionError};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub enum CompatSsoLoginState {
|
||||
@@ -18,12 +18,12 @@ pub enum CompatSsoLoginState {
|
||||
Pending,
|
||||
Fulfilled {
|
||||
fulfilled_at: DateTime<Utc>,
|
||||
session_id: Ulid,
|
||||
browser_session_id: Ulid,
|
||||
},
|
||||
Exchanged {
|
||||
fulfilled_at: DateTime<Utc>,
|
||||
exchanged_at: DateTime<Utc>,
|
||||
session_id: Ulid,
|
||||
compat_session_id: Ulid,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -80,18 +80,20 @@ impl CompatSsoLoginState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the session ID associated with the login.
|
||||
/// Get the compat session ID associated with the login.
|
||||
///
|
||||
/// Returns `None` if the compat SSO login state is [`Pending`].
|
||||
/// Returns `None` if the compat SSO login state is [`Pending`] or
|
||||
/// [`Fulfilled`].
|
||||
///
|
||||
/// [`Pending`]: CompatSsoLoginState::Pending
|
||||
#[must_use]
|
||||
pub fn session_id(&self) -> Option<Ulid> {
|
||||
match self {
|
||||
Self::Pending => None,
|
||||
Self::Fulfilled { session_id, .. } | Self::Exchanged { session_id, .. } => {
|
||||
Some(*session_id)
|
||||
}
|
||||
Self::Pending | Self::Fulfilled { .. } => None,
|
||||
Self::Exchanged {
|
||||
compat_session_id: session_id,
|
||||
..
|
||||
} => Some(*session_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,12 +108,12 @@ impl CompatSsoLoginState {
|
||||
pub fn fulfill(
|
||||
self,
|
||||
fulfilled_at: DateTime<Utc>,
|
||||
session: &CompatSession,
|
||||
browser_session: &BrowserSession,
|
||||
) -> Result<Self, InvalidTransitionError> {
|
||||
match self {
|
||||
Self::Pending => Ok(Self::Fulfilled {
|
||||
fulfilled_at,
|
||||
session_id: session.id,
|
||||
browser_session_id: browser_session.id,
|
||||
}),
|
||||
Self::Fulfilled { .. } | Self::Exchanged { .. } => Err(InvalidTransitionError),
|
||||
}
|
||||
@@ -126,15 +128,19 @@ impl CompatSsoLoginState {
|
||||
///
|
||||
/// [`Fulfilled`]: CompatSsoLoginState::Fulfilled
|
||||
/// [`Exchanged`]: CompatSsoLoginState::Exchanged
|
||||
pub fn exchange(self, exchanged_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
|
||||
pub fn exchange(
|
||||
self,
|
||||
exchanged_at: DateTime<Utc>,
|
||||
compat_session: &CompatSession,
|
||||
) -> Result<Self, InvalidTransitionError> {
|
||||
match self {
|
||||
Self::Fulfilled {
|
||||
fulfilled_at,
|
||||
session_id,
|
||||
browser_session_id: _,
|
||||
} => Ok(Self::Exchanged {
|
||||
fulfilled_at,
|
||||
exchanged_at,
|
||||
session_id,
|
||||
compat_session_id: compat_session.id,
|
||||
}),
|
||||
Self::Pending { .. } | Self::Exchanged { .. } => Err(InvalidTransitionError),
|
||||
}
|
||||
@@ -171,9 +177,9 @@ impl CompatSsoLogin {
|
||||
pub fn fulfill(
|
||||
mut self,
|
||||
fulfilled_at: DateTime<Utc>,
|
||||
session: &CompatSession,
|
||||
browser_session: &BrowserSession,
|
||||
) -> Result<Self, InvalidTransitionError> {
|
||||
self.state = self.state.fulfill(fulfilled_at, session)?;
|
||||
self.state = self.state.fulfill(fulfilled_at, browser_session)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
@@ -186,8 +192,12 @@ impl CompatSsoLogin {
|
||||
///
|
||||
/// [`Fulfilled`]: CompatSsoLoginState::Fulfilled
|
||||
/// [`Exchanged`]: CompatSsoLoginState::Exchanged
|
||||
pub fn exchange(mut self, exchanged_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
|
||||
self.state = self.state.exchange(exchanged_at)?;
|
||||
pub fn exchange(
|
||||
mut self,
|
||||
exchanged_at: DateTime<Utc>,
|
||||
compat_session: &CompatSession,
|
||||
) -> Result<Self, InvalidTransitionError> {
|
||||
self.state = self.state.exchange(exchanged_at, compat_session)?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,9 +170,6 @@ pub enum RouteError {
|
||||
#[error("user not found")]
|
||||
UserNotFound,
|
||||
|
||||
#[error("session not found")]
|
||||
SessionNotFound,
|
||||
|
||||
#[error("user has no password")]
|
||||
NoPassword,
|
||||
|
||||
@@ -201,13 +198,11 @@ impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let event_id = sentry::capture_error(&self);
|
||||
let response = match self {
|
||||
Self::Internal(_) | Self::SessionNotFound | Self::ProvisionDeviceFailed(_) => {
|
||||
MatrixError {
|
||||
errcode: "M_UNKNOWN",
|
||||
error: "Internal server error",
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
Self::Internal(_) | Self::ProvisionDeviceFailed(_) => MatrixError {
|
||||
errcode: "M_UNKNOWN",
|
||||
error: "Internal server error",
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
Self::RateLimited(_) => MatrixError {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
error: "Too many login attempts",
|
||||
@@ -323,7 +318,17 @@ pub(crate) async fn post(
|
||||
.await?
|
||||
}
|
||||
|
||||
(_, Credentials::Token { token }) => token_login(&mut repo, &clock, &token).await?,
|
||||
(_, Credentials::Token { token }) => {
|
||||
token_login(
|
||||
&mut repo,
|
||||
&clock,
|
||||
&token,
|
||||
input.device_id,
|
||||
&homeserver,
|
||||
&mut rng,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(RouteError::Unsupported);
|
||||
@@ -382,6 +387,9 @@ async fn token_login(
|
||||
repo: &mut BoxRepository,
|
||||
clock: &dyn Clock,
|
||||
token: &str,
|
||||
requested_device_id: Option<String>,
|
||||
homeserver: &dyn HomeserverConnection,
|
||||
rng: &mut (dyn RngCore + Send),
|
||||
) -> Result<(CompatSession, User), RouteError> {
|
||||
let login = repo
|
||||
.compat_sso_login()
|
||||
@@ -390,7 +398,7 @@ async fn token_login(
|
||||
.ok_or(RouteError::InvalidLoginToken)?;
|
||||
|
||||
let now = clock.now();
|
||||
let session_id = match login.state {
|
||||
let browser_session_id = match login.state {
|
||||
CompatSsoLoginState::Pending => {
|
||||
tracing::error!(
|
||||
compat_sso_login.id = %login.id,
|
||||
@@ -400,25 +408,25 @@ async fn token_login(
|
||||
}
|
||||
CompatSsoLoginState::Fulfilled {
|
||||
fulfilled_at,
|
||||
session_id,
|
||||
browser_session_id,
|
||||
..
|
||||
} => {
|
||||
if now > fulfilled_at + Duration::microseconds(30 * 1000 * 1000) {
|
||||
return Err(RouteError::LoginTookTooLong);
|
||||
}
|
||||
|
||||
session_id
|
||||
browser_session_id
|
||||
}
|
||||
CompatSsoLoginState::Exchanged {
|
||||
exchanged_at,
|
||||
session_id,
|
||||
compat_session_id,
|
||||
..
|
||||
} => {
|
||||
if now > exchanged_at + Duration::microseconds(30 * 1000 * 1000) {
|
||||
// TODO: log that session out
|
||||
tracing::error!(
|
||||
compat_sso_login.id = %login.id,
|
||||
compat_session.id = %session_id,
|
||||
compat_session.id = %compat_session_id,
|
||||
"Login token exchanged a second time more than 30s after"
|
||||
);
|
||||
}
|
||||
@@ -427,22 +435,56 @@ async fn token_login(
|
||||
}
|
||||
};
|
||||
|
||||
let session = repo
|
||||
let Some(browser_session) = repo.browser_session().lookup(browser_session_id).await? else {
|
||||
tracing::error!(
|
||||
compat_sso_login.id = %login.id,
|
||||
browser_session.id = %browser_session_id,
|
||||
"Attempt to exchange login token but no associated browser session found"
|
||||
);
|
||||
return Err(RouteError::InvalidLoginToken);
|
||||
};
|
||||
if !browser_session.active() || !browser_session.user.is_valid() {
|
||||
tracing::info!(
|
||||
compat_sso_login.id = %login.id,
|
||||
browser_session.id = %browser_session_id,
|
||||
"Attempt to exchange login token but browser session is not active"
|
||||
);
|
||||
return Err(RouteError::InvalidLoginToken);
|
||||
}
|
||||
|
||||
// Lock the user sync to make sure we don't get into a race condition
|
||||
repo.user()
|
||||
.acquire_lock_for_sync(&browser_session.user)
|
||||
.await?;
|
||||
|
||||
let device = if let Some(requested_device_id) = requested_device_id {
|
||||
Device::from(requested_device_id)
|
||||
} else {
|
||||
Device::generate(rng)
|
||||
};
|
||||
let mxid = homeserver.mxid(&browser_session.user.username);
|
||||
homeserver
|
||||
.create_device(&mxid, device.as_str())
|
||||
.await
|
||||
.map_err(RouteError::ProvisionDeviceFailed)?;
|
||||
|
||||
let compat_session = repo
|
||||
.compat_session()
|
||||
.lookup(session_id)
|
||||
.await?
|
||||
.ok_or(RouteError::SessionNotFound)?;
|
||||
.add(
|
||||
rng,
|
||||
clock,
|
||||
&browser_session.user,
|
||||
device,
|
||||
Some(&browser_session),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user = repo
|
||||
.user()
|
||||
.lookup(session.user_id)
|
||||
.await?
|
||||
.filter(mas_data_model::User::is_valid)
|
||||
.ok_or(RouteError::UserNotFound)?;
|
||||
repo.compat_sso_login()
|
||||
.exchange(clock, login, &compat_session)
|
||||
.await?;
|
||||
|
||||
repo.compat_sso_login().exchange(clock, login).await?;
|
||||
|
||||
Ok((session, user))
|
||||
Ok((compat_session, browser_session.user))
|
||||
}
|
||||
|
||||
async fn user_password_login(
|
||||
@@ -1015,7 +1057,7 @@ mod tests {
|
||||
}
|
||||
"###);
|
||||
|
||||
let (device, token) = get_login_token(&state, &user).await;
|
||||
let token = get_login_token(&state, &user).await;
|
||||
|
||||
// Try to login with the token.
|
||||
let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({
|
||||
@@ -1026,14 +1068,13 @@ mod tests {
|
||||
response.assert_status(StatusCode::OK);
|
||||
|
||||
let body: serde_json::Value = response.json();
|
||||
insta::assert_json_snapshot!(body, @r###"
|
||||
insta::assert_json_snapshot!(body, @r#"
|
||||
{
|
||||
"access_token": "mct_uihy4bk51gxgUbUTa4XIh92RARTPTj_xADEE4",
|
||||
"device_id": "Yp7FM44zJN",
|
||||
"access_token": "mct_bnkWh1tPmm1MZOpygPaXwygX8PfxEY_hE6do1",
|
||||
"device_id": "O3Ju1MUh3Z",
|
||||
"user_id": "@alice:example.com"
|
||||
}
|
||||
"###);
|
||||
assert_eq!(body["device_id"], device.to_string());
|
||||
"#);
|
||||
|
||||
// Try again with the same token, it should fail.
|
||||
let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({
|
||||
@@ -1051,7 +1092,7 @@ mod tests {
|
||||
"###);
|
||||
|
||||
// Try to login, but wait too long before sending the request.
|
||||
let (_device, token) = get_login_token(&state, &user).await;
|
||||
let token = get_login_token(&state, &user).await;
|
||||
|
||||
// Advance the clock to make the token expire.
|
||||
state
|
||||
@@ -1079,14 +1120,13 @@ mod tests {
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the repository fails.
|
||||
async fn get_login_token(state: &TestState, user: &User) -> (Device, String) {
|
||||
async fn get_login_token(state: &TestState, user: &User) -> String {
|
||||
// XXX: This is a bit manual, but this is what basically the SSO login flow
|
||||
// does.
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
|
||||
// Generate a device and a token randomly
|
||||
// Generate a token randomly
|
||||
let token = Alphanumeric.sample_string(&mut state.rng(), 32);
|
||||
let device = Device::generate(&mut state.rng());
|
||||
|
||||
// Start a compat SSO login flow
|
||||
let login = repo
|
||||
@@ -1100,27 +1140,20 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Complete the flow by fulfilling it with a session
|
||||
let compat_session = repo
|
||||
.compat_session()
|
||||
.add(
|
||||
&mut state.rng(),
|
||||
&state.clock,
|
||||
user,
|
||||
device.clone(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
// Advance the flow by fulfilling it with a browser session
|
||||
let browser_session = repo
|
||||
.browser_session()
|
||||
.add(&mut state.rng(), &state.clock, user, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.compat_sso_login()
|
||||
.fulfill(&state.clock, login, &compat_session)
|
||||
let _login = repo
|
||||
.compat_sso_login()
|
||||
.fulfill(&state.clock, login, &browser_session)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.save().await.unwrap();
|
||||
|
||||
(device, token)
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
@@ -17,12 +17,9 @@ use mas_axum_utils::{
|
||||
cookies::CookieJar,
|
||||
csrf::{CsrfExt, ProtectedForm},
|
||||
};
|
||||
use mas_data_model::Device;
|
||||
use mas_matrix::HomeserverConnection;
|
||||
use mas_router::{CompatLoginSsoAction, UrlBuilder};
|
||||
use mas_storage::{
|
||||
BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess,
|
||||
compat::{CompatSessionRepository, CompatSsoLoginRepository},
|
||||
BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, compat::CompatSsoLoginRepository,
|
||||
};
|
||||
use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -133,7 +130,6 @@ pub async fn post(
|
||||
PreferredLanguage(locale): PreferredLanguage,
|
||||
State(templates): State<Templates>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
cookie_jar: CookieJar,
|
||||
Path(id): Path<Ulid>,
|
||||
Query(params): Query<Params>,
|
||||
@@ -174,8 +170,10 @@ pub async fn post(
|
||||
.await?
|
||||
.context("Could not find compat SSO login")?;
|
||||
|
||||
// Bail out if that login session is more than 30min old
|
||||
if clock.now() > login.created_at + Duration::microseconds(30 * 60 * 1000 * 1000) {
|
||||
// Bail out if that login session isn't pending, or is more than 30min old
|
||||
if !login.is_pending()
|
||||
|| clock.now() > login.created_at + Duration::microseconds(30 * 60 * 1000 * 1000)
|
||||
{
|
||||
let ctx = ErrorContext::new()
|
||||
.with_code("compat_sso_login_expired")
|
||||
.with_description("This login session expired.".to_owned())
|
||||
@@ -202,30 +200,10 @@ pub async fn post(
|
||||
redirect_uri
|
||||
};
|
||||
|
||||
// Lock the user sync to make sure we don't get into a race condition
|
||||
repo.user().acquire_lock_for_sync(&session.user).await?;
|
||||
|
||||
let device = Device::generate(&mut rng);
|
||||
let mxid = homeserver.mxid(&session.user.username);
|
||||
homeserver
|
||||
.create_device(&mxid, device.as_str())
|
||||
.await
|
||||
.context("Failed to provision device")?;
|
||||
|
||||
let compat_session = repo
|
||||
.compat_session()
|
||||
.add(
|
||||
&mut rng,
|
||||
&clock,
|
||||
&session.user,
|
||||
device,
|
||||
Some(&session),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Note that if the login is not Pending,
|
||||
// this fails and aborts the transaction.
|
||||
repo.compat_sso_login()
|
||||
.fulfill(&clock, login, &compat_session)
|
||||
.fulfill(&clock, login, &session)
|
||||
.await?;
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
16
crates/storage-pg/.sqlx/query-3f9d76f442c82a1631da931950b83b80c9620e1825ab07ab6c52f3f1a32d2527.json
generated
Normal file
16
crates/storage-pg/.sqlx/query-3f9d76f442c82a1631da931950b83b80c9620e1825ab07ab6c52f3f1a32d2527.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE compat_sso_logins\n SET\n user_session_id = $2,\n fulfilled_at = $3\n WHERE\n compat_sso_login_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3f9d76f442c82a1631da931950b83b80c9620e1825ab07ab6c52f3f1a32d2527"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT compat_sso_login_id\n , login_token\n , redirect_uri\n , created_at\n , fulfilled_at\n , exchanged_at\n , compat_session_id\n\n FROM compat_sso_logins\n WHERE compat_session_id = $1\n ",
|
||||
"query": "\n SELECT compat_sso_login_id\n , login_token\n , redirect_uri\n , created_at\n , fulfilled_at\n , exchanged_at\n , compat_session_id\n , user_session_id\n\n FROM compat_sso_logins\n WHERE compat_session_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "compat_session_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "user_session_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -51,8 +56,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1787a5e86b60f57295fe5111259a29ffb15aa31e707cb7f2ad4269d125f6d8c9"
|
||||
"hash": "933d2bed9c00eb9b37bfe757266ead15df5e0a4209ff47dcf4a5f19d35154e89"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE compat_sso_logins\n SET\n exchanged_at = $2\n WHERE\n compat_sso_login_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9348d87f9e06b614c7e90bdc93bcf38236766aaf4d894bf768debdff2b59fae2"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT compat_sso_login_id\n , login_token\n , redirect_uri\n , created_at\n , fulfilled_at\n , exchanged_at\n , compat_session_id\n\n FROM compat_sso_logins\n WHERE compat_sso_login_id = $1\n ",
|
||||
"query": "\n SELECT compat_sso_login_id\n , login_token\n , redirect_uri\n , created_at\n , fulfilled_at\n , exchanged_at\n , compat_session_id\n , user_session_id\n\n FROM compat_sso_logins\n WHERE compat_sso_login_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "compat_session_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "user_session_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -51,8 +56,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ddb22dd9ae9367af65a607e1fdc48b3d9581d67deea0c168f24e02090082bb82"
|
||||
"hash": "a7094d84d313602729fde155cfbe63041fca7cbab407f98452462ec45e3cfd16"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT compat_sso_login_id\n , login_token\n , redirect_uri\n , created_at\n , fulfilled_at\n , exchanged_at\n , compat_session_id\n\n FROM compat_sso_logins\n WHERE login_token = $1\n ",
|
||||
"query": "\n SELECT compat_sso_login_id\n , login_token\n , redirect_uri\n , created_at\n , fulfilled_at\n , exchanged_at\n , compat_session_id\n , user_session_id\n\n FROM compat_sso_logins\n WHERE login_token = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "compat_session_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "user_session_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -51,8 +56,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "478f0ad710da8bfd803c6cddd982bc504d1b6bd0f5283de53c8c7b1b4b7dafd4"
|
||||
"hash": "ce36eb8d3e4478a4e8520919ff41f1a5e6470cef581b1638f5578546dd28c4df"
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE compat_sso_logins\n SET\n compat_session_id = $2,\n fulfilled_at = $3\n WHERE\n compat_sso_login_id = $1\n ",
|
||||
"query": "\n UPDATE compat_sso_logins\n SET\n exchanged_at = $2,\n compat_session_id = $3\n WHERE\n compat_sso_login_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Timestamptz"
|
||||
"Timestamptz",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4d79ce892e4595edb8b801e94fb0cbef28facdfd2e45d1c72c57f47418fbe24b"
|
||||
"hash": "e8e48db74ac1ab5baa1e4b121643cfa33a0bf3328df6e869464fe7f31429b81e"
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright 2025 New Vector Ltd.
|
||||
--
|
||||
-- SPDX-License-Identifier: AGPL-3.0-only
|
||||
-- Please see LICENSE in the repository root for full details.
|
||||
|
||||
|
||||
-- Compat SSO Logins in the 'fulfilled' state will now be attached to
|
||||
-- browser sessions, not compat sessions.
|
||||
-- Only those in the 'exchanged' state will now have a compat session.
|
||||
--
|
||||
-- Rationale: We can't create the compat session without the client
|
||||
-- being given an opportunity to specify the device_id, which does not happen
|
||||
-- until the exchange phase.
|
||||
|
||||
-- Empty the table because we don't want to need to think about backwards
|
||||
-- compatibility for fulfilled logins that don't have an attached
|
||||
-- browser session ID.
|
||||
TRUNCATE compat_sso_logins;
|
||||
|
||||
ALTER TABLE compat_sso_logins
|
||||
-- browser sessions and user sessions are the same thing
|
||||
ADD COLUMN user_session_id UUID
|
||||
REFERENCES user_sessions(user_session_id) ON DELETE CASCADE;
|
||||
@@ -209,21 +209,41 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(login.is_pending());
|
||||
|
||||
// Start a browser session for the user
|
||||
let browser_session = repo
|
||||
.browser_session()
|
||||
.add(&mut rng, &clock, &user, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Start a compat session for that user
|
||||
let device = Device::generate(&mut rng);
|
||||
let sso_login_session = repo
|
||||
.compat_session()
|
||||
.add(&mut rng, &clock, &user, device, None, false)
|
||||
.add(
|
||||
&mut rng,
|
||||
&clock,
|
||||
&user,
|
||||
device,
|
||||
Some(&browser_session),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Associate the login with the session
|
||||
let login = repo
|
||||
.compat_sso_login()
|
||||
.fulfill(&clock, login, &sso_login_session)
|
||||
.fulfill(&clock, login, &browser_session)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(login.is_fulfilled());
|
||||
let login = repo
|
||||
.compat_sso_login()
|
||||
.exchange(&clock, login, &sso_login_session)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(login.is_exchanged());
|
||||
|
||||
// Now query the session list with both the unknown and SSO login session type
|
||||
// filter
|
||||
@@ -594,26 +614,33 @@ mod tests {
|
||||
.expect("login not found");
|
||||
assert_eq!(login_lookup, login);
|
||||
|
||||
// Start a compat session for that user
|
||||
let device = Device::generate(&mut rng);
|
||||
let compat_session = repo
|
||||
.compat_session()
|
||||
.add(&mut rng, &clock, &user, device, None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Exchanging before fulfilling should not work
|
||||
// Note: It should also not poison the SQL transaction
|
||||
let res = repo
|
||||
.compat_sso_login()
|
||||
.exchange(&clock, login.clone())
|
||||
.exchange(&clock, login.clone(), &compat_session)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
|
||||
// Start a compat session for that user
|
||||
let device = Device::generate(&mut rng);
|
||||
let session = repo
|
||||
.compat_session()
|
||||
.add(&mut rng, &clock, &user, device, None, false)
|
||||
// Start a browser session for that user
|
||||
let browser_session = repo
|
||||
.browser_session()
|
||||
.add(&mut rng, &clock, &user, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Associate the login with the session
|
||||
let login = repo
|
||||
.compat_sso_login()
|
||||
.fulfill(&clock, login, &session)
|
||||
.fulfill(&clock, login, &browser_session)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(login.is_fulfilled());
|
||||
@@ -629,14 +656,14 @@ mod tests {
|
||||
// Note: It should also not poison the SQL transaction
|
||||
let res = repo
|
||||
.compat_sso_login()
|
||||
.fulfill(&clock, login.clone(), &session)
|
||||
.fulfill(&clock, login.clone(), &browser_session)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
|
||||
// Exchange that login
|
||||
let login = repo
|
||||
.compat_sso_login()
|
||||
.exchange(&clock, login)
|
||||
.exchange(&clock, login, &compat_session)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(login.is_exchanged());
|
||||
@@ -652,7 +679,7 @@ mod tests {
|
||||
// Note: It should also not poison the SQL transaction
|
||||
let res = repo
|
||||
.compat_sso_login()
|
||||
.exchange(&clock, login.clone())
|
||||
.exchange(&clock, login.clone(), &compat_session)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
|
||||
@@ -660,7 +687,7 @@ mod tests {
|
||||
// Note: It should also not poison the SQL transaction
|
||||
let res = repo
|
||||
.compat_sso_login()
|
||||
.fulfill(&clock, login.clone(), &session)
|
||||
.fulfill(&clock, login.clone(), &browser_session)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
|
||||
|
||||
@@ -157,14 +157,10 @@ impl TryFrom<CompatSessionAndSsoLoginLookup> for (CompatSession, Option<CompatSs
|
||||
})?;
|
||||
|
||||
let state = match (fulfilled_at, exchanged_at) {
|
||||
(Some(fulfilled_at), None) => CompatSsoLoginState::Fulfilled {
|
||||
fulfilled_at,
|
||||
session_id: session.id,
|
||||
},
|
||||
(Some(fulfilled_at), Some(exchanged_at)) => CompatSsoLoginState::Exchanged {
|
||||
fulfilled_at,
|
||||
exchanged_at,
|
||||
session_id: session.id,
|
||||
compat_session_id: session.id,
|
||||
},
|
||||
_ => return Err(DatabaseInconsistencyError::on("compat_sso_logins").row(id)),
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{CompatSession, CompatSsoLogin, CompatSsoLoginState};
|
||||
use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, CompatSsoLoginState};
|
||||
use mas_storage::{
|
||||
Clock, Page, Pagination,
|
||||
compat::{CompatSsoLoginFilter, CompatSsoLoginRepository},
|
||||
@@ -22,7 +22,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
DatabaseError, DatabaseInconsistencyError,
|
||||
filter::{Filter, StatementExt},
|
||||
iden::{CompatSessions, CompatSsoLogins},
|
||||
iden::{CompatSsoLogins, UserSessions},
|
||||
pagination::QueryBuilderExt,
|
||||
tracing::ExecuteExt,
|
||||
};
|
||||
@@ -41,7 +41,7 @@ impl<'c> PgCompatSsoLoginRepository<'c> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
#[derive(sqlx::FromRow, Debug)]
|
||||
#[enum_def]
|
||||
struct CompatSsoLoginLookup {
|
||||
compat_sso_login_id: Uuid,
|
||||
@@ -50,6 +50,7 @@ struct CompatSsoLoginLookup {
|
||||
created_at: DateTime<Utc>,
|
||||
fulfilled_at: Option<DateTime<Utc>>,
|
||||
exchanged_at: Option<DateTime<Utc>>,
|
||||
user_session_id: Option<Uuid>,
|
||||
compat_session_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
@@ -65,17 +66,24 @@ impl TryFrom<CompatSsoLoginLookup> for CompatSsoLogin {
|
||||
.source(e)
|
||||
})?;
|
||||
|
||||
let state = match (res.fulfilled_at, res.exchanged_at, res.compat_session_id) {
|
||||
(None, None, None) => CompatSsoLoginState::Pending,
|
||||
(Some(fulfilled_at), None, Some(session_id)) => CompatSsoLoginState::Fulfilled {
|
||||
fulfilled_at,
|
||||
session_id: session_id.into(),
|
||||
},
|
||||
(Some(fulfilled_at), Some(exchanged_at), Some(session_id)) => {
|
||||
let state = match (
|
||||
res.fulfilled_at,
|
||||
res.exchanged_at,
|
||||
res.user_session_id,
|
||||
res.compat_session_id,
|
||||
) {
|
||||
(None, None, None, None) => CompatSsoLoginState::Pending,
|
||||
(Some(fulfilled_at), None, Some(browser_session_id), None) => {
|
||||
CompatSsoLoginState::Fulfilled {
|
||||
fulfilled_at,
|
||||
browser_session_id: browser_session_id.into(),
|
||||
}
|
||||
}
|
||||
(Some(fulfilled_at), Some(exchanged_at), _, Some(compat_session_id)) => {
|
||||
CompatSsoLoginState::Exchanged {
|
||||
fulfilled_at,
|
||||
exchanged_at,
|
||||
session_id: session_id.into(),
|
||||
compat_session_id: compat_session_id.into(),
|
||||
}
|
||||
}
|
||||
_ => return Err(DatabaseInconsistencyError::on("compat_sso_logins").row(id)),
|
||||
@@ -98,14 +106,14 @@ impl Filter for CompatSsoLoginFilter<'_> {
|
||||
Expr::exists(
|
||||
Query::select()
|
||||
.expr(Expr::cust("1"))
|
||||
.from(CompatSessions::Table)
|
||||
.from(UserSessions::Table)
|
||||
.and_where(
|
||||
Expr::col((CompatSessions::Table, CompatSessions::UserId))
|
||||
Expr::col((UserSessions::Table, UserSessions::UserId))
|
||||
.eq(Uuid::from(user.id)),
|
||||
)
|
||||
.and_where(
|
||||
Expr::col((CompatSsoLogins::Table, CompatSsoLogins::CompatSessionId))
|
||||
.equals((CompatSessions::Table, CompatSessions::CompatSessionId)),
|
||||
Expr::col((CompatSsoLogins::Table, CompatSsoLogins::UserSessionId))
|
||||
.equals((UserSessions::Table, UserSessions::UserSessionId)),
|
||||
)
|
||||
.take(),
|
||||
)
|
||||
@@ -151,6 +159,7 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> {
|
||||
, fulfilled_at
|
||||
, exchanged_at
|
||||
, compat_session_id
|
||||
, user_session_id
|
||||
|
||||
FROM compat_sso_logins
|
||||
WHERE compat_sso_login_id = $1
|
||||
@@ -189,6 +198,7 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> {
|
||||
, fulfilled_at
|
||||
, exchanged_at
|
||||
, compat_session_id
|
||||
, user_session_id
|
||||
|
||||
FROM compat_sso_logins
|
||||
WHERE compat_session_id = $1
|
||||
@@ -226,6 +236,7 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> {
|
||||
, fulfilled_at
|
||||
, exchanged_at
|
||||
, compat_session_id
|
||||
, user_session_id
|
||||
|
||||
FROM compat_sso_logins
|
||||
WHERE login_token = $1
|
||||
@@ -292,9 +303,8 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> {
|
||||
fields(
|
||||
db.query.text,
|
||||
%compat_sso_login.id,
|
||||
%compat_session.id,
|
||||
compat_session.device.id = compat_session.device.as_ref().map(mas_data_model::Device::as_str),
|
||||
user.id = %compat_session.user_id,
|
||||
%browser_session.id,
|
||||
user.id = %browser_session.user.id,
|
||||
),
|
||||
err,
|
||||
)]
|
||||
@@ -302,24 +312,24 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> {
|
||||
&mut self,
|
||||
clock: &dyn Clock,
|
||||
compat_sso_login: CompatSsoLogin,
|
||||
compat_session: &CompatSession,
|
||||
browser_session: &BrowserSession,
|
||||
) -> Result<CompatSsoLogin, Self::Error> {
|
||||
let fulfilled_at = clock.now();
|
||||
let compat_sso_login = compat_sso_login
|
||||
.fulfill(fulfilled_at, compat_session)
|
||||
.fulfill(fulfilled_at, browser_session)
|
||||
.map_err(DatabaseError::to_invalid_operation)?;
|
||||
|
||||
let res = sqlx::query!(
|
||||
r#"
|
||||
UPDATE compat_sso_logins
|
||||
SET
|
||||
compat_session_id = $2,
|
||||
user_session_id = $2,
|
||||
fulfilled_at = $3
|
||||
WHERE
|
||||
compat_sso_login_id = $1
|
||||
"#,
|
||||
Uuid::from(compat_sso_login.id),
|
||||
Uuid::from(compat_session.id),
|
||||
Uuid::from(browser_session.id),
|
||||
fulfilled_at,
|
||||
)
|
||||
.traced()
|
||||
@@ -337,6 +347,8 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> {
|
||||
fields(
|
||||
db.query.text,
|
||||
%compat_sso_login.id,
|
||||
%compat_session.id,
|
||||
compat_session.device.id = compat_session.device.as_ref().map(mas_data_model::Device::as_str),
|
||||
),
|
||||
err,
|
||||
)]
|
||||
@@ -344,22 +356,25 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> {
|
||||
&mut self,
|
||||
clock: &dyn Clock,
|
||||
compat_sso_login: CompatSsoLogin,
|
||||
compat_session: &CompatSession,
|
||||
) -> Result<CompatSsoLogin, Self::Error> {
|
||||
let exchanged_at = clock.now();
|
||||
let compat_sso_login = compat_sso_login
|
||||
.exchange(exchanged_at)
|
||||
.exchange(exchanged_at, compat_session)
|
||||
.map_err(DatabaseError::to_invalid_operation)?;
|
||||
|
||||
let res = sqlx::query!(
|
||||
r#"
|
||||
UPDATE compat_sso_logins
|
||||
SET
|
||||
exchanged_at = $2
|
||||
exchanged_at = $2,
|
||||
compat_session_id = $3
|
||||
WHERE
|
||||
compat_sso_login_id = $1
|
||||
"#,
|
||||
Uuid::from(compat_sso_login.id),
|
||||
exchanged_at,
|
||||
Uuid::from(compat_session.id),
|
||||
)
|
||||
.traced()
|
||||
.execute(&mut *self.conn)
|
||||
@@ -392,6 +407,10 @@ impl CompatSsoLoginRepository for PgCompatSsoLoginRepository<'_> {
|
||||
Expr::col((CompatSsoLogins::Table, CompatSsoLogins::CompatSessionId)),
|
||||
CompatSsoLoginLookupIden::CompatSessionId,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((CompatSsoLogins::Table, CompatSsoLogins::UserSessionId)),
|
||||
CompatSsoLoginLookupIden::UserSessionId,
|
||||
)
|
||||
.expr_as(
|
||||
Expr::col((CompatSsoLogins::Table, CompatSsoLogins::LoginToken)),
|
||||
CompatSsoLoginLookupIden::LoginToken,
|
||||
|
||||
@@ -61,6 +61,7 @@ pub enum CompatSsoLogins {
|
||||
RedirectUri,
|
||||
LoginToken,
|
||||
CompatSessionId,
|
||||
UserSessionId,
|
||||
CreatedAt,
|
||||
FulfilledAt,
|
||||
ExchangedAt,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use mas_data_model::{CompatSession, CompatSsoLogin, User};
|
||||
use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, User};
|
||||
use rand_core::RngCore;
|
||||
use ulid::Ulid;
|
||||
use url::Url;
|
||||
@@ -168,7 +168,7 @@ pub trait CompatSsoLoginRepository: Send + Sync {
|
||||
redirect_uri: Url,
|
||||
) -> Result<CompatSsoLogin, Self::Error>;
|
||||
|
||||
/// Fulfill a compat SSO login by providing a compat session
|
||||
/// Fulfill a compat SSO login by providing a browser session
|
||||
///
|
||||
/// Returns the fulfilled compat SSO login
|
||||
///
|
||||
@@ -176,8 +176,8 @@ pub trait CompatSsoLoginRepository: Send + Sync {
|
||||
///
|
||||
/// * `clock`: The clock used to generate the timestamps
|
||||
/// * `compat_sso_login`: The compat SSO login to fulfill
|
||||
/// * `compat_session`: The compat session to associate with the compat SSO
|
||||
/// login
|
||||
/// * `browser_session`: The browser session to associate with the compat
|
||||
/// SSO login
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -186,7 +186,7 @@ pub trait CompatSsoLoginRepository: Send + Sync {
|
||||
&mut self,
|
||||
clock: &dyn Clock,
|
||||
compat_sso_login: CompatSsoLogin,
|
||||
compat_session: &CompatSession,
|
||||
browser_session: &BrowserSession,
|
||||
) -> Result<CompatSsoLogin, Self::Error>;
|
||||
|
||||
/// Mark a compat SSO login as exchanged
|
||||
@@ -197,6 +197,8 @@ pub trait CompatSsoLoginRepository: Send + Sync {
|
||||
///
|
||||
/// * `clock`: The clock used to generate the timestamps
|
||||
/// * `compat_sso_login`: The compat SSO login to mark as exchanged
|
||||
/// * `compat_session`: The compat session created as a result of the
|
||||
/// exchange
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -205,6 +207,7 @@ pub trait CompatSsoLoginRepository: Send + Sync {
|
||||
&mut self,
|
||||
clock: &dyn Clock,
|
||||
compat_sso_login: CompatSsoLogin,
|
||||
compat_session: &CompatSession,
|
||||
) -> Result<CompatSsoLogin, Self::Error>;
|
||||
|
||||
/// List [`CompatSsoLogin`] with the given filter and pagination
|
||||
@@ -262,13 +265,14 @@ repository_impl!(CompatSsoLoginRepository:
|
||||
&mut self,
|
||||
clock: &dyn Clock,
|
||||
compat_sso_login: CompatSsoLogin,
|
||||
compat_session: &CompatSession,
|
||||
browser_session: &BrowserSession,
|
||||
) -> Result<CompatSsoLogin, Self::Error>;
|
||||
|
||||
async fn exchange(
|
||||
&mut self,
|
||||
clock: &dyn Clock,
|
||||
compat_sso_login: CompatSsoLogin,
|
||||
compat_session: &CompatSession,
|
||||
) -> Result<CompatSsoLogin, Self::Error>;
|
||||
|
||||
async fn list(
|
||||
|
||||
Reference in New Issue
Block a user