diff --git a/crates/data-model/src/compat.rs b/crates/data-model/src/compat.rs index 1fd6d09f..9d9cf46e 100644 --- a/crates/data-model/src/compat.rs +++ b/crates/data-model/src/compat.rs @@ -20,6 +20,7 @@ use rand::{ }; use serde::Serialize; use thiserror::Error; +use url::Url; use crate::{StorageBackend, StorageBackendMarker, User}; @@ -114,3 +115,29 @@ impl From> for CompatAccessToken<( } } } + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(bound = "T: StorageBackend")] +pub enum CompatSsoLoginState { + Pending, + Fullfilled { + fullfilled_at: DateTime, + session: CompatSession, + }, + Exchanged { + fullfilled_at: DateTime, + exchanged_at: DateTime, + session: CompatSession, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(bound = "T: StorageBackend")] +pub struct CompatSsoLogin { + #[serde(skip_serializing)] + pub data: T::CompatSsoLoginData, + pub redirect_uri: Url, + pub token: String, + pub created_at: DateTime, + pub state: CompatSsoLoginState, +} diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 6fd173c0..b6db744f 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -29,7 +29,10 @@ pub(crate) mod traits; pub(crate) mod users; pub use self::{ - compat::{CompatAccessToken, CompatRefreshToken, CompatSession, Device}, + compat::{ + CompatAccessToken, CompatRefreshToken, CompatSession, CompatSsoLogin, CompatSsoLoginState, + Device, + }, oauth2::{ AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, diff --git a/crates/data-model/src/traits.rs b/crates/data-model/src/traits.rs index 569ffe2b..53209262 100644 --- a/crates/data-model/src/traits.rs +++ b/crates/data-model/src/traits.rs @@ -37,6 +37,7 @@ pub trait StorageBackend { type CompatAccessTokenData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; type CompatRefreshTokenData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; type CompatSessionData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type CompatSsoLoginData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; } impl StorageBackend for () { @@ -48,6 +49,7 @@ impl StorageBackend for () { type CompatAccessTokenData = (); type CompatRefreshTokenData = (); type CompatSessionData = (); + type CompatSsoLoginData = (); type RefreshTokenData = (); type SessionData = (); type UserData = (); diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 3fe9fefc..1513ac63 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -16,31 +16,56 @@ use axum::{response::IntoResponse, Extension, Json}; use chrono::Duration; use hyper::StatusCode; use mas_config::MatrixConfig; -use mas_data_model::{Device, TokenType}; -use mas_storage::compat::{add_compat_access_token, add_compat_refresh_token, compat_login}; +use mas_data_model::{CompatSession, Device, TokenType}; +use mas_storage::{ + compat::{ + add_compat_access_token, add_compat_refresh_token, compat_login, + get_compat_sso_login_by_token, mark_compat_sso_login_as_exchanged, + }, + PostgresqlBackend, +}; use rand::thread_rng; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, skip_serializing_none, DurationMilliSeconds}; -use sqlx::PgPool; +use sqlx::{PgPool, Postgres, Transaction}; use thiserror::Error; use super::MatrixError; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize)] #[serde(tag = "type")] enum LoginType { #[serde(rename = "m.login.password")] Password, + + #[serde(rename = "m.login.sso")] + Sso { + identity_providers: Vec, + }, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize)] +struct SsoIdentityProvider { + id: &'static str, + name: &'static str, +} + +#[derive(Debug, Serialize)] struct LoginTypes { flows: Vec, } pub(crate) async fn get() -> impl IntoResponse { let res = LoginTypes { - flows: vec![LoginType::Password], + flows: vec![ + LoginType::Password, + LoginType::Sso { + identity_providers: vec![SsoIdentityProvider { + id: "legacy", + name: "SSO", + }], + }, + ], }; Json(res) @@ -64,6 +89,9 @@ pub enum Credentials { password: String, }, + #[serde(rename = "m.login.token")] + Token { token: String }, + #[serde(other)] Unsupported, } @@ -140,23 +168,20 @@ pub(crate) async fn post( Extension(config): Extension, Json(input): Json, ) -> Result { - let (username, password) = match input.credentials { + let mut txn = pool.begin().await?; + let session = match input.credentials { Credentials::Password { identifier: Identifier::User { user }, password, - } => (user, password), + } => user_password_login(&mut txn, user, password).await?, + + Credentials::Token { token } => token_login(&mut txn, &token).await?, + _ => { return Err(RouteError::Unsupported); } }; - let mut txn = pool.begin().await?; - - let device = Device::generate(&mut thread_rng()); - let session = compat_login(&mut txn, &username, &password, device) - .await - .map_err(|_| RouteError::LoginFailed)?; - let user_id = format!("@{}:{}", session.user.username, config.homeserver); // If the client asked for a refreshable token, make it expire @@ -190,3 +215,29 @@ pub(crate) async fn post( expires_in_ms: expires_in, })) } + +async fn token_login( + txn: &mut Transaction<'_, Postgres>, + token: &str, +) -> Result, RouteError> { + let login = get_compat_sso_login_by_token(&mut *txn, token).await?; + let login = mark_compat_sso_login_as_exchanged(&mut *txn, login).await?; + + match login.state { + mas_data_model::CompatSsoLoginState::Exchanged { session, .. } => Ok(session), + _ => unreachable!(), + } +} + +async fn user_password_login( + txn: &mut Transaction<'_, Postgres>, + username: String, + password: String, +) -> Result, RouteError> { + let device = Device::generate(&mut thread_rng()); + let session = compat_login(txn, &username, &password, device) + .await + .map_err(|_| RouteError::LoginFailed)?; + + Ok(session) +} diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs new file mode 100644 index 00000000..6d0dc01d --- /dev/null +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -0,0 +1,86 @@ +// 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 std::collections::HashMap; + +use axum::{ + extract::Path, + response::{IntoResponse, Redirect, Response}, + Extension, +}; +use axum_extra::extract::PrivateCookieJar; +use mas_axum_utils::{FancyError, SessionInfoExt}; +use mas_config::Encrypter; +use mas_data_model::Device; +use mas_router::Route; +use mas_storage::compat::{fullfill_compat_sso_login, get_compat_sso_login_by_id}; +use rand::thread_rng; +use serde::Serialize; +use sqlx::PgPool; + +#[derive(Serialize)] +struct AllParams<'s> { + #[serde(flatten, skip_serializing_if = "Option::is_none")] + existing_params: Option>, + + #[serde(rename = "loginToken")] + login_token: &'s str, +} + +pub async fn get( + Extension(pool): Extension, + cookie_jar: PrivateCookieJar, + Path(id): Path, +) -> Result { + let mut txn = pool.begin().await?; + + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info.load_session(&mut txn).await?; + + let session = if let Some(session) = maybe_session { + session + } else { + // If there is no session, redirect to the login screen + let login = mas_router::Login::and_continue_compat_sso_login(id); + return Ok((cookie_jar, login.go()).into_response()); + }; + + let login = get_compat_sso_login_by_id(&mut txn, id).await?; + + let redirect_uri = { + let mut redirect_uri = login.redirect_uri.clone(); + let existing_params = redirect_uri + .query() + .map(serde_urlencoded::from_str) + .transpose()? + .unwrap_or_default(); + + let params = AllParams { + existing_params, + login_token: &login.token, + }; + let query = serde_urlencoded::to_string(¶ms)?; + redirect_uri.set_query(Some(&query)); + redirect_uri + }; + + let device = Device::generate(&mut thread_rng()); + let _login = fullfill_compat_sso_login(&mut txn, session.user, login, device).await?; + + txn.commit().await?; + + Ok((cookie_jar, Redirect::to(redirect_uri.as_str())).into_response()) +} diff --git a/crates/handlers/src/compat/login_sso_redirect.rs b/crates/handlers/src/compat/login_sso_redirect.rs new file mode 100644 index 00000000..c3993785 --- /dev/null +++ b/crates/handlers/src/compat/login_sso_redirect.rs @@ -0,0 +1,76 @@ +// 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 axum::{extract::Query, response::IntoResponse, Extension}; +use hyper::StatusCode; +use mas_router::{CompatLoginSsoComplete, UrlBuilder}; +use mas_storage::compat::insert_compat_sso_login; +use rand::{ + distributions::{Alphanumeric, DistString}, + thread_rng, +}; +use serde::Deserialize; +use sqlx::PgPool; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Deserialize)] +pub struct Params { + #[serde(rename = "redirectUrl")] + redirect_url: Option, +} + +#[derive(Debug, Error)] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error(transparent)] + Anyhow(#[from] anyhow::Error), + + #[error("missing redirect_url")] + MissingRedirectUrl, + + #[error("invalid redirect_url")] + InvalidRedirectUrl, +} + +impl From for RouteError { + fn from(e: sqlx::Error) -> Self { + Self::Internal(Box::new(e)) + } +} + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", self)).into_response() + } +} + +#[tracing::instrument(skip(pool, url_builder), err)] +pub async fn get( + Extension(pool): Extension, + Extension(url_builder): Extension, + Query(params): Query, +) -> Result { + let redirect_url = params.redirect_url.ok_or(RouteError::MissingRedirectUrl)?; + let redirect_url = Url::parse(&redirect_url).map_err(|_| RouteError::InvalidRedirectUrl)?; + + let token = Alphanumeric.sample_string(&mut thread_rng(), 32); + let mut conn = pool.acquire().await?; + let login = insert_compat_sso_login(&mut conn, token, redirect_url).await?; + + Ok(url_builder.absolute_redirect(&CompatLoginSsoComplete(login.data))) +} diff --git a/crates/handlers/src/compat/mod.rs b/crates/handlers/src/compat/mod.rs index 59b4d25a..3ae2030c 100644 --- a/crates/handlers/src/compat/mod.rs +++ b/crates/handlers/src/compat/mod.rs @@ -17,6 +17,8 @@ use hyper::StatusCode; use serde::Serialize; pub(crate) mod login; +pub(crate) mod login_sso_complete; +pub(crate) mod login_sso_redirect; pub(crate) mod logout; pub(crate) mod refresh; diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index a0c91c54..4c82e2b4 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -183,6 +183,18 @@ where mas_router::Consent::route(), get(self::oauth2::consent::get).post(self::oauth2::consent::post), ) + .route( + mas_router::CompatLoginSsoRedirect::route(), + get(self::compat::login_sso_redirect::get), + ) + .route( + mas_router::CompatLoginSsoRedirectIdp::route(), + get(self::compat::login_sso_redirect::get), + ) + .route( + mas_router::CompatLoginSsoComplete::route(), + get(self::compat::login_sso_complete::get), + ) .layer(ThenLayer::new( move |result: Result| async move { let response = result.unwrap(); diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index 694a831c..bd54e831 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -41,6 +41,9 @@ impl OptionalPostAuthAction { let grant = Box::new(grant.into()); Ok(Some(PostAuthContext::ContinueAuthorizationGrant { grant })) } + Some(PostAuthAction::ContinueCompatSsoLogin { .. }) => { + Ok(Some(PostAuthContext::ContinueCompatSsoLogin)) + } Some(PostAuthAction::ChangePassword) => Ok(Some(PostAuthContext::ChangePassword)), None => Ok(None), } diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index b964f7df..51320802 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -23,6 +23,10 @@ pub enum PostAuthAction { #[serde(deserialize_with = "serde_with::rust::display_fromstr::deserialize")] data: i64, }, + ContinueCompatSsoLogin { + #[serde(deserialize_with = "serde_with::rust::display_fromstr::deserialize")] + data: i64, + }, ChangePassword, } @@ -32,10 +36,16 @@ impl PostAuthAction { PostAuthAction::ContinueAuthorizationGrant { data } } + #[must_use] + pub fn continue_compat_sso_login(data: i64) -> Self { + PostAuthAction::ContinueCompatSsoLogin { data } + } + #[must_use] pub fn go_next(&self) -> axum::response::Redirect { match self { Self::ContinueAuthorizationGrant { data } => ContinueAuthorizationGrant(*data).go(), + Self::ContinueCompatSsoLogin { data } => CompatLoginSsoComplete(*data).go(), Self::ChangePassword => AccountPassword.go(), } } @@ -161,6 +171,13 @@ impl Login { } } + #[must_use] + pub fn and_continue_compat_sso_login(data: i64) -> Self { + Self { + post_auth_action: Some(PostAuthAction::continue_compat_sso_login(data)), + } + } + /// Get a reference to the login's post auth action. #[must_use] pub fn post_auth_action(&self) -> Option<&PostAuthAction> { @@ -387,3 +404,31 @@ pub struct CompatRefresh; impl SimpleRoute for CompatRefresh { const PATH: &'static str = "/_matrix/client/:version/refresh"; } + +/// `POST /_matrix/client/v3/login/sso/redirect` +pub struct CompatLoginSsoRedirect; + +impl SimpleRoute for CompatLoginSsoRedirect { + const PATH: &'static str = "/_matrix/client/:version/login/sso/redirect"; +} + +/// `POST /_matrix/client/v3/login/sso/redirect/:idp` +pub struct CompatLoginSsoRedirectIdp; + +impl SimpleRoute for CompatLoginSsoRedirectIdp { + const PATH: &'static str = "/_matrix/client/:version/login/sso/redirect/:idp"; +} + +/// `GET|POST /complete-compat-sso/:id` +pub struct CompatLoginSsoComplete(pub i64); + +impl Route for CompatLoginSsoComplete { + type Query = (); + fn route() -> &'static str { + "/complete-compat-sso/:grant_id" + } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/complete-compat-sso/{}", self.0).into() + } +} diff --git a/crates/router/src/traits.rs b/crates/router/src/traits.rs index 4068bd57..632c49a0 100644 --- a/crates/router/src/traits.rs +++ b/crates/router/src/traits.rs @@ -46,6 +46,10 @@ pub trait Route { fn go(&self) -> axum::response::Redirect { axum::response::Redirect::to(&self.relative_url()) } + + fn go_absolute(&self, base: &Url) -> axum::response::Redirect { + axum::response::Redirect::to(self.absolute_url(base).as_str()) + } } pub trait SimpleRoute { diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index 13697fec..5c0098e5 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -31,6 +31,13 @@ impl UrlBuilder { destination.absolute_url(&self.base) } + pub fn absolute_redirect(&self, destination: &U) -> axum::response::Redirect + where + U: Route, + { + destination.go_absolute(&self.base) + } + /// Create a new [`UrlBuilder`] from a base URL #[must_use] pub fn new(base: Url) -> Self { diff --git a/crates/storage/migrations/20220519113358_compat_sso_login.down.sql b/crates/storage/migrations/20220519113358_compat_sso_login.down.sql new file mode 100644 index 00000000..b998a2aa --- /dev/null +++ b/crates/storage/migrations/20220519113358_compat_sso_login.down.sql @@ -0,0 +1,15 @@ +-- 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. + +DROP TABLE compat_sso_logins; diff --git a/crates/storage/migrations/20220519113358_compat_sso_login.up.sql b/crates/storage/migrations/20220519113358_compat_sso_login.up.sql new file mode 100644 index 00000000..8ea0fe4f --- /dev/null +++ b/crates/storage/migrations/20220519113358_compat_sso_login.up.sql @@ -0,0 +1,25 @@ +-- 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. + +CREATE TABLE compat_sso_logins ( + "id" BIGSERIAL PRIMARY KEY, + "redirect_uri" TEXT NOT NULL, + "token" TEXT UNIQUE NOT NULL, + + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "fullfilled_at" TIMESTAMP WITH TIME ZONE, + "exchanged_at" TIMESTAMP WITH TIME ZONE, + + "compat_session_id" BIGINT REFERENCES compat_sessions (id) ON DELETE CASCADE +); diff --git a/crates/storage/sqlx-data.json b/crates/storage/sqlx-data.json index 3d3156e6..420d4548 100644 --- a/crates/storage/sqlx-data.json +++ b/crates/storage/sqlx-data.json @@ -346,6 +346,122 @@ }, "query": "\n INSERT INTO user_sessions (user_id)\n VALUES ($1)\n RETURNING id, created_at\n " }, + "32c94e013dc6cc0422dd8bc9ceaaf9100fee09df0cf52f84086a619de2fbbaaf": { + "describe": { + "columns": [ + { + "name": "compat_refresh_token_id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "compat_refresh_token", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "compat_refresh_token_created_at", + "ordinal": 2, + "type_info": "Timestamptz" + }, + { + "name": "compat_access_token_id", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "compat_access_token", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "compat_access_token_created_at", + "ordinal": 5, + "type_info": "Timestamptz" + }, + { + "name": "compat_access_token_expires_at", + "ordinal": 6, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_id", + "ordinal": 7, + "type_info": "Int8" + }, + { + "name": "compat_session_created_at", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_deleted_at", + "ordinal": 9, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_device_id", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "user_id!", + "ordinal": 11, + "type_info": "Int8" + }, + { + "name": "user_username!", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "user_email_id?", + "ordinal": 13, + "type_info": "Int8" + }, + { + "name": "user_email?", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "user_email_created_at?", + "ordinal": 15, + "type_info": "Timestamptz" + }, + { + "name": "user_email_confirmed_at?", + "ordinal": 16, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT\n cr.id AS \"compat_refresh_token_id\",\n cr.token AS \"compat_refresh_token\",\n cr.created_at AS \"compat_refresh_token_created_at\",\n ct.id AS \"compat_access_token_id\",\n ct.token AS \"compat_access_token\",\n ct.created_at AS \"compat_access_token_created_at\",\n ct.expires_at AS \"compat_access_token_expires_at\",\n cs.id AS \"compat_session_id\",\n cs.created_at AS \"compat_session_created_at\",\n cs.deleted_at AS \"compat_session_deleted_at\",\n cs.device_id AS \"compat_session_device_id\",\n u.id AS \"user_id!\",\n u.username AS \"user_username!\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n\n FROM compat_refresh_tokens cr\n INNER JOIN compat_access_tokens ct\n ON ct.id = cr.compat_access_token_id\n INNER JOIN compat_sessions cs\n ON cs.id = cr.compat_session_id\n INNER JOIN users u\n ON u.id = cs.user_id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE cr.token = $1\n AND cr.next_token_id IS NULL\n AND cs.deleted_at IS NULL\n " + }, "366ea127c7b220960f17fd1b651600826ac10b8baf92f0e936fd07f34a7dc0fc": { "describe": { "columns": [], @@ -470,6 +586,34 @@ }, "query": "\n SELECT\n s.id,\n u.id AS user_id,\n u.username,\n s.created_at,\n a.id AS \"last_authentication_id?\",\n a.created_at AS \"last_authd_at?\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n FROM user_sessions s\n INNER JOIN users u \n ON s.user_id = u.id\n LEFT JOIN user_session_authentications a\n ON a.session_id = s.id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n WHERE s.id = $1 AND s.active\n ORDER BY a.created_at DESC\n LIMIT 1\n " }, + "4a6bee8775e2c614a28dc691e7e59d0e685859dc6cda07296326f2d9cfb09114": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "created_at", + "ordinal": 1, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Interval" + ] + } + }, + "query": "\n INSERT INTO compat_access_tokens (compat_session_id, token, created_at, expires_at)\n VALUES ($1, $2, NOW(), NOW() + $3)\n RETURNING id, created_at\n " + }, "4b9de6face2e21117c947b4f550cc747ad8397b6dfadb6bc6a84124763dc66e8": { "describe": { "columns": [], @@ -503,6 +647,33 @@ }, "query": "\n SELECT scope_token\n FROM oauth2_consents\n WHERE user_id = $1 AND oauth2_client_id = $2\n " }, + "51d148123a4a4254f3fc16574a7136ed015808d5e967f00431f1f9ed12f72c93": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "created_at", + "ordinal": 1, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "\n INSERT INTO compat_sessions (user_id, device_id)\n VALUES ($1, $2)\n RETURNING id, created_at\n " + }, "581243a7f0c033548cc9644e0c60855ecb8bfefe51779eb135dd7547b886de79": { "describe": { "columns": [], @@ -724,7 +895,7 @@ }, "query": "\n INSERT INTO oauth2_clients\n (client_id,\n encrypted_client_secret,\n response_types,\n grant_type_authorization_code,\n grant_type_refresh_token,\n contacts,\n client_name,\n logo_uri,\n client_uri,\n policy_uri,\n tos_uri,\n jwks_uri,\n jwks,\n id_token_signed_response_alg,\n userinfo_signed_response_alg,\n token_endpoint_auth_method,\n token_endpoint_auth_signing_alg,\n initiate_login_uri)\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)\n RETURNING id\n " }, - "5ee505120c3bfddccd7c933de356dd035d18d56316ddf4d0be0d13530b8a643c": { + "63522dddec4f2218fdb099473a80411d7f1c19b3750e27bb1f087357588e40c9": { "describe": { "columns": [ { @@ -820,7 +991,7 @@ ] } }, - "query": "\n SELECT\n ct.id AS \"compat_access_token_id\",\n ct.token AS \"compat_access_token\",\n ct.created_at AS \"compat_access_token_created_at\",\n ct.expires_at AS \"compat_access_token_expires_at\",\n cs.id AS \"compat_session_id\",\n cs.created_at AS \"compat_session_created_at\",\n cs.deleted_at AS \"compat_session_deleted_at\",\n cs.device_id AS \"compat_session_device_id\",\n u.id AS \"user_id!\",\n u.username AS \"user_username!\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n\n FROM compat_access_tokens ct\n INNER JOIN compat_sessions cs\n ON cs.id = ct.compat_session_id\n INNER JOIN users u\n ON u.id = cs.user_id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE ct.token = $1\n AND (ct.expires_at IS NULL OR ct.expires_at > NOW())\n AND cs.deleted_at IS NULL\n " + "query": "\n SELECT\n ct.id AS \"compat_access_token_id\",\n ct.token AS \"compat_access_token\",\n ct.created_at AS \"compat_access_token_created_at\",\n ct.expires_at AS \"compat_access_token_expires_at\",\n cs.id AS \"compat_session_id\",\n cs.created_at AS \"compat_session_created_at\",\n cs.deleted_at AS \"compat_session_deleted_at\",\n cs.device_id AS \"compat_session_device_id\",\n u.id AS \"user_id!\",\n u.username AS \"user_username!\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n\n FROM compat_access_tokens ct\n INNER JOIN compat_sessions cs\n ON cs.id = ct.compat_session_id\n INNER JOIN users u\n ON u.id = cs.user_id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE ct.token = $1\n AND (ct.expires_at IS NULL OR ct.expires_at > NOW())\n AND cs.deleted_at IS NULL\n " }, "647a2a5bbde39d0ed3931d0287b468bc7dedf6171e1dc6171a5d9f079b9ed0fa": { "describe": { @@ -1217,6 +1388,226 @@ }, "query": "\n UPDATE user_emails\n SET confirmed_at = NOW()\n WHERE id = $1\n RETURNING confirmed_at\n " }, + "81c673253d86035037695e0d2a3e24bfc8bbb5603c9291f9b5bcac64b43e1c04": { + "describe": { + "columns": [ + { + "name": "compat_sso_login_id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "compat_sso_login_token", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "compat_sso_login_redirect_uri", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "compat_sso_login_created_at", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "compat_sso_login_fullfilled_at", + "ordinal": 4, + "type_info": "Timestamptz" + }, + { + "name": "compat_sso_login_exchanged_at", + "ordinal": 5, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_id?", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "compat_session_created_at?", + "ordinal": 7, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_deleted_at?", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_device_id?", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "user_id?", + "ordinal": 10, + "type_info": "Int8" + }, + { + "name": "user_username?", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "user_email_id?", + "ordinal": 12, + "type_info": "Int8" + }, + { + "name": "user_email?", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "user_email_created_at?", + "ordinal": 14, + "type_info": "Timestamptz" + }, + { + "name": "user_email_confirmed_at?", + "ordinal": 15, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT\n cl.id AS \"compat_sso_login_id\",\n cl.token AS \"compat_sso_login_token\",\n cl.redirect_uri AS \"compat_sso_login_redirect_uri\",\n cl.created_at AS \"compat_sso_login_created_at\",\n cl.fullfilled_at AS \"compat_sso_login_fullfilled_at\",\n cl.exchanged_at AS \"compat_sso_login_exchanged_at\",\n cs.id AS \"compat_session_id?\",\n cs.created_at AS \"compat_session_created_at?\",\n cs.deleted_at AS \"compat_session_deleted_at?\",\n cs.device_id AS \"compat_session_device_id?\",\n u.id AS \"user_id?\",\n u.username AS \"user_username?\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n FROM compat_sso_logins cl\n LEFT JOIN compat_sessions cs\n ON cs.id = cl.compat_session_id\n LEFT JOIN users u\n ON u.id = cs.user_id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n WHERE cl.id = $1\n " + }, + "860722788c244caf722d1941e4b83aa421fd179586f9a1c2342c539fcb6c6361": { + "describe": { + "columns": [ + { + "name": "compat_sso_login_id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "compat_sso_login_token", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "compat_sso_login_redirect_uri", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "compat_sso_login_created_at", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "compat_sso_login_fullfilled_at", + "ordinal": 4, + "type_info": "Timestamptz" + }, + { + "name": "compat_sso_login_exchanged_at", + "ordinal": 5, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_id?", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "compat_session_created_at?", + "ordinal": 7, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_deleted_at?", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "compat_session_device_id?", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "user_id?", + "ordinal": 10, + "type_info": "Int8" + }, + { + "name": "user_username?", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "user_email_id?", + "ordinal": 12, + "type_info": "Int8" + }, + { + "name": "user_email?", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "user_email_created_at?", + "ordinal": 14, + "type_info": "Timestamptz" + }, + { + "name": "user_email_confirmed_at?", + "ordinal": 15, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT\n cl.id AS \"compat_sso_login_id\",\n cl.token AS \"compat_sso_login_token\",\n cl.redirect_uri AS \"compat_sso_login_redirect_uri\",\n cl.created_at AS \"compat_sso_login_created_at\",\n cl.fullfilled_at AS \"compat_sso_login_fullfilled_at\",\n cl.exchanged_at AS \"compat_sso_login_exchanged_at\",\n cs.id AS \"compat_session_id?\",\n cs.created_at AS \"compat_session_created_at?\",\n cs.deleted_at AS \"compat_session_deleted_at?\",\n cs.device_id AS \"compat_session_device_id?\",\n u.id AS \"user_id?\",\n u.username AS \"user_username?\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n FROM compat_sso_logins cl\n LEFT JOIN compat_sessions cs\n ON cs.id = cl.compat_session_id\n LEFT JOIN users u\n ON u.id = cs.user_id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n WHERE cl.token = $1\n " + }, "88ac8783bd5881c42eafd9cf87a16fe6031f3153fd6a8618e689694584aeb2de": { "describe": { "columns": [], @@ -1256,7 +1647,7 @@ }, "query": "\n INSERT INTO compat_sessions (user_id, device_id)\n VALUES ($1, $2)\n RETURNING id, created_at\n " }, - "8aed8f0b7aec4854f8dfc88f43e3e6029ef563189eff6ed1e33c3421b395040c": { + "929605e8e86ab15a34721b8cbbe29f1bff90102e5641bc49ded86f6539810c73": { "describe": { "columns": [ { @@ -1276,13 +1667,12 @@ ], "parameters": { "Left": [ - "Int8", "Text", - "Interval" + "Text" ] } }, - "query": "\n INSERT INTO compat_access_tokens (compat_session_id, token, created_at, expires_at)\n VALUES ($1, $2, NOW(), NOW() + $3)\n RETURNING id, created_at\n " + "query": "\n INSERT INTO compat_sso_logins (token, redirect_uri)\n VALUES ($1, $2)\n RETURNING id, created_at\n " }, "9882e49f34dff80c1442565f035a1b47ed4dbae1a405f58cf2db198885bb9f47": { "describe": { @@ -1522,122 +1912,6 @@ }, "query": "\n INSERT INTO oauth2_client_redirect_uris (oauth2_client_id, redirect_uri)\n SELECT $1, uri FROM UNNEST($2::text[]) uri\n " }, - "ab800ea65b9c703a56b6c3b7dd47402dbbe0c9900f6d965c908b84332b2aa148": { - "describe": { - "columns": [ - { - "name": "compat_refresh_token_id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "compat_refresh_token", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "compat_refresh_token_created_at", - "ordinal": 2, - "type_info": "Timestamptz" - }, - { - "name": "compat_access_token_id", - "ordinal": 3, - "type_info": "Int8" - }, - { - "name": "compat_access_token", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "compat_access_token_created_at", - "ordinal": 5, - "type_info": "Timestamptz" - }, - { - "name": "compat_access_token_expires_at", - "ordinal": 6, - "type_info": "Timestamptz" - }, - { - "name": "compat_session_id", - "ordinal": 7, - "type_info": "Int8" - }, - { - "name": "compat_session_created_at", - "ordinal": 8, - "type_info": "Timestamptz" - }, - { - "name": "compat_session_deleted_at", - "ordinal": 9, - "type_info": "Timestamptz" - }, - { - "name": "compat_session_device_id", - "ordinal": 10, - "type_info": "Text" - }, - { - "name": "user_id!", - "ordinal": 11, - "type_info": "Int8" - }, - { - "name": "user_username!", - "ordinal": 12, - "type_info": "Text" - }, - { - "name": "user_email_id?", - "ordinal": 13, - "type_info": "Int8" - }, - { - "name": "user_email?", - "ordinal": 14, - "type_info": "Text" - }, - { - "name": "user_email_created_at?", - "ordinal": 15, - "type_info": "Timestamptz" - }, - { - "name": "user_email_confirmed_at?", - "ordinal": 16, - "type_info": "Timestamptz" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - false, - false, - true, - false, - false, - false, - false, - false, - false, - true - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT\n cr.id AS \"compat_refresh_token_id\",\n cr.token AS \"compat_refresh_token\",\n cr.created_at AS \"compat_refresh_token_created_at\",\n ct.id AS \"compat_access_token_id\",\n ct.token AS \"compat_access_token\",\n ct.created_at AS \"compat_access_token_created_at\",\n ct.expires_at AS \"compat_access_token_expires_at\",\n cs.id AS \"compat_session_id\",\n cs.created_at AS \"compat_session_created_at\",\n cs.deleted_at AS \"compat_session_deleted_at\",\n cs.device_id AS \"compat_session_device_id\",\n u.id AS \"user_id!\",\n u.username AS \"user_username!\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n\n FROM compat_refresh_tokens cr\n INNER JOIN compat_access_tokens ct\n ON ct.id = cr.compat_access_token_id\n INNER JOIN compat_sessions cs\n ON cs.id = cr.compat_session_id\n INNER JOIN users u\n ON u.id = cs.user_id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE cr.token = $1\n AND cr.next_token_id IS NULL\n AND cs.deleted_at IS NULL\n " - }, "aea289a04e151da235825305a5085bc6aa100fce139dbf10a2c1bed4867fc52a": { "describe": { "columns": [ @@ -1822,6 +2096,27 @@ }, "query": "\n SELECT\n ev.id AS \"verification_id\",\n (ev.created_at + $2 < NOW()) AS \"verification_expired!\",\n ev.created_at AS \"verification_created_at\",\n ev.consumed_at AS \"verification_consumed_at\",\n ue.id AS \"user_email_id\",\n ue.email AS \"user_email\",\n ue.created_at AS \"user_email_created_at\",\n ue.confirmed_at AS \"user_email_confirmed_at\"\n FROM user_email_verifications ev\n INNER JOIN user_emails ue\n ON ue.id = ev.user_email_id\n WHERE ev.code = $1\n " }, + "bb5c43feb0e2ac1b8b31ab18423176057e795618ac64527bfb04fee91d86be2a": { + "describe": { + "columns": [ + { + "name": "fullfilled_at!", + "ordinal": 0, + "type_info": "Timestamptz" + } + ], + "nullable": [ + true + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE compat_sso_logins\n SET\n fullfilled_at = NOW(),\n compat_session_id = $2\n WHERE\n id = $1\n RETURNING fullfilled_at AS \"fullfilled_at!\"\n " + }, "c2c402cfe0adcafa615f14a499caba4c96ca71d9ffb163e1feb05e5d85f3462c": { "describe": { "columns": [], @@ -1835,32 +2130,25 @@ }, "query": "\n UPDATE oauth2_refresh_tokens\n SET next_token_id = $2\n WHERE id = $1\n " }, - "c2de0bb4b67834bd6dd8106c3fcf9874d0e9d1c5d54cbbedfa070dcd0b372475": { + "cd14bbd315bec758b846f619202fdfd26634dfdcc185d5117a394b556c019473": { "describe": { "columns": [ { - "name": "id", + "name": "exchanged_at!", "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "created_at", - "ordinal": 1, "type_info": "Timestamptz" } ], "nullable": [ - false, - false + true ], "parameters": { "Left": [ - "Int8", - "Text" + "Int8" ] } }, - "query": "\n INSERT INTO compat_access_tokens (compat_session_id, token)\n VALUES ($1, $2)\n RETURNING id, created_at\n " + "query": "\n UPDATE compat_sso_logins\n SET\n exchanged_at = NOW()\n WHERE\n id = $1\n RETURNING exchanged_at AS \"exchanged_at!\"\n " }, "d2f767218ec2489058db9a0382ca0eea20379c30aeae9f492da4ba35b66f4dc7": { "describe": { @@ -1933,6 +2221,33 @@ }, "query": "\n INSERT INTO user_session_authentications (session_id)\n VALUES ($1)\n RETURNING id, created_at\n " }, + "d9deefd13877e64c44fa7b60d07e97380ca6a9612b3f08fb8c341b32c3a63a27": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "created_at", + "ordinal": 1, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "\n INSERT INTO compat_access_tokens (compat_session_id, token)\n VALUES ($1, $2)\n RETURNING id, created_at\n " + }, "db34b3d7fa5d824e63f388d660615d748e11c1406e8166da907e0a54a665e37a": { "describe": { "columns": [ diff --git a/crates/storage/src/compat.rs b/crates/storage/src/compat.rs index 32d31482..8b1e06dc 100644 --- a/crates/storage/src/compat.rs +++ b/crates/storage/src/compat.rs @@ -12,16 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -use anyhow::Context; +use anyhow::{bail, Context}; use argon2::{Argon2, PasswordHash}; use chrono::{DateTime, Duration, Utc}; use mas_data_model::{ - CompatAccessToken, CompatRefreshToken, CompatSession, Device, User, UserEmail, + CompatAccessToken, CompatRefreshToken, CompatSession, CompatSsoLogin, CompatSsoLoginState, + Device, User, UserEmail, }; use sqlx::{postgres::types::PgInterval, Acquire, PgExecutor, Postgres}; use thiserror::Error; use tokio::task; use tracing::{info_span, Instrument}; +use url::Url; use crate::{ user::lookup_user_by_username, DatabaseInconsistencyError, IdAndCreationTime, PostgresqlBackend, @@ -98,8 +100,8 @@ pub async fn lookup_active_compat_access_token( WHERE ct.token = $1 AND (ct.expires_at IS NULL OR ct.expires_at > NOW()) - AND cs.deleted_at IS NULL - "#, + AND cs.deleted_at IS NULL + "#, token, ) .fetch_one(executor) @@ -210,8 +212,8 @@ pub async fn lookup_active_compat_refresh_token( cs.created_at AS "compat_session_created_at", cs.deleted_at AS "compat_session_deleted_at", cs.device_id AS "compat_session_device_id", - u.id AS "user_id!", - u.username AS "user_username!", + u.id AS "user_id!", + u.username AS "user_username!", ue.id AS "user_email_id?", ue.email AS "user_email?", ue.created_at AS "user_email_created_at?", @@ -367,10 +369,10 @@ pub async fn add_compat_access_token( let res = sqlx::query_as!( IdAndCreationTime, r#" - INSERT INTO compat_access_tokens (compat_session_id, token, created_at, expires_at) - VALUES ($1, $2, NOW(), NOW() + $3) - RETURNING id, created_at - "#, + INSERT INTO compat_access_tokens (compat_session_id, token, created_at, expires_at) + VALUES ($1, $2, NOW(), NOW() + $3) + RETURNING id, created_at + "#, session.data, token, pg_expires_after, @@ -390,10 +392,10 @@ pub async fn add_compat_access_token( let res = sqlx::query_as!( IdAndCreationTime, r#" - INSERT INTO compat_access_tokens (compat_session_id, token) - VALUES ($1, $2) - RETURNING id, created_at - "#, + INSERT INTO compat_access_tokens (compat_session_id, token) + VALUES ($1, $2) + RETURNING id, created_at + "#, session.data, token, ) @@ -518,3 +520,324 @@ pub async fn replace_compat_refresh_token( )) } } + +pub async fn insert_compat_sso_login( + executor: impl PgExecutor<'_>, + token: String, + redirect_uri: Url, +) -> anyhow::Result> { + let res = sqlx::query_as!( + IdAndCreationTime, + r#" + INSERT INTO compat_sso_logins (token, redirect_uri) + VALUES ($1, $2) + RETURNING id, created_at + "#, + &token, + redirect_uri.as_str(), + ) + .fetch_one(executor) + .instrument(tracing::info_span!("Insert compat SSO login")) + .await + .context("could not insert compat SSO login")?; + + Ok(CompatSsoLogin { + data: res.id, + token, + redirect_uri, + created_at: res.created_at, + state: CompatSsoLoginState::Pending, + }) +} + +struct CompatSsoLoginLookup { + compat_sso_login_id: i64, + compat_sso_login_token: String, + compat_sso_login_redirect_uri: String, + compat_sso_login_created_at: DateTime, + compat_sso_login_fullfilled_at: Option>, + compat_sso_login_exchanged_at: Option>, + compat_session_id: Option, + compat_session_created_at: Option>, + compat_session_deleted_at: Option>, + compat_session_device_id: Option, + user_id: Option, + user_username: Option, + user_email_id: Option, + user_email: Option, + user_email_created_at: Option>, + user_email_confirmed_at: Option>, +} + +impl TryFrom for CompatSsoLogin { + type Error = DatabaseInconsistencyError; + + fn try_from(res: CompatSsoLoginLookup) -> Result { + let redirect_uri = Url::parse(&res.compat_sso_login_redirect_uri) + .map_err(|_| DatabaseInconsistencyError)?; + + let primary_email = match ( + res.user_email_id, + res.user_email, + res.user_email_created_at, + res.user_email_confirmed_at, + ) { + (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { + data: id, + email, + created_at, + confirmed_at, + }), + (None, None, None, None) => None, + _ => return Err(DatabaseInconsistencyError), + }; + + let user = match (res.user_id, res.user_username, primary_email) { + (Some(id), Some(username), primary_email) => Some(User { + data: id, + username, + sub: format!("fake-sub-{}", id), + primary_email, + }), + (None, None, None) => None, + _ => return Err(DatabaseInconsistencyError), + }; + + let session = match ( + res.compat_session_id, + res.compat_session_device_id, + res.compat_session_created_at, + res.compat_session_deleted_at, + user, + ) { + (Some(id), Some(device_id), Some(created_at), deleted_at, Some(user)) => { + let device = Device::try_from(device_id).map_err(|_| DatabaseInconsistencyError)?; + Some(CompatSession { + data: id, + user, + device, + created_at, + deleted_at, + }) + } + (None, None, None, None, None) => None, + _ => return Err(DatabaseInconsistencyError), + }; + + let state = match ( + res.compat_sso_login_fullfilled_at, + res.compat_sso_login_exchanged_at, + session, + ) { + (None, None, None) => CompatSsoLoginState::Pending, + (Some(fullfilled_at), None, Some(session)) => CompatSsoLoginState::Fullfilled { + fullfilled_at, + session, + }, + (Some(fullfilled_at), Some(exchanged_at), Some(session)) => { + CompatSsoLoginState::Exchanged { + fullfilled_at, + exchanged_at, + session, + } + } + _ => return Err(DatabaseInconsistencyError), + }; + + Ok(CompatSsoLogin { + data: res.compat_sso_login_id, + token: res.compat_sso_login_token, + redirect_uri, + created_at: res.compat_sso_login_created_at, + state, + }) + } +} + +#[allow(clippy::too_many_lines)] +#[tracing::instrument(skip(executor), err)] +pub async fn get_compat_sso_login_by_id( + executor: impl PgExecutor<'_>, + id: i64, +) -> anyhow::Result> { + let res = sqlx::query_as!( + CompatSsoLoginLookup, + r#" + SELECT + cl.id AS "compat_sso_login_id", + cl.token AS "compat_sso_login_token", + cl.redirect_uri AS "compat_sso_login_redirect_uri", + cl.created_at AS "compat_sso_login_created_at", + cl.fullfilled_at AS "compat_sso_login_fullfilled_at", + cl.exchanged_at AS "compat_sso_login_exchanged_at", + cs.id AS "compat_session_id?", + cs.created_at AS "compat_session_created_at?", + cs.deleted_at AS "compat_session_deleted_at?", + cs.device_id AS "compat_session_device_id?", + u.id AS "user_id?", + u.username AS "user_username?", + ue.id AS "user_email_id?", + ue.email AS "user_email?", + ue.created_at AS "user_email_created_at?", + ue.confirmed_at AS "user_email_confirmed_at?" + FROM compat_sso_logins cl + LEFT JOIN compat_sessions cs + ON cs.id = cl.compat_session_id + LEFT JOIN users u + ON u.id = cs.user_id + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id + WHERE cl.id = $1 + "#, + id, + ) + .fetch_one(executor) + .instrument(tracing::info_span!("Lookup compat SSO login")) + .await + .context("could not lookup compat SSO login")?; + + Ok(res.try_into()?) +} + +#[allow(clippy::too_many_lines)] +#[tracing::instrument(skip(executor), err)] +pub async fn get_compat_sso_login_by_token( + executor: impl PgExecutor<'_>, + token: &str, +) -> anyhow::Result> { + let res = sqlx::query_as!( + CompatSsoLoginLookup, + r#" + SELECT + cl.id AS "compat_sso_login_id", + cl.token AS "compat_sso_login_token", + cl.redirect_uri AS "compat_sso_login_redirect_uri", + cl.created_at AS "compat_sso_login_created_at", + cl.fullfilled_at AS "compat_sso_login_fullfilled_at", + cl.exchanged_at AS "compat_sso_login_exchanged_at", + cs.id AS "compat_session_id?", + cs.created_at AS "compat_session_created_at?", + cs.deleted_at AS "compat_session_deleted_at?", + cs.device_id AS "compat_session_device_id?", + u.id AS "user_id?", + u.username AS "user_username?", + ue.id AS "user_email_id?", + ue.email AS "user_email?", + ue.created_at AS "user_email_created_at?", + ue.confirmed_at AS "user_email_confirmed_at?" + FROM compat_sso_logins cl + LEFT JOIN compat_sessions cs + ON cs.id = cl.compat_session_id + LEFT JOIN users u + ON u.id = cs.user_id + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id + WHERE cl.token = $1 + "#, + token, + ) + .fetch_one(executor) + .instrument(tracing::info_span!("Lookup compat SSO login")) + .await + .context("could not lookup compat SSO login")?; + + Ok(res.try_into()?) +} + +pub async fn fullfill_compat_sso_login( + conn: impl Acquire<'_, Database = Postgres>, + user: User, + mut login: CompatSsoLogin, + device: Device, +) -> anyhow::Result> { + // TODO: check if login is in pending state + let mut txn = conn.begin().await.context("could not start transaction")?; + let res = sqlx::query_as!( + IdAndCreationTime, + r#" + INSERT INTO compat_sessions (user_id, device_id) + VALUES ($1, $2) + RETURNING id, created_at + "#, + user.data, + device.as_str(), + ) + .fetch_one(&mut txn) + .instrument(tracing::info_span!("Insert compat session")) + .await + .context("could not insert compat session")?; + + let session = CompatSession { + data: res.id, + user, + device, + created_at: res.created_at, + deleted_at: None, + }; + + let res = sqlx::query_scalar!( + r#" + UPDATE compat_sso_logins + SET + fullfilled_at = NOW(), + compat_session_id = $2 + WHERE + id = $1 + RETURNING fullfilled_at AS "fullfilled_at!" + "#, + login.data, + session.data, + ) + .fetch_one(&mut txn) + .instrument(tracing::info_span!("Update compat SSO login")) + .await + .context("could not update compat SSO login")?; + + let state = CompatSsoLoginState::Fullfilled { + fullfilled_at: res, + session, + }; + + login.state = state; + + txn.commit().await?; + + Ok(login) +} + +pub async fn mark_compat_sso_login_as_exchanged( + executor: impl PgExecutor<'_>, + mut login: CompatSsoLogin, +) -> anyhow::Result> { + let (fullfilled_at, session) = match login.state { + CompatSsoLoginState::Fullfilled { + fullfilled_at, + session, + } => (fullfilled_at, session), + _ => bail!("sso login in wrong state"), + }; + + let res = sqlx::query_scalar!( + r#" + UPDATE compat_sso_logins + SET + exchanged_at = NOW() + WHERE + id = $1 + RETURNING exchanged_at AS "exchanged_at!" + "#, + login.data, + ) + .fetch_one(executor) + .instrument(tracing::info_span!("Update compat SSO login")) + .await + .context("could not update compat SSO login")?; + + let state = CompatSsoLoginState::Exchanged { + fullfilled_at, + exchanged_at: res, + session, + }; + login.state = state; + Ok(login) +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index b0b75f3b..21a2194e 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -45,6 +45,7 @@ impl StorageBackend for PostgresqlBackend { type CompatAccessTokenData = i64; type CompatRefreshTokenData = i64; type CompatSessionData = i64; + type CompatSsoLoginData = i64; type RefreshTokenData = i64; type SessionData = i64; type UserData = i64; diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index a35d54fb..7d2d70cf 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -248,6 +248,10 @@ pub enum PostAuthContext { grant: Box>, }, + /// Continue legacy login + /// TODO: add the login context in there + ContinueCompatSsoLogin, + /// Change the account password ChangePassword, }