compat login (sso): support using client-provided device_id

This commit is contained in:
Olivier 'reivilibre
2025-04-04 16:24:48 +01:00
parent b4c6930b94
commit 1e2af0fd3a
16 changed files with 287 additions and 177 deletions

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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?;

View 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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -23,7 +23,7 @@
"Left": []
},
"nullable": [
true,
false,
true,
null
]

View File

@@ -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;

View File

@@ -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());

View File

@@ -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)),
};

View File

@@ -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,

View File

@@ -61,6 +61,7 @@ pub enum CompatSsoLogins {
RedirectUri,
LoginToken,
CompatSessionId,
UserSessionId,
CreatedAt,
FulfilledAt,
ExchangedAt,

View File

@@ -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(