1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-28 11:02:02 +03:00

Database refactoring

This commit is contained in:
Quentin Gliech
2022-10-21 11:25:38 +02:00
parent 0571c36da9
commit e2142f9cd4
79 changed files with 3070 additions and 3833 deletions

19
Cargo.lock generated
View File

@ -2430,6 +2430,7 @@ dependencies = [
"tokio", "tokio",
"tower", "tower",
"tracing", "tracing",
"ulid",
"url", "url",
] ]
@ -2505,6 +2506,7 @@ dependencies = [
"thiserror", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
"ulid",
"url", "url",
] ]
@ -2575,6 +2577,7 @@ dependencies = [
"tower", "tower",
"tower-http", "tower-http",
"tracing", "tracing",
"ulid",
"url", "url",
] ]
@ -2744,6 +2747,7 @@ dependencies = [
"serde", "serde",
"serde_urlencoded", "serde_urlencoded",
"serde_with", "serde_with",
"ulid",
"url", "url",
] ]
@ -2781,7 +2785,9 @@ dependencies = [
"thiserror", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
"ulid",
"url", "url",
"uuid",
] ]
[[package]] [[package]]
@ -2813,6 +2819,7 @@ dependencies = [
"thiserror", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
"ulid",
"url", "url",
] ]
@ -4525,6 +4532,7 @@ dependencies = [
"thiserror", "thiserror",
"tokio-stream", "tokio-stream",
"url", "url",
"uuid",
"webpki-roots", "webpki-roots",
"whoami", "whoami",
] ]
@ -5153,6 +5161,17 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81"
[[package]]
name = "ulid"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a3aaa69b04e5b66cc27309710a569ea23593612387d67daaf102e73aa974fd"
dependencies = [
"rand",
"serde",
"uuid",
]
[[package]] [[package]]
name = "uncased" name = "uncased"
version = "0.9.7" version = "0.9.7"

View File

@ -4,3 +4,6 @@ members = ["crates/*"]
[profile.dev.package.num-bigint-dig] [profile.dev.package.num-bigint-dig]
opt-level = 3 opt-level = 3
[profile.dev.package.sqlx-macros]
opt-level = 3

View File

@ -28,6 +28,7 @@ tokio = "1.21.2"
tower = { version = "0.4.13", features = ["util"] } tower = { version = "0.4.13", features = ["util"] }
tracing = "0.1.37" tracing = "0.1.37"
url = "2.3.1" url = "2.3.1"
ulid = { version = "1.0.0", features = ["serde"] }
mas-data-model = { path = "../data-model" } mas-data-model = { path = "../data-model" }
mas-http = { path = "../http", features = ["client"] } mas-http = { path = "../http", features = ["client"] }

View File

@ -23,9 +23,11 @@ pub struct FancyError {
context: ErrorContext, context: ErrorContext,
} }
impl<E: std::fmt::Display> From<E> for FancyError { impl<E: std::fmt::Debug + std::fmt::Display> From<E> for FancyError {
fn from(err: E) -> Self { fn from(err: E) -> Self {
let context = ErrorContext::new().with_description(err.to_string()); let context = ErrorContext::new()
.with_description(format!("{err}"))
.with_details(format!("{err:?}"));
FancyError { context } FancyError { context }
} }
} }

View File

@ -20,13 +20,14 @@ use mas_storage::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Executor, Postgres}; use sqlx::{Executor, Postgres};
use ulid::Ulid;
use crate::CookieExt; use crate::CookieExt;
/// An encrypted cookie to save the session ID /// An encrypted cookie to save the session ID
#[derive(Serialize, Deserialize, Debug, Default)] #[derive(Serialize, Deserialize, Debug, Default)]
pub struct SessionInfo { pub struct SessionInfo {
current: Option<i64>, current: Option<Ulid>,
} }
impl SessionInfo { impl SessionInfo {

View File

@ -16,7 +16,7 @@ use argon2::Argon2;
use clap::Parser; use clap::Parser;
use mas_config::{DatabaseConfig, RootConfig}; use mas_config::{DatabaseConfig, RootConfig};
use mas_storage::{ use mas_storage::{
oauth2::client::{insert_client_from_config, lookup_client_by_client_id, truncate_clients}, oauth2::client::{insert_client_from_config, lookup_client, truncate_clients},
user::{ user::{
lookup_user_by_username, lookup_user_email, mark_user_email_as_verified, register_user, lookup_user_by_username, lookup_user_email, mark_user_email_as_verified, register_user,
}, },
@ -96,8 +96,8 @@ impl Options {
} }
for client in config.clients.iter() { for client in config.clients.iter() {
let client_id = &client.client_id; let client_id = client.client_id;
let res = lookup_client_by_client_id(&mut txn, client_id).await; let res = lookup_client(&mut txn, client_id).await;
match res { match res {
Ok(_) => { Ok(_) => {
warn!(%client_id, "Skipping already imported client"); warn!(%client_id, "Skipping already imported client");

View File

@ -17,6 +17,7 @@ schemars = { version = "0.8.11", features = ["url", "chrono"] }
figment = { version = "0.10.8", features = ["env", "yaml", "test"] } figment = { version = "0.10.8", features = ["env", "yaml", "test"] }
chrono = { version = "0.4.22", features = ["serde"] } chrono = { version = "0.4.22", features = ["serde"] }
url = { version = "2.3.1", features = ["serde"] } url = { version = "2.3.1", features = ["serde"] }
ulid = { version = "1.0.0", features = ["serde"] }
serde = { version = "1.0.147", features = ["derive"] } serde = { version = "1.0.147", features = ["derive"] }
serde_with = { version = "2.0.1", features = ["hex", "chrono"] } serde_with = { version = "2.0.1", features = ["hex", "chrono"] }

View File

@ -21,6 +21,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use thiserror::Error; use thiserror::Error;
use ulid::Ulid;
use url::Url; use url::Url;
use super::ConfigurationSection; use super::ConfigurationSection;
@ -76,7 +77,8 @@ pub enum ClientAuthMethodConfig {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ClientConfig { pub struct ClientConfig {
/// The client ID /// The client ID
pub client_id: String, #[schemars(with = "String")]
pub client_id: Ulid,
/// Authentication method used for this client /// Authentication method used for this client
#[serde(flatten)] #[serde(flatten)]
@ -181,6 +183,8 @@ impl ConfigurationSection<'_> for ClientsConfig {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::str::FromStr;
use figment::Jail; use figment::Jail;
use super::*; use super::*;
@ -192,24 +196,24 @@ mod tests {
"config.yaml", "config.yaml",
r#" r#"
clients: clients:
- client_id: public - client_id: 01GFWR28C4KNE04WG3HKXB7C9R
client_auth_method: none client_auth_method: none
redirect_uris: redirect_uris:
- https://exemple.fr/callback - https://exemple.fr/callback
- client_id: secret-basic - client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6
client_auth_method: client_secret_basic client_auth_method: client_secret_basic
client_secret: hello client_secret: hello
- client_id: secret-post - client_id: 01GFWR3WHR93Y5HK389H28VHZ9
client_auth_method: client_secret_post client_auth_method: client_secret_post
client_secret: hello client_secret: hello
- client_id: secret-jwk - client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG
client_auth_method: client_secret_jwt client_auth_method: client_secret_jwt
client_secret: hello client_secret: hello
- client_id: jwks - client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR
client_auth_method: private_key_jwt client_auth_method: private_key_jwt
jwks: jwks:
keys: keys:
@ -233,13 +237,19 @@ mod tests {
assert_eq!(config.0.len(), 5); assert_eq!(config.0.len(), 5);
assert_eq!(config.0[0].client_id, "public"); assert_eq!(
config.0[0].client_id,
Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap()
);
assert_eq!( assert_eq!(
config.0[0].redirect_uris, config.0[0].redirect_uris,
vec!["https://exemple.fr/callback".parse().unwrap()] vec!["https://exemple.fr/callback".parse().unwrap()]
); );
assert_eq!(config.0[1].client_id, "secret-basic"); assert_eq!(
config.0[1].client_id,
Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap()
);
assert_eq!(config.0[1].redirect_uris, Vec::new()); assert_eq!(config.0[1].redirect_uris, Vec::new());
Ok(()) Ok(())

View File

@ -89,7 +89,7 @@ pub struct CompatSession<T: StorageBackend> {
pub user: User<T>, pub user: User<T>,
pub device: Device, pub device: Device,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>, pub finished_at: Option<DateTime<Utc>>,
} }
impl<S: StorageBackendMarker> From<CompatSession<S>> for CompatSession<()> { impl<S: StorageBackendMarker> From<CompatSession<S>> for CompatSession<()> {
@ -99,7 +99,7 @@ impl<S: StorageBackendMarker> From<CompatSession<S>> for CompatSession<()> {
user: t.user.into(), user: t.user.into(),
device: t.device, device: t.device,
created_at: t.created_at, created_at: t.created_at,
deleted_at: t.deleted_at, finished_at: t.finished_at,
} }
} }
} }
@ -144,12 +144,12 @@ impl<S: StorageBackendMarker> From<CompatRefreshToken<S>> for CompatRefreshToken
#[serde(bound = "T: StorageBackend")] #[serde(bound = "T: StorageBackend")]
pub enum CompatSsoLoginState<T: StorageBackend> { pub enum CompatSsoLoginState<T: StorageBackend> {
Pending, Pending,
Fullfilled { Fulfilled {
fullfilled_at: DateTime<Utc>, fulfilled_at: DateTime<Utc>,
session: CompatSession<T>, session: CompatSession<T>,
}, },
Exchanged { Exchanged {
fullfilled_at: DateTime<Utc>, fulfilled_at: DateTime<Utc>,
exchanged_at: DateTime<Utc>, exchanged_at: DateTime<Utc>,
session: CompatSession<T>, session: CompatSession<T>,
}, },
@ -159,19 +159,19 @@ impl<S: StorageBackendMarker> From<CompatSsoLoginState<S>> for CompatSsoLoginSta
fn from(t: CompatSsoLoginState<S>) -> Self { fn from(t: CompatSsoLoginState<S>) -> Self {
match t { match t {
CompatSsoLoginState::Pending => Self::Pending, CompatSsoLoginState::Pending => Self::Pending,
CompatSsoLoginState::Fullfilled { CompatSsoLoginState::Fulfilled {
fullfilled_at, fulfilled_at,
session, session,
} => Self::Fullfilled { } => Self::Fulfilled {
fullfilled_at, fulfilled_at,
session: session.into(), session: session.into(),
}, },
CompatSsoLoginState::Exchanged { CompatSsoLoginState::Exchanged {
fullfilled_at, fulfilled_at,
exchanged_at, exchanged_at,
session, session,
} => Self::Exchanged { } => Self::Exchanged {
fullfilled_at, fulfilled_at,
exchanged_at, exchanged_at,
session: session.into(), session: session.into(),
}, },
@ -185,7 +185,7 @@ pub struct CompatSsoLogin<T: StorageBackend> {
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub data: T::CompatSsoLoginData, pub data: T::CompatSsoLoginData,
pub redirect_uri: Url, pub redirect_uri: Url,
pub token: String, pub login_token: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub state: CompatSsoLoginState<T>, pub state: CompatSsoLoginState<T>,
} }
@ -195,7 +195,7 @@ impl<S: StorageBackendMarker> From<CompatSsoLogin<S>> for CompatSsoLogin<()> {
Self { Self {
data: (), data: (),
redirect_uri: t.redirect_uri, redirect_uri: t.redirect_uri,
token: t.token, login_token: t.login_token,
created_at: t.created_at, created_at: t.created_at,
state: t.state.into(), state: t.state.into(),
} }

View File

@ -171,7 +171,6 @@ pub struct AuthorizationGrant<T: StorageBackend> {
pub state: Option<String>, pub state: Option<String>,
pub nonce: Option<String>, pub nonce: Option<String>,
pub max_age: Option<NonZeroU32>, pub max_age: Option<NonZeroU32>,
pub acr_values: Option<String>,
pub response_mode: ResponseMode, pub response_mode: ResponseMode,
pub response_type_id_token: bool, pub response_type_id_token: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
@ -190,7 +189,6 @@ impl<S: StorageBackendMarker> From<AuthorizationGrant<S>> for AuthorizationGrant
state: g.state, state: g.state,
nonce: g.nonce, nonce: g.nonce,
max_age: g.max_age, max_age: g.max_age,
acr_values: g.acr_values,
response_mode: g.response_mode, response_mode: g.response_mode,
response_type_id_token: g.response_type_id_token, response_type_id_token: g.response_type_id_token,
created_at: g.created_at, created_at: g.created_at,

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Utc};
use crc::{Crc, CRC_32_ISO_HDLC}; use crc::{Crc, CRC_32_ISO_HDLC};
use mas_iana::oauth::OAuthTokenTypeHint; use mas_iana::oauth::OAuthTokenTypeHint;
use rand::{distributions::Alphanumeric, Rng}; use rand::{distributions::Alphanumeric, Rng};
@ -24,9 +24,9 @@ use crate::traits::{StorageBackend, StorageBackendMarker};
pub struct AccessToken<T: StorageBackend> { pub struct AccessToken<T: StorageBackend> {
pub data: T::AccessTokenData, pub data: T::AccessTokenData,
pub jti: String, pub jti: String,
pub token: String, pub access_token: String,
pub expires_after: Duration,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
} }
impl<S: StorageBackendMarker> From<AccessToken<S>> for AccessToken<()> { impl<S: StorageBackendMarker> From<AccessToken<S>> for AccessToken<()> {
@ -34,23 +34,24 @@ impl<S: StorageBackendMarker> From<AccessToken<S>> for AccessToken<()> {
AccessToken { AccessToken {
data: (), data: (),
jti: t.jti, jti: t.jti,
token: t.token, access_token: t.access_token,
expires_after: t.expires_after, expires_at: t.expires_at,
created_at: t.created_at, created_at: t.created_at,
} }
} }
} }
impl<T: StorageBackend> AccessToken<T> { impl<T: StorageBackend> AccessToken<T> {
// XXX
pub fn exp(&self) -> DateTime<Utc> { pub fn exp(&self) -> DateTime<Utc> {
self.created_at + self.expires_after self.expires_at
} }
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct RefreshToken<T: StorageBackend> { pub struct RefreshToken<T: StorageBackend> {
pub data: T::RefreshTokenData, pub data: T::RefreshTokenData,
pub token: String, pub refresh_token: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub access_token: Option<AccessToken<T>>, pub access_token: Option<AccessToken<T>>,
} }
@ -59,7 +60,7 @@ impl<S: StorageBackendMarker> From<RefreshToken<S>> for RefreshToken<()> {
fn from(t: RefreshToken<S>) -> Self { fn from(t: RefreshToken<S>) -> Self {
RefreshToken { RefreshToken {
data: (), data: (),
token: t.token, refresh_token: t.refresh_token,
created_at: t.created_at, created_at: t.created_at,
access_token: t.access_token.map(Into::into), access_token: t.access_token.map(Into::into),
} }

View File

@ -164,7 +164,7 @@ where
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum UserEmailVerificationState { pub enum UserEmailVerificationState {
AlreadyUsed { when: DateTime<Utc> }, AlreadyUsed { when: DateTime<Utc> },
Expired, Expired { when: DateTime<Utc> },
Valid, Valid,
} }
@ -200,7 +200,9 @@ where
UserEmailVerificationState::AlreadyUsed { UserEmailVerificationState::AlreadyUsed {
when: Utc::now() - Duration::minutes(5), when: Utc::now() - Duration::minutes(5),
}, },
UserEmailVerificationState::Expired, UserEmailVerificationState::Expired {
when: Utc::now() - Duration::hours(5),
},
UserEmailVerificationState::Valid, UserEmailVerificationState::Valid,
]; ];

View File

@ -45,6 +45,7 @@ url = { version = "2.3.1", features = ["serde"] }
mime = "0.3.16" mime = "0.3.16"
rand = "0.8.5" rand = "0.8.5"
headers = "0.3.8" headers = "0.3.8"
ulid = "1.0.0"
oauth2-types = { path = "../oauth2-types" } oauth2-types = { path = "../oauth2-types" }
mas-axum-utils = { path = "../axum-utils", default-features = false } mas-axum-utils = { path = "../axum-utils", default-features = false }

View File

@ -259,12 +259,15 @@ async fn token_login(
match login.state { match login.state {
CompatSsoLoginState::Pending => { CompatSsoLoginState::Pending => {
tracing::error!( tracing::error!(
login.data, compat_sso_login.id = %login.data,
"Exchanged a token for a login that was not fullfilled yet" "Exchanged a token for a login that was not fullfilled yet"
); );
return Err(RouteError::InvalidLoginToken); return Err(RouteError::InvalidLoginToken);
} }
CompatSsoLoginState::Fullfilled { fullfilled_at, .. } => { CompatSsoLoginState::Fulfilled {
fulfilled_at: fullfilled_at,
..
} => {
if now > fullfilled_at + Duration::seconds(30) { if now > fullfilled_at + Duration::seconds(30) {
return Err(RouteError::LoginTookTooLong); return Err(RouteError::LoginTookTooLong);
} }
@ -273,7 +276,7 @@ async fn token_login(
if now > exchanged_at + Duration::seconds(30) { if now > exchanged_at + Duration::seconds(30) {
// TODO: log that session out // TODO: log that session out
tracing::error!( tracing::error!(
login.data, compat_sso_login.id = %login.data,
"Login token exchanged a second time more than 30s after" "Login token exchanged a second time more than 30s after"
); );
} }

View File

@ -33,6 +33,7 @@ use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates};
use rand::thread_rng; use rand::thread_rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use ulid::Ulid;
#[derive(Serialize)] #[derive(Serialize)]
struct AllParams<'s> { struct AllParams<'s> {
@ -52,7 +53,7 @@ pub async fn get(
State(pool): State<PgPool>, State(pool): State<PgPool>,
State(templates): State<Templates>, State(templates): State<Templates>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Path(id): Path<i64>, Path(id): Path<Ulid>,
Query(params): Query<Params>, Query(params): Query<Params>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
let mut conn = pool.acquire().await?; let mut conn = pool.acquire().await?;
@ -116,7 +117,7 @@ pub async fn post(
State(pool): State<PgPool>, State(pool): State<PgPool>,
State(templates): State<Templates>, State(templates): State<Templates>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Path(id): Path<i64>, Path(id): Path<Ulid>,
Query(params): Query<Params>, Query(params): Query<Params>,
Form(form): Form<ProtectedForm<()>>, Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
@ -178,7 +179,7 @@ pub async fn post(
let params = AllParams { let params = AllParams {
existing_params, existing_params,
login_token: &login.token, login_token: &login.login_token,
}; };
let query = serde_urlencoded::to_string(&params)?; let query = serde_urlencoded::to_string(&params)?;
redirect_uri.set_query(Some(&query)); redirect_uri.set_query(Some(&query));

View File

@ -17,9 +17,8 @@ use chrono::Duration;
use hyper::StatusCode; use hyper::StatusCode;
use mas_data_model::{TokenFormatError, TokenType}; use mas_data_model::{TokenFormatError, TokenType};
use mas_storage::compat::{ use mas_storage::compat::{
add_compat_access_token, add_compat_refresh_token, expire_compat_access_token, add_compat_access_token, add_compat_refresh_token, consume_compat_refresh_token,
lookup_active_compat_refresh_token, replace_compat_refresh_token, expire_compat_access_token, lookup_active_compat_refresh_token, CompatRefreshTokenLookupError,
CompatRefreshTokenLookupError,
}; };
use rand::thread_rng; use rand::thread_rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -125,7 +124,7 @@ pub(crate) async fn post(
add_compat_refresh_token(&mut txn, &session, &new_access_token, new_refresh_token_str) add_compat_refresh_token(&mut txn, &session, &new_access_token, new_refresh_token_str)
.await?; .await?;
replace_compat_refresh_token(&mut txn, &refresh_token, &new_refresh_token).await?; consume_compat_refresh_token(&mut txn, refresh_token).await?;
expire_compat_access_token(&mut txn, access_token).await?; expire_compat_access_token(&mut txn, access_token).await?;
txn.commit().await?; txn.commit().await?;

View File

@ -38,6 +38,7 @@ use mas_templates::Templates;
use oauth2_types::requests::{AccessTokenResponse, AuthorizationResponse}; use oauth2_types::requests::{AccessTokenResponse, AuthorizationResponse};
use sqlx::{PgPool, Postgres, Transaction}; use sqlx::{PgPool, Postgres, Transaction};
use thiserror::Error; use thiserror::Error;
use ulid::Ulid;
use super::callback::{ use super::callback::{
CallbackDestination, CallbackDestinationError, IntoCallbackDestinationError, CallbackDestination, CallbackDestinationError, IntoCallbackDestinationError,
@ -109,7 +110,7 @@ pub(crate) async fn get(
State(templates): State<Templates>, State(templates): State<Templates>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Path(grant_id): Path<i64>, Path(grant_id): Path<Ulid>,
) -> Result<Response, RouteError> { ) -> Result<Response, RouteError> {
let mut txn = pool.begin().await?; let mut txn = pool.begin().await?;

View File

@ -36,6 +36,7 @@ use mas_storage::oauth2::{
use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Templates}; use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Templates};
use sqlx::PgPool; use sqlx::PgPool;
use thiserror::Error; use thiserror::Error;
use ulid::Ulid;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum RouteError { pub enum RouteError {
@ -54,7 +55,7 @@ pub(crate) async fn get(
State(templates): State<Templates>, State(templates): State<Templates>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Path(grant_id): Path<i64>, Path(grant_id): Path<Ulid>,
) -> Result<Response, RouteError> { ) -> Result<Response, RouteError> {
let mut conn = pool let mut conn = pool
.acquire() .acquire()
@ -115,7 +116,7 @@ pub(crate) async fn post(
State(policy_factory): State<Arc<PolicyFactory>>, State(policy_factory): State<Arc<PolicyFactory>>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Path(grant_id): Path<i64>, Path(grant_id): Path<Ulid>,
Form(form): Form<ProtectedForm<()>>, Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, RouteError> { ) -> Result<Response, RouteError> {
let mut txn = pool let mut txn = pool

View File

@ -24,10 +24,10 @@ use oauth2_types::{
ClientMetadata, ClientMetadataVerificationError, ClientRegistrationResponse, Localized, ClientMetadata, ClientMetadataVerificationError, ClientRegistrationResponse, Localized,
}, },
}; };
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use sqlx::PgPool; use sqlx::PgPool;
use thiserror::Error; use thiserror::Error;
use tracing::info; use tracing::info;
use ulid::Ulid;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub(crate) enum RouteError { pub(crate) enum RouteError {
@ -127,18 +127,14 @@ pub(crate) async fn post(
let mut txn = pool.begin().await?; let mut txn = pool.begin().await?;
// Let's generate a random client ID // Let's generate a random client ID
let client_id: String = thread_rng() let client_id = Ulid::new();
.sample_iter(&Alphanumeric)
.take(10)
.map(char::from)
.collect();
insert_client( insert_client(
&mut txn, &mut txn,
&client_id, client_id,
metadata.redirect_uris(), metadata.redirect_uris(),
None, None,
&metadata.response_types(), //&metadata.response_types(),
metadata.grant_types(), metadata.grant_types(),
contacts, contacts,
metadata metadata
@ -162,7 +158,7 @@ pub(crate) async fn post(
txn.commit().await?; txn.commit().await?;
let response = ClientRegistrationResponse { let response = ClientRegistrationResponse {
client_id, client_id: client_id.to_string(),
client_secret: None, client_secret: None,
client_id_issued_at: None, client_id_issued_at: None,
client_secret_expires_at: None, client_secret_expires_at: None,

View File

@ -36,7 +36,7 @@ use mas_storage::{
client::ClientFetchError, client::ClientFetchError,
end_oauth_session, end_oauth_session,
refresh_token::{ refresh_token::{
add_refresh_token, lookup_active_refresh_token, replace_refresh_token, add_refresh_token, consume_refresh_token, lookup_active_refresh_token,
RefreshTokenLookupError, RefreshTokenLookupError,
}, },
}, },
@ -311,10 +311,10 @@ async fn authorization_code_grant(
) )
}; };
let access_token = add_access_token(&mut txn, session, &access_token_str, ttl).await?; let access_token = add_access_token(&mut txn, session, access_token_str.clone(), ttl).await?;
let _refresh_token = let _refresh_token =
add_refresh_token(&mut txn, session, access_token, &refresh_token_str).await?; add_refresh_token(&mut txn, session, access_token, refresh_token_str.clone()).await?;
let id_token = if session.scope.contains(&scope::OPENID) { let id_token = if session.scope.contains(&scope::OPENID) {
let mut claims = HashMap::new(); let mut claims = HashMap::new();
@ -391,20 +391,21 @@ async fn refresh_token_grant(
) )
}; };
let new_access_token = add_access_token(&mut txn, &session, &access_token_str, ttl).await?; let new_access_token =
add_access_token(&mut txn, &session, access_token_str.clone(), ttl).await?;
let new_refresh_token = let new_refresh_token =
add_refresh_token(&mut txn, &session, new_access_token, &refresh_token_str).await?; add_refresh_token(&mut txn, &session, new_access_token, refresh_token_str).await?;
replace_refresh_token(&mut txn, &refresh_token, &new_refresh_token).await?; consume_refresh_token(&mut txn, &refresh_token).await?;
if let Some(access_token) = refresh_token.access_token { if let Some(access_token) = refresh_token.access_token {
revoke_access_token(&mut txn, &access_token).await?; revoke_access_token(&mut txn, access_token).await?;
} }
let params = AccessTokenResponse::new(access_token_str) let params = AccessTokenResponse::new(access_token_str)
.with_expires_in(ttl) .with_expires_in(ttl)
.with_refresh_token(refresh_token_str) .with_refresh_token(new_refresh_token.refresh_token)
.with_scope(session.scope); .with_scope(session.scope);
txn.commit().await?; txn.commit().await?;

View File

@ -86,7 +86,7 @@ pub(crate) async fn post(
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, login.go()).into_response());
}; };
let user_email = add_user_email(&mut txn, &session.user, &form.email).await?; let user_email = add_user_email(&mut txn, &session.user, form.email).await?;
let next = mas_router::AccountVerifyEmail::new(user_email.data); let next = mas_router::AccountVerifyEmail::new(user_email.data);
let next = if let Some(action) = query.post_auth_action { let next = if let Some(action) = query.post_auth_action {
next.and_then(action) next.and_then(action)

View File

@ -17,6 +17,7 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::extract::PrivateCookieJar; use axum_extra::extract::PrivateCookieJar;
use chrono::Duration;
use lettre::{message::Mailbox, Address}; use lettre::{message::Mailbox, Address};
use mas_axum_utils::{ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
@ -101,7 +102,8 @@ async fn start_email_verification(
let address: Address = user_email.email.parse()?; let address: Address = user_email.email.parse()?;
let verification = add_user_email_verification_code(executor, user_email, code).await?; let verification =
add_user_email_verification_code(executor, user_email, Duration::hours(8), code).await?;
// And send the verification email // And send the verification email
let mailbox = Mailbox::new(Some(user.username.clone()), address); let mailbox = Mailbox::new(Some(user.username.clone()), address);
@ -111,7 +113,7 @@ async fn start_email_verification(
mailer.send_verification_email(mailbox, &context).await?; mailer.send_verification_email(mailbox, &context).await?;
info!( info!(
email.id = verification.email.data, email.id = %verification.email.data,
"Verification email sent" "Verification email sent"
); );
Ok(()) Ok(())
@ -141,7 +143,7 @@ pub(crate) async fn post(
match form { match form {
ManagementForm::Add { email } => { ManagementForm::Add { email } => {
let user_email = add_user_email(&mut txn, &session.user, &email).await?; let user_email = add_user_email(&mut txn, &session.user, email).await?;
let next = mas_router::AccountVerifyEmail::new(user_email.data); let next = mas_router::AccountVerifyEmail::new(user_email.data);
start_email_verification(&mailer, &mut txn, &session.user, user_email).await?; start_email_verification(&mailer, &mut txn, &session.user, user_email).await?;
txn.commit().await?; txn.commit().await?;

View File

@ -17,7 +17,6 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::extract::PrivateCookieJar; use axum_extra::extract::PrivateCookieJar;
use chrono::Duration;
use mas_axum_utils::{ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
@ -31,6 +30,7 @@ use mas_storage::user::{
use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates}; use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates};
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use ulid::Ulid;
use crate::views::shared::OptionalPostAuthAction; use crate::views::shared::OptionalPostAuthAction;
@ -43,7 +43,7 @@ pub(crate) async fn get(
State(templates): State<Templates>, State(templates): State<Templates>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
Path(id): Path<i64>, Path(id): Path<Ulid>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
let mut conn = pool.acquire().await?; let mut conn = pool.acquire().await?;
@ -81,7 +81,7 @@ pub(crate) async fn post(
State(pool): State<PgPool>, State(pool): State<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
Path(id): Path<i64>, Path(id): Path<Ulid>,
Form(form): Form<ProtectedForm<CodeForm>>, Form(form): Form<ProtectedForm<CodeForm>>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
let mut txn = pool.begin().await?; let mut txn = pool.begin().await?;
@ -105,9 +105,7 @@ pub(crate) async fn post(
} }
// TODO: make those 8 hours configurable // TODO: make those 8 hours configurable
let verification = let verification = lookup_user_email_verification_code(&mut txn, email, &form.code).await?;
lookup_user_email_verification_code(&mut txn, email, &form.code, Duration::hours(8))
.await?;
// TODO: display nice errors if the code was already consumed or expired // TODO: display nice errors if the code was already consumed or expired
let verification = consume_email_verification(&mut txn, verification).await?; let verification = consume_email_verification(&mut txn, verification).await?;

View File

@ -22,6 +22,7 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::extract::PrivateCookieJar; use axum_extra::extract::PrivateCookieJar;
use chrono::Duration;
use lettre::{message::Mailbox, Address}; use lettre::{message::Mailbox, Address};
use mas_axum_utils::{ use mas_axum_utils::{
csrf::{CsrfExt, CsrfToken, ProtectedForm}, csrf::{CsrfExt, CsrfToken, ProtectedForm},
@ -181,7 +182,7 @@ pub(crate) async fn post(
let pfh = Argon2::default(); let pfh = Argon2::default();
let user = register_user(&mut txn, pfh, &form.username, &form.password).await?; let user = register_user(&mut txn, pfh, &form.username, &form.password).await?;
let user_email = add_user_email(&mut txn, &user, &form.email).await?; let user_email = add_user_email(&mut txn, &user, form.email).await?;
// First, generate a code // First, generate a code
let range = Uniform::<u32>::from(0..1_000_000); let range = Uniform::<u32>::from(0..1_000_000);
@ -189,7 +190,8 @@ pub(crate) async fn post(
let address: Address = user_email.email.parse()?; let address: Address = user_email.email.parse()?;
let verification = add_user_email_verification_code(&mut txn, user_email, code).await?; let verification =
add_user_email_verification_code(&mut txn, user_email, Duration::hours(8), code).await?;
// And send the verification email // And send the verification email
let mailbox = Mailbox::new(Some(user.username.clone()), address); let mailbox = Mailbox::new(Some(user.username.clone()), address);

View File

@ -11,3 +11,4 @@ serde = { version = "1.0.147", features = ["derive"] }
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
serde_with = "2.0.1" serde_with = "2.0.1"
url = "2.3.1" url = "2.3.1"
ulid = "1.0.0"

View File

@ -14,6 +14,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr}; use serde_with::{serde_as, DisplayFromStr};
use ulid::Ulid;
pub use crate::traits::*; pub use crate::traits::*;
@ -23,23 +24,23 @@ pub use crate::traits::*;
pub enum PostAuthAction { pub enum PostAuthAction {
ContinueAuthorizationGrant { ContinueAuthorizationGrant {
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
data: i64, data: Ulid,
}, },
ContinueCompatSsoLogin { ContinueCompatSsoLogin {
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
data: i64, data: Ulid,
}, },
ChangePassword, ChangePassword,
} }
impl PostAuthAction { impl PostAuthAction {
#[must_use] #[must_use]
pub fn continue_grant(data: i64) -> Self { pub fn continue_grant(data: Ulid) -> Self {
PostAuthAction::ContinueAuthorizationGrant { data } PostAuthAction::ContinueAuthorizationGrant { data }
} }
#[must_use] #[must_use]
pub fn continue_compat_sso_login(data: i64) -> Self { pub fn continue_compat_sso_login(data: Ulid) -> Self {
PostAuthAction::ContinueCompatSsoLogin { data } PostAuthAction::ContinueCompatSsoLogin { data }
} }
@ -166,14 +167,14 @@ impl Login {
} }
#[must_use] #[must_use]
pub fn and_continue_grant(data: i64) -> Self { pub fn and_continue_grant(data: Ulid) -> Self {
Self { Self {
post_auth_action: Some(PostAuthAction::continue_grant(data)), post_auth_action: Some(PostAuthAction::continue_grant(data)),
} }
} }
#[must_use] #[must_use]
pub fn and_continue_compat_sso_login(data: i64) -> Self { pub fn and_continue_compat_sso_login(data: Ulid) -> Self {
Self { Self {
post_auth_action: Some(PostAuthAction::continue_compat_sso_login(data)), post_auth_action: Some(PostAuthAction::continue_compat_sso_login(data)),
} }
@ -222,7 +223,7 @@ impl Reauth {
} }
#[must_use] #[must_use]
pub fn and_continue_grant(data: i64) -> Self { pub fn and_continue_grant(data: Ulid) -> Self {
Self { Self {
post_auth_action: Some(PostAuthAction::continue_grant(data)), post_auth_action: Some(PostAuthAction::continue_grant(data)),
} }
@ -275,14 +276,14 @@ impl Register {
} }
#[must_use] #[must_use]
pub fn and_continue_grant(data: i64) -> Self { pub fn and_continue_grant(data: Ulid) -> Self {
Self { Self {
post_auth_action: Some(PostAuthAction::continue_grant(data)), post_auth_action: Some(PostAuthAction::continue_grant(data)),
} }
} }
#[must_use] #[must_use]
pub fn and_continue_compat_sso_login(data: i64) -> Self { pub fn and_continue_compat_sso_login(data: Ulid) -> Self {
Self { Self {
post_auth_action: Some(PostAuthAction::continue_compat_sso_login(data)), post_auth_action: Some(PostAuthAction::continue_compat_sso_login(data)),
} }
@ -323,13 +324,13 @@ impl From<Option<PostAuthAction>> for Register {
/// `GET|POST /account/emails/verify/:id` /// `GET|POST /account/emails/verify/:id`
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AccountVerifyEmail { pub struct AccountVerifyEmail {
id: i64, id: Ulid,
post_auth_action: Option<PostAuthAction>, post_auth_action: Option<PostAuthAction>,
} }
impl AccountVerifyEmail { impl AccountVerifyEmail {
#[must_use] #[must_use]
pub fn new(id: i64) -> Self { pub fn new(id: Ulid) -> Self {
Self { Self {
id, id,
post_auth_action: None, post_auth_action: None,
@ -415,7 +416,7 @@ impl SimpleRoute for AccountEmails {
/// `GET /authorize/:grant_id` /// `GET /authorize/:grant_id`
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ContinueAuthorizationGrant(pub i64); pub struct ContinueAuthorizationGrant(pub Ulid);
impl Route for ContinueAuthorizationGrant { impl Route for ContinueAuthorizationGrant {
type Query = (); type Query = ();
@ -430,7 +431,7 @@ impl Route for ContinueAuthorizationGrant {
/// `GET /consent/:grant_id` /// `GET /consent/:grant_id`
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Consent(pub i64); pub struct Consent(pub Ulid);
impl Route for Consent { impl Route for Consent {
type Query = (); type Query = ();
@ -493,13 +494,13 @@ pub struct CompatLoginSsoActionParams {
/// `GET|POST /complete-compat-sso/:id` /// `GET|POST /complete-compat-sso/:id`
pub struct CompatLoginSsoComplete { pub struct CompatLoginSsoComplete {
id: i64, id: Ulid,
query: Option<CompatLoginSsoActionParams>, query: Option<CompatLoginSsoActionParams>,
} }
impl CompatLoginSsoComplete { impl CompatLoginSsoComplete {
#[must_use] #[must_use]
pub fn new(id: i64, action: Option<CompatLoginSsoAction>) -> Self { pub fn new(id: Ulid, action: Option<CompatLoginSsoAction>) -> Self {
Self { Self {
id, id,
query: action.map(|action| CompatLoginSsoActionParams { action }), query: action.map(|action| CompatLoginSsoActionParams { action }),

View File

@ -30,6 +30,7 @@ pub use self::{endpoints::*, traits::Route, url_builder::UrlBuilder};
mod tests { mod tests {
use std::borrow::Cow; use std::borrow::Cow;
use ulid::Ulid;
use url::Url; use url::Url;
use super::*; use super::*;
@ -42,8 +43,10 @@ mod tests {
); );
assert_eq!(Index.relative_url(), Cow::Borrowed("/")); assert_eq!(Index.relative_url(), Cow::Borrowed("/"));
assert_eq!( assert_eq!(
Login::and_continue_grant(42).relative_url(), Login::and_continue_grant(Ulid::nil()).relative_url(),
Cow::Borrowed("/login?next=continue_authorization_grant&data=42") Cow::Borrowed(
"/login?next=continue_authorization_grant&data=00000000000000000000000000"
)
); );
} }

View File

@ -7,7 +7,7 @@ license = "Apache-2.0"
[dependencies] [dependencies]
tokio = "1.21.2" tokio = "1.21.2"
sqlx = { version = "0.6.2", features = ["runtime-tokio-rustls", "postgres", "migrate", "chrono", "offline", "json"] } sqlx = { version = "0.6.2", features = ["runtime-tokio-rustls", "postgres", "migrate", "chrono", "offline", "json", "uuid"] }
chrono = { version = "0.4.22", features = ["serde"] } chrono = { version = "0.4.22", features = ["serde"] }
serde = { version = "1.0.147", features = ["derive"] } serde = { version = "1.0.147", features = ["derive"] }
serde_json = "1.0.87" serde_json = "1.0.87"
@ -20,6 +20,8 @@ argon2 = { version = "0.4.1", features = ["password-hash"] }
password-hash = { version = "0.4.2", features = ["std"] } password-hash = { version = "0.4.2", features = ["std"] }
rand = "0.8.5" rand = "0.8.5"
url = { version = "2.3.1", features = ["serde"] } url = { version = "2.3.1", features = ["serde"] }
uuid = "1.2.1"
ulid = { version = "1.0.0", features = ["uuid", "serde"] }
oauth2-types = { path = "../oauth2-types" } oauth2-types = { path = "../oauth2-types" }
mas-data-model = { path = "../data-model" } mas-data-model = { path = "../data-model" }

View File

@ -1,15 +0,0 @@
-- Copyright 2021 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 FUNCTION IF EXISTS trigger_set_timestamp();

View File

@ -1,21 +0,0 @@
-- Copyright 2021 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 OR REPLACE FUNCTION trigger_set_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@ -1,16 +0,0 @@
-- Copyright 2021 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 TRIGGER set_timestamp ON users;
DROP TABLE users;

View File

@ -1,26 +0,0 @@
-- Copyright 2021 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 users (
"id" BIGSERIAL PRIMARY KEY,
"username" TEXT NOT NULL UNIQUE,
"hashed_password" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();

View File

@ -1,17 +0,0 @@
-- Copyright 2021 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 TRIGGER set_timestamp ON user_sessions;
DROP TABLE user_session_authentications;
DROP TABLE user_sessions;

View File

@ -1,35 +0,0 @@
-- Copyright 2021 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.
-- A logged in session
CREATE TABLE user_sessions (
"id" BIGSERIAL PRIMARY KEY,
"user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"active" BOOLEAN NOT NULL DEFAULT TRUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON user_sessions
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
-- An authentication within a session
CREATE TABLE user_session_authentications (
"id" BIGSERIAL PRIMARY KEY,
"session_id" BIGINT NOT NULL REFERENCES user_sessions (id) ON DELETE CASCADE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);

View File

@ -1,17 +0,0 @@
-- Copyright 2021 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 TRIGGER set_timestamp ON oauth2_sessions;
DROP TABLE oauth2_codes;
DROP TABLE oauth2_sessions;

View File

@ -1,45 +0,0 @@
-- Copyright 2021 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 oauth2_sessions (
"id" BIGSERIAL PRIMARY KEY,
"user_session_id" BIGINT REFERENCES user_sessions (id) ON DELETE CASCADE,
"client_id" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"state" TEXT,
"nonce" TEXT,
"max_age" INT,
"response_type" TEXT NOT NULL,
"response_mode" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON oauth2_sessions
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
CREATE TABLE oauth2_codes (
"id" BIGSERIAL PRIMARY KEY,
"oauth2_session_id" BIGINT NOT NULL REFERENCES oauth2_sessions (id) ON DELETE CASCADE,
"code" TEXT UNIQUE NOT NULL,
"code_challenge_method" SMALLINT,
"code_challenge" TEXT,
CHECK (("code_challenge" IS NULL AND "code_challenge_method" IS NULL)
OR ("code_challenge" IS NOT NULL AND "code_challenge_method" IS NOT NULL))
);

View File

@ -1,15 +0,0 @@
-- Copyright 2021 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 oauth2_access_tokens;

View File

@ -1,23 +0,0 @@
-- Copyright 2021 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 oauth2_access_tokens (
"id" BIGSERIAL PRIMARY KEY,
"oauth2_session_id" BIGINT NOT NULL REFERENCES oauth2_sessions (id) ON DELETE CASCADE,
"token" TEXT UNIQUE NOT NULL,
"expires_after" INT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);

View File

@ -1,16 +0,0 @@
-- Copyright 2021 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 TRIGGER set_timestamp ON oauth2_refresh_tokens;
DROP TABLE oauth2_refresh_tokens;

View File

@ -1,30 +0,0 @@
-- Copyright 2021 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 oauth2_refresh_tokens (
"id" BIGSERIAL PRIMARY KEY,
"oauth2_session_id" BIGINT NOT NULL REFERENCES oauth2_sessions (id) ON DELETE CASCADE,
"oauth2_access_token_id" BIGINT REFERENCES oauth2_access_tokens (id) ON DELETE SET NULL,
"token" TEXT UNIQUE NOT NULL,
"next_token_id" BIGINT REFERENCES oauth2_refresh_tokens (id),
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON oauth2_refresh_tokens
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();

View File

@ -1,103 +0,0 @@
-- Copyright 2021 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.
-- Replace the old "sessions" table
ALTER TABLE oauth2_sessions RENAME TO oauth2_sessions_old;
-- TODO: how do we handle temporary session upgrades (aka. sudo mode)?
CREATE TABLE oauth2_sessions (
"id" BIGSERIAL PRIMARY KEY,
"user_session_id" BIGINT NOT NULL REFERENCES user_sessions (id) ON DELETE CASCADE,
"client_id" TEXT NOT NULL, -- The "authorization party" would be more accurate in that case
"scope" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
TRUNCATE oauth2_access_tokens, oauth2_refresh_tokens;
ALTER TABLE oauth2_access_tokens
DROP CONSTRAINT oauth2_access_tokens_oauth2_session_id_fkey,
ADD CONSTRAINT oauth2_access_tokens_oauth2_session_id_fkey
FOREIGN KEY (oauth2_session_id) REFERENCES oauth2_sessions (id);
ALTER TABLE oauth2_refresh_tokens
DROP CONSTRAINT oauth2_refresh_tokens_oauth2_session_id_fkey,
ADD CONSTRAINT oauth2_refresh_tokens_oauth2_session_id_fkey
FOREIGN KEY (oauth2_session_id) REFERENCES oauth2_sessions (id);
DROP TABLE oauth2_codes, oauth2_sessions_old;
CREATE TABLE oauth2_authorization_grants (
"id" BIGSERIAL PRIMARY KEY, -- Saved as encrypted cookie
-- All this comes from the authorization request
"client_id" TEXT NOT NULL, -- This should be verified before insertion
"redirect_uri" TEXT NOT NULL, -- This should be verified before insertion
"scope" TEXT NOT NULL, -- This should be verified before insertion
"state" TEXT,
"nonce" TEXT,
"max_age" INT CHECK ("max_age" IS NULL OR "max_age" > 0),
"acr_values" TEXT, -- This should be verified before insertion
"response_mode" TEXT NOT NULL,
"code_challenge_method" TEXT,
"code_challenge" TEXT,
-- The "response_type" parameter broken down
"response_type_code" BOOLEAN NOT NULL,
"response_type_token" BOOLEAN NOT NULL,
"response_type_id_token" BOOLEAN NOT NULL,
-- This one is created eagerly on grant creation if the response_type
-- includes "code"
-- When looking up codes, it should do "where fulfilled_at is not null" and
-- "inner join on oauth2_sessions". When doing that, it should check the
-- "exchanged_at" field: if it is not null and was exchanged more than 30s
-- ago, the session shold be considered as hijacked and fully invalidated
"code" TEXT UNIQUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"fulfilled_at" TIMESTAMP WITH TIME ZONE, -- When we got back to the client
"cancelled_at" TIMESTAMP WITH TIME ZONE, -- When that grant was cancelled
"exchanged_at" TIMESTAMP WITH TIME ZONE, -- When the code was exchanged by the client
"oauth2_session_id" BIGINT REFERENCES oauth2_sessions (id) ON DELETE CASCADE,
-- Check a few invariants to keep a coherent state.
-- Even though the service should never violate those, it helps ensuring we're not doing anything wrong
-- Code exchange can only happen after the grant was fulfilled
CONSTRAINT "oauth2_authorization_grants_exchanged_after_fullfill"
CHECK (("exchanged_at" IS NULL)
OR ("exchanged_at" IS NOT NULL AND
"fulfilled_at" IS NOT NULL AND
"exchanged_at" >= "fulfilled_at")),
-- A grant can be either fulfilled or cancelled, but not both
CONSTRAINT "oauth2_authorization_grants_fulfilled_xor_cancelled"
CHECK ("fulfilled_at" IS NULL OR "cancelled_at" IS NULL),
-- If it was fulfilled there is an oauth2_session_id attached to it
CONSTRAINT "oauth2_authorization_grants_fulfilled_and_session"
CHECK (("fulfilled_at" IS NULL AND "oauth2_session_id" IS NULL)
OR ("fulfilled_at" IS NOT NULL AND "oauth2_session_id" IS NOT NULL)),
-- We should have a code if and only if the "code" response_type was asked
CONSTRAINT "oauth2_authorization_grants_code"
CHECK (("response_type_code" IS TRUE AND "code" IS NOT NULL)
OR ("response_type_code" IS FALSE AND "code" IS NULL)),
-- If we have a challenge, we also have a challenge method and a code
CONSTRAINT "oauth2_authorization_grants_code_challenge"
CHECK (("code_challenge" IS NULL AND "code_challenge_method" IS NULL)
OR ("code_challenge" IS NOT NULL AND "code_challenge_method" IS NOT NULL AND "response_type_code" IS TRUE))
);

View File

@ -1,26 +0,0 @@
-- Copyright 2021 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.
ALTER TABLE users ADD COLUMN hashed_password TEXT;
UPDATE users u
SET hashed_password = up.hashed_password
FROM user_passwords up
WHERE up.user_id = u.id;
ALTER TABLE users
ALTER COLUMN hashed_password
SET NOT NULL;
DROP TABLE user_passwords;

View File

@ -1,27 +0,0 @@
-- Copyright 2021 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 user_passwords (
"id" BIGSERIAL PRIMARY KEY,
"user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"hashed_password" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
INSERT INTO
user_passwords (user_id, hashed_password, created_at)
SELECT id, hashed_password, updated_at
FROM users;
ALTER TABLE users DROP COLUMN hashed_password;

View File

@ -1,16 +0,0 @@
-- Copyright 2021 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.
ALTER TABLE oauth2_sessions
DROP COLUMN "ended_at";

View File

@ -1,16 +0,0 @@
-- Copyright 2021 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.
ALTER TABLE oauth2_sessions
ADD COLUMN "ended_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL;

View File

@ -1,15 +0,0 @@
-- Copyright 2021 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 user_emails;

View File

@ -1,24 +0,0 @@
-- Copyright 2021 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 user_emails (
"id" BIGSERIAL PRIMARY KEY,
"user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"email" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"confirmed_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL
);
ALTER TABLE users
ADD COLUMN "primary_email_id" BIGINT REFERENCES user_emails (id) ON DELETE SET NULL;

View File

@ -1,15 +0,0 @@
-- Copyright 2021 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 user_email_verifications;

View File

@ -1,21 +0,0 @@
-- 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 user_email_verifications (
"id" BIGSERIAL PRIMARY KEY,
"user_email_id" BIGINT NOT NULL REFERENCES user_emails (id) ON DELETE CASCADE,
"code" TEXT UNIQUE NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"consumed_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL
);

View File

@ -1,17 +0,0 @@
-- Copyright 2021 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 oauth2_client_redirect_uris;
DROP TRIGGER set_timestamp ON oauth2_clients;
DROP TABLE oauth2_clients;

View File

@ -1,51 +0,0 @@
-- 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 oauth2_clients (
"id" BIGSERIAL PRIMARY KEY,
"client_id" TEXT NOT NULL UNIQUE,
"encrypted_client_secret" TEXT,
"response_types" TEXT[] NOT NULL,
"grant_type_authorization_code" BOOL NOT NULL,
"grant_type_refresh_token" BOOL NOT NULL,
"contacts" TEXT[] NOT NULL,
"client_name" TEXT,
"logo_uri" TEXT,
"client_uri" TEXT,
"policy_uri" TEXT,
"tos_uri" TEXT,
"jwks_uri" TEXT,
"jwks" JSONB,
"id_token_signed_response_alg" TEXT,
"token_endpoint_auth_method" TEXT,
"token_endpoint_auth_signing_alg" TEXT,
"initiate_login_uri" TEXT,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
-- jwks and jwks_uri can't be set at the same time
CHECK ("jwks" IS NULL OR "jwks_uri" IS NULL)
);
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON oauth2_clients
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
CREATE TABLE oauth2_client_redirect_uris (
"id" BIGSERIAL PRIMARY KEY,
"oauth2_client_id" BIGINT NOT NULL REFERENCES oauth2_clients (id) ON DELETE CASCADE,
"redirect_uri" TEXT NOT NULL
);

View File

@ -1,16 +0,0 @@
-- 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.
ALTER TABLE oauth2_clients
DROP COLUMN "userinfo_signed_response_alg" TEXT;

View File

@ -1,16 +0,0 @@
-- 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.
ALTER TABLE oauth2_clients
ADD COLUMN "userinfo_signed_response_alg" TEXT;

View File

@ -1,23 +0,0 @@
-- 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.
TRUNCATE TABLE oauth2_sessions, oauth2_authorization_grants RESTART IDENTITY CASCADE;
ALTER TABLE oauth2_sessions
DROP COLUMN "oauth2_client_id",
ADD COLUMN "client_id" TEXT NOT NULL;
ALTER TABLE oauth2_authorization_grants
DROP COLUMN "oauth2_client_id",
ADD COLUMN "client_id" TEXT NOT NULL;

View File

@ -1,27 +0,0 @@
-- 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.
TRUNCATE TABLE oauth2_sessions, oauth2_authorization_grants RESTART IDENTITY CASCADE;
ALTER TABLE oauth2_sessions
DROP COLUMN "client_id",
ADD COLUMN "oauth2_client_id" BIGINT
NOT NULL
REFERENCES oauth2_clients (id) ON DELETE CASCADE;
ALTER TABLE oauth2_authorization_grants
DROP COLUMN "client_id",
ADD COLUMN "oauth2_client_id" BIGINT
NOT NULL
REFERENCES oauth2_clients (id) ON DELETE CASCADE;

View File

@ -1,17 +0,0 @@
-- 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 TRIGGER set_timestamp ON oauth2_consents;
DROP INDEX oauth2_consents_client_id_user_id_key;
DROP TABLE oauth2_consents;

View File

@ -1,32 +0,0 @@
-- 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 oauth2_consents (
"id" BIGSERIAL PRIMARY KEY,
"oauth2_client_id" BIGINT NOT NULL REFERENCES oauth2_clients (id) ON DELETE CASCADE,
"user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"scope_token" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT user_client_scope_tuple UNIQUE ("oauth2_client_id", "user_id", "scope_token")
);
CREATE INDEX oauth2_consents_client_id_user_id_key
ON oauth2_consents ("oauth2_client_id", "user_id");
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON oauth2_consents
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();

View File

@ -1,16 +0,0 @@
-- 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.
ALTER TABLE oauth2_authorization_grants
DROP COLUMN requires_consent;

View File

@ -1,16 +0,0 @@
-- 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.
ALTER TABLE oauth2_authorization_grants
ADD COLUMN requires_consent BOOLEAN NOT NULL DEFAULT 'f';

View File

@ -1,17 +0,0 @@
-- 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_refresh_tokens;
DROP TABLE compat_access_tokens;
DROP TABLE compat_session;

View File

@ -1,42 +0,0 @@
-- 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_sessions (
"id" BIGSERIAL PRIMARY KEY,
"user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"device_id" TEXT UNIQUE NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deleted_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE compat_access_tokens (
"id" BIGSERIAL PRIMARY KEY,
"compat_session_id" BIGINT NOT NULL REFERENCES compat_sessions (id) ON DELETE CASCADE,
"token" TEXT UNIQUE NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"expires_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE compat_refresh_tokens (
"id" BIGSERIAL PRIMARY KEY,
"compat_session_id" BIGINT NOT NULL REFERENCES compat_sessions (id) ON DELETE CASCADE,
"compat_access_token_id" BIGINT REFERENCES compat_access_tokens (id) ON DELETE SET NULL,
"token" TEXT UNIQUE NOT NULL,
"next_token_id" BIGINT REFERENCES compat_refresh_tokens (id),
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);

View File

@ -1,25 +0,0 @@
-- 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
);

View File

@ -1,19 +0,0 @@
-- 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.
ALTER TABLE oauth2_authorization_grants
ADD COLUMN "response_type_token" BOOLEAN NOT NULL DEFAULT 'f';
ALTER TABLE oauth2_authorization_grants
ALTER COLUMN "response_type_token" DROP DEFAULT;

View File

@ -1,16 +0,0 @@
-- 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.
ALTER TABLE oauth2_authorization_grants
DROP COLUMN "response_type_token";

View File

@ -12,4 +12,3 @@
-- See the License for the specific language governing permissions and -- See the License for the specific language governing permissions and
-- limitations under the License. -- limitations under the License.
DROP TABLE compat_sso_logins;

View File

@ -0,0 +1,350 @@
-- 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.
-----------
-- Users --
-----------
CREATE TABLE "users" (
"user_id" UUID NOT NULL
CONSTRAINT "users_pkey"
PRIMARY KEY,
"username" TEXT NOT NULL
CONSTRAINT "users_username_unique"
UNIQUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE TABLE "user_passwords" (
"user_password_id" UUID NOT NULL
CONSTRAINT "user_passwords_pkey"
PRIMARY KEY,
"user_id" UUID NOT NULL
CONSTRAINT "user_passwords_user_id_fkey"
REFERENCES "users" ("user_id"),
"hashed_password" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE TABLE "user_emails" (
"user_email_id" UUID NOT NULL
CONSTRAINT "user_emails_pkey"
PRIMARY KEY,
"user_id" UUID NOT NULL
CONSTRAINT "user_emails_user_id_fkey"
REFERENCES "users" ("user_id")
ON DELETE CASCADE,
"email" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"confirmed_at" TIMESTAMP WITH TIME ZONE
);
ALTER TABLE "users"
ADD COLUMN "primary_user_email_id" UUID
CONSTRAINT "users_primary_user_email_id_fkey"
REFERENCES "user_emails" ("user_email_id")
ON DELETE SET NULL;
CREATE TABLE "user_email_confirmation_codes" (
"user_email_confirmation_code_id" UUID NOT NULL
CONSTRAINT "user_email_confirmation_codes_pkey"
PRIMARY KEY,
"user_email_id" UUID NOT NULL
CONSTRAINT "user_email_confirmation_codes_user_email_id_fkey"
REFERENCES "user_emails" ("user_email_id"),
"code" TEXT NOT NULL
CONSTRAINT "user_email_confirmation_codes_code_unique"
UNIQUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"expires_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"consumed_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE "user_sessions" (
"user_session_id" UUID NOT NULL
CONSTRAINT "user_sessions_pkey"
PRIMARY KEY,
"user_id" UUID NOT NULL
CONSTRAINT "user_sessions_user_id_fkey"
REFERENCES "users" ("user_id"),
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"finished_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE "user_session_authentications" (
"user_session_authentication_id" UUID NOT NULL
CONSTRAINT "user_session_authentications_pkey"
PRIMARY KEY,
"user_session_id" UUID NOT NULL
CONSTRAINT "user_session_authentications_user_session_id_fkey"
REFERENCES "user_sessions" ("user_session_id"),
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL
);
---------------------
-- Compat sessions --
---------------------
CREATE TABLE "compat_sessions" (
"compat_session_id" UUID NOT NULL
CONSTRAINT "compat_sessions_pkey"
PRIMARY KEY,
"user_id" UUID NOT NULL
CONSTRAINT "compat_sessions_user_id_fkey"
REFERENCES "users" ("user_id"),
"device_id" TEXT NOT NULL
CONSTRAINT "compat_sessions_device_id_unique"
UNIQUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"finished_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE "compat_sso_logins" (
"compat_sso_login_id" UUID NOT NULL
CONSTRAINT "compat_sso_logins_pkey"
PRIMARY KEY,
"redirect_uri" TEXT NOT NULL,
"login_token" TEXT NOT NULL
CONSTRAINT "compat_sessions_login_token_unique"
UNIQUE,
"compat_session_id" UUID
CONSTRAINT "compat_sso_logins_compat_session_id_fkey"
REFERENCES "compat_sessions" ("compat_session_id")
ON DELETE SET NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"fulfilled_at" TIMESTAMP WITH TIME ZONE,
"exchanged_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE "compat_access_tokens" (
"compat_access_token_id" UUID NOT NULL
CONSTRAINT "compat_access_tokens_pkey"
PRIMARY KEY,
"compat_session_id" UUID NOT NULL
CONSTRAINT "compat_access_tokens_compat_session_id_fkey"
REFERENCES "compat_sessions" ("compat_session_id"),
"access_token" TEXT NOT NULL
CONSTRAINT "compat_access_tokens_access_token_unique"
UNIQUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"expires_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE "compat_refresh_tokens" (
"compat_refresh_token_id" UUID NOT NULL
CONSTRAINT "compat_refresh_tokens_pkey"
PRIMARY KEY,
"compat_session_id" UUID NOT NULL
CONSTRAINT "compat_refresh_tokens_compat_session_id_fkey"
REFERENCES "compat_sessions" ("compat_session_id"),
"compat_access_token_id" UUID NOT NULL
CONSTRAINT "compat_refresh_tokens_compat_access_token_id_fkey"
REFERENCES "compat_access_tokens" ("compat_access_token_id"),
"refresh_token" TEXT NOT NULL
CONSTRAINT "compat_refresh_tokens_refresh_token_unique"
UNIQUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"consumed_at" TIMESTAMP WITH TIME ZONE
);
----------------
-- OAuth 2.0 ---
----------------
CREATE TABLE "oauth2_clients" (
"oauth2_client_id" UUID NOT NULL
CONSTRAINT "oauth2_clients_pkey"
PRIMARY KEY,
"encrypted_client_secret" TEXT,
"grant_type_authorization_code" BOOLEAN NOT NULL,
"grant_type_refresh_token" BOOLEAN NOT NULL,
"client_name" TEXT,
"logo_uri" TEXT,
"client_uri" TEXT,
"policy_uri" TEXT,
"tos_uri" TEXT,
"jwks_uri" TEXT,
"jwks" JSONB,
"id_token_signed_response_alg" TEXT,
"token_endpoint_auth_method" TEXT,
"token_endpoint_auth_signing_alg" TEXT,
"initiate_login_uri" TEXT,
"userinfo_signed_response_alg" TEXT,
"created_at" TIMESTAMP WITH TIME ZONE NULL
);
CREATE TABLE "oauth2_client_redirect_uris" (
"oauth2_client_redirect_uri_id" UUID NOT NULL
CONSTRAINT "oauth2_client_redirect_uris_pkey"
PRIMARY KEY,
"oauth2_client_id" UUID NOT NULL
CONSTRAINT "tbl_oauth2_client_id_fkey"
REFERENCES "oauth2_clients" ("oauth2_client_id"),
"redirect_uri" TEXT NOT NULL
);
CREATE TABLE "oauth2_sessions" (
"oauth2_session_id" UUID NOT NULL
CONSTRAINT "oauth2_sessions_pkey"
PRIMARY KEY,
"user_session_id" UUID NOT NULL
CONSTRAINT "oauth2_sessions_user_session_id_fkey"
REFERENCES "user_sessions" ("user_session_id"),
"oauth2_client_id" UUID NOT NULL
CONSTRAINT "oauth2_sessions_oauth2_client_id_fkey"
REFERENCES "oauth2_clients" ("oauth2_client_id"),
"scope" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"finished_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE "oauth2_consents" (
"oauth2_consent_id" UUID NOT NULL
CONSTRAINT "oauth2_consents_pkey"
PRIMARY KEY,
"oauth2_client_id" UUID NOT NULL
CONSTRAINT "oauth2_consents_oauth2_client_id_fkey"
REFERENCES "oauth2_clients" ("oauth2_client_id"),
"user_id" UUID NOT NULL
CONSTRAINT "oauth2_consents_user_id_fkey"
REFERENCES "users" ("user_id"),
"scope_token" TEXT NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"refreshed_at" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "oauth2_consents_unique"
UNIQUE ("oauth2_client_id", "user_id", "scope_token")
);
CREATE INDEX "oauth2_consents_oauth2_client_id_user_id"
ON "oauth2_consents" ("oauth2_client_id", "user_id");
CREATE TABLE "oauth2_access_tokens" (
"oauth2_access_token_id" UUID NOT NULL
CONSTRAINT "oauth2_access_tokens_pkey"
PRIMARY KEY,
"oauth2_session_id" UUID NOT NULL
CONSTRAINT "oauth2_access_tokens_oauth2_session_id_fkey"
REFERENCES "oauth2_sessions" ("oauth2_session_id"),
"access_token" TEXT NOT NULL
CONSTRAINT "oauth2_access_tokens_unique"
UNIQUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"expires_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"revoked_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE "oauth2_refresh_tokens" (
"oauth2_refresh_token_id" UUID NOT NULL
CONSTRAINT "oauth2_refresh_tokens_pkey"
PRIMARY KEY,
"oauth2_session_id" UUID NOT NULL
CONSTRAINT "oauth2_access_tokens_oauth2_session_id_fkey"
REFERENCES "oauth2_sessions" ("oauth2_session_id"),
"oauth2_access_token_id" UUID NOT NULL
CONSTRAINT "oauth2_refresh_tokens_oauth2_access_token_id_fkey"
REFERENCES "oauth2_access_tokens" ("oauth2_access_token_id"),
"refresh_token" TEXT NOT NULL
CONSTRAINT "oauth2_refresh_tokens_unique"
UNIQUE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"consumed_at" TIMESTAMP WITH TIME ZONE,
"revoked_at" TIMESTAMP WITH TIME ZONE
);
CREATE TABLE "oauth2_authorization_grants" (
"oauth2_authorization_grant_id" UUID NOT NULL
CONSTRAINT "oauth2_authorization_grants_pkey"
PRIMARY KEY,
"oauth2_client_id" UUID NOT NULL
CONSTRAINT "tbl_oauth2_client_fkey"
REFERENCES "oauth2_clients" ("oauth2_client_id"),
"oauth2_session_id" UUID
CONSTRAINT "tbl_oauth2_session_fkey"
REFERENCES "oauth2_sessions" ("oauth2_session_id"),
"authorization_code" TEXT
CONSTRAINT "oauth2_authorization_grants_authorization_code_unique"
UNIQUE,
"redirect_uri" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"state" TEXT,
"nonce" TEXT,
"max_age" INTEGER,
"response_mode" TEXT NOT NULL,
"code_challenge_method" TEXT,
"code_challenge" TEXT,
"response_type_code" BOOLEAN NOT NULL,
"response_type_id_token" BOOLEAN NOT NULL,
"requires_consent" BOOLEAN NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"fulfilled_at" TIMESTAMP WITH TIME ZONE,
"cancelled_at" TIMESTAMP WITH TIME ZONE,
"exchanged_at" TIMESTAMP WITH TIME ZONE
);

File diff suppressed because it is too large Load Diff

View File

@ -19,28 +19,28 @@ use mas_data_model::{
CompatAccessToken, CompatRefreshToken, CompatSession, CompatSsoLogin, CompatSsoLoginState, CompatAccessToken, CompatRefreshToken, CompatSession, CompatSsoLogin, CompatSsoLoginState,
Device, User, UserEmail, Device, User, UserEmail,
}; };
use sqlx::{postgres::types::PgInterval, Acquire, PgExecutor, Postgres}; use sqlx::{Acquire, PgExecutor, Postgres};
use thiserror::Error; use thiserror::Error;
use tokio::task; use tokio::task;
use tracing::{info_span, Instrument}; use tracing::{info_span, Instrument};
use ulid::Ulid;
use url::Url; use url::Url;
use uuid::Uuid;
use crate::{ use crate::{user::lookup_user_by_username, DatabaseInconsistencyError, PostgresqlBackend};
user::lookup_user_by_username, DatabaseInconsistencyError, IdAndCreationTime, PostgresqlBackend,
};
struct CompatAccessTokenLookup { struct CompatAccessTokenLookup {
compat_access_token_id: i64, compat_access_token_id: Uuid,
compat_access_token: String, compat_access_token: String,
compat_access_token_created_at: DateTime<Utc>, compat_access_token_created_at: DateTime<Utc>,
compat_access_token_expires_at: Option<DateTime<Utc>>, compat_access_token_expires_at: Option<DateTime<Utc>>,
compat_session_id: i64, compat_session_id: Uuid,
compat_session_created_at: DateTime<Utc>, compat_session_created_at: DateTime<Utc>,
compat_session_deleted_at: Option<DateTime<Utc>>, compat_session_finished_at: Option<DateTime<Utc>>,
compat_session_device_id: String, compat_session_device_id: String,
user_id: i64, user_id: Uuid,
user_username: String, user_username: String,
user_email_id: Option<i64>, user_email_id: Option<Uuid>,
user_email: Option<String>, user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>, user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -49,6 +49,7 @@ struct CompatAccessTokenLookup {
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("failed to lookup compat access token")] #[error("failed to lookup compat access token")]
pub enum CompatAccessTokenLookupError { pub enum CompatAccessTokenLookupError {
Expired { when: DateTime<Utc> },
Database(#[from] sqlx::Error), Database(#[from] sqlx::Error),
Inconsistency(#[from] DatabaseInconsistencyError), Inconsistency(#[from] DatabaseInconsistencyError),
} }
@ -56,7 +57,10 @@ pub enum CompatAccessTokenLookupError {
impl CompatAccessTokenLookupError { impl CompatAccessTokenLookupError {
#[must_use] #[must_use]
pub fn not_found(&self) -> bool { pub fn not_found(&self) -> bool {
matches!(self, Self::Database(sqlx::Error::RowNotFound)) matches!(
self,
Self::Database(sqlx::Error::RowNotFound) | Self::Expired { .. }
)
} }
} }
@ -75,41 +79,48 @@ pub async fn lookup_active_compat_access_token(
CompatAccessTokenLookup, CompatAccessTokenLookup,
r#" r#"
SELECT SELECT
ct.id AS "compat_access_token_id", ct.compat_access_token_id,
ct.token AS "compat_access_token", ct.access_token AS "compat_access_token",
ct.created_at AS "compat_access_token_created_at", ct.created_at AS "compat_access_token_created_at",
ct.expires_at AS "compat_access_token_expires_at", ct.expires_at AS "compat_access_token_expires_at",
cs.id AS "compat_session_id", cs.compat_session_id,
cs.created_at AS "compat_session_created_at", cs.created_at AS "compat_session_created_at",
cs.deleted_at AS "compat_session_deleted_at", cs.finished_at AS "compat_session_finished_at",
cs.device_id AS "compat_session_device_id", cs.device_id AS "compat_session_device_id",
u.id AS "user_id!", u.user_id AS "user_id!",
u.username AS "user_username!", u.username AS "user_username!",
ue.id AS "user_email_id?", ue.user_email_id AS "user_email_id?",
ue.email AS "user_email?", ue.email AS "user_email?",
ue.created_at AS "user_email_created_at?", ue.created_at AS "user_email_created_at?",
ue.confirmed_at AS "user_email_confirmed_at?" ue.confirmed_at AS "user_email_confirmed_at?"
FROM compat_access_tokens ct FROM compat_access_tokens ct
INNER JOIN compat_sessions cs INNER JOIN compat_sessions cs
ON cs.id = ct.compat_session_id USING (compat_session_id)
INNER JOIN users u INNER JOIN users u
ON u.id = cs.user_id USING (user_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE ct.token = $1 WHERE ct.access_token = $1
AND (ct.expires_at IS NULL OR ct.expires_at > NOW()) AND (ct.expires_at IS NULL OR ct.expires_at > NOW())
AND cs.deleted_at IS NULL AND cs.finished_at IS NULL
"#, "#,
token, token,
) )
.fetch_one(executor) .fetch_one(executor)
.instrument(info_span!("Fetch compat access token")) .instrument(info_span!("Fetch compat access token"))
.await?; .await?;
// Check for token expiration
if let Some(expires_at) = res.compat_access_token_expires_at {
if expires_at < Utc::now() {
return Err(CompatAccessTokenLookupError::Expired { when: expires_at });
}
}
let token = CompatAccessToken { let token = CompatAccessToken {
data: res.compat_access_token_id, data: res.compat_access_token_id.into(),
token: res.compat_access_token, token: res.compat_access_token,
created_at: res.compat_access_token_created_at, created_at: res.compat_access_token_created_at,
expires_at: res.compat_access_token_expires_at, expires_at: res.compat_access_token_expires_at,
@ -122,7 +133,7 @@ pub async fn lookup_active_compat_access_token(
res.user_email_confirmed_at, res.user_email_confirmed_at,
) { ) {
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
data: id, data: id.into(),
email, email,
created_at, created_at,
confirmed_at, confirmed_at,
@ -131,41 +142,42 @@ pub async fn lookup_active_compat_access_token(
_ => return Err(DatabaseInconsistencyError.into()), _ => return Err(DatabaseInconsistencyError.into()),
}; };
let id = Ulid::from(res.user_id);
let user = User { let user = User {
data: res.user_id, data: id,
username: res.user_username, username: res.user_username,
sub: format!("fake-sub-{}", res.user_id), sub: id.to_string(),
primary_email, primary_email,
}; };
let device = Device::try_from(res.compat_session_device_id).unwrap(); let device = Device::try_from(res.compat_session_device_id).unwrap();
let session = CompatSession { let session = CompatSession {
data: res.compat_session_id, data: res.compat_session_id.into(),
user, user,
device, device,
created_at: res.compat_session_created_at, created_at: res.compat_session_created_at,
deleted_at: res.compat_session_deleted_at, finished_at: res.compat_session_finished_at,
}; };
Ok((token, session)) Ok((token, session))
} }
pub struct CompatRefreshTokenLookup { pub struct CompatRefreshTokenLookup {
compat_refresh_token_id: i64, compat_refresh_token_id: Uuid,
compat_refresh_token: String, compat_refresh_token: String,
compat_refresh_token_created_at: DateTime<Utc>, compat_refresh_token_created_at: DateTime<Utc>,
compat_access_token_id: i64, compat_access_token_id: Uuid,
compat_access_token: String, compat_access_token: String,
compat_access_token_created_at: DateTime<Utc>, compat_access_token_created_at: DateTime<Utc>,
compat_access_token_expires_at: Option<DateTime<Utc>>, compat_access_token_expires_at: Option<DateTime<Utc>>,
compat_session_id: i64, compat_session_id: Uuid,
compat_session_created_at: DateTime<Utc>, compat_session_created_at: DateTime<Utc>,
compat_session_deleted_at: Option<DateTime<Utc>>, compat_session_finished_at: Option<DateTime<Utc>>,
compat_session_device_id: String, compat_session_device_id: String,
user_id: i64, user_id: Uuid,
user_username: String, user_username: String,
user_email_id: Option<i64>, user_email_id: Option<Uuid>,
user_email: Option<String>, user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>, user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -202,37 +214,37 @@ pub async fn lookup_active_compat_refresh_token(
CompatRefreshTokenLookup, CompatRefreshTokenLookup,
r#" r#"
SELECT SELECT
cr.id AS "compat_refresh_token_id", cr.compat_refresh_token_id,
cr.token AS "compat_refresh_token", cr.refresh_token AS "compat_refresh_token",
cr.created_at AS "compat_refresh_token_created_at", cr.created_at AS "compat_refresh_token_created_at",
ct.id AS "compat_access_token_id", ct.compat_access_token_id,
ct.token AS "compat_access_token", ct.access_token AS "compat_access_token",
ct.created_at AS "compat_access_token_created_at", ct.created_at AS "compat_access_token_created_at",
ct.expires_at AS "compat_access_token_expires_at", ct.expires_at AS "compat_access_token_expires_at",
cs.id AS "compat_session_id", cs.compat_session_id,
cs.created_at AS "compat_session_created_at", cs.created_at AS "compat_session_created_at",
cs.deleted_at AS "compat_session_deleted_at", cs.finished_at AS "compat_session_finished_at",
cs.device_id AS "compat_session_device_id", cs.device_id AS "compat_session_device_id",
u.id AS "user_id!", u.user_id,
u.username AS "user_username!", u.username AS "user_username!",
ue.id AS "user_email_id?", ue.user_email_id AS "user_email_id?",
ue.email AS "user_email?", ue.email AS "user_email?",
ue.created_at AS "user_email_created_at?", ue.created_at AS "user_email_created_at?",
ue.confirmed_at AS "user_email_confirmed_at?" ue.confirmed_at AS "user_email_confirmed_at?"
FROM compat_refresh_tokens cr FROM compat_refresh_tokens cr
INNER JOIN compat_access_tokens ct
ON ct.id = cr.compat_access_token_id
INNER JOIN compat_sessions cs INNER JOIN compat_sessions cs
ON cs.id = cr.compat_session_id USING (compat_session_id)
INNER JOIN compat_access_tokens ct
USING (compat_access_token_id)
INNER JOIN users u INNER JOIN users u
ON u.id = cs.user_id USING (user_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE cr.token = $1 WHERE cr.refresh_token = $1
AND cr.next_token_id IS NULL AND cr.consumed_at IS NULL
AND cs.deleted_at IS NULL AND cs.finished_at IS NULL
"#, "#,
token, token,
) )
@ -241,13 +253,13 @@ pub async fn lookup_active_compat_refresh_token(
.await?; .await?;
let refresh_token = CompatRefreshToken { let refresh_token = CompatRefreshToken {
data: res.compat_refresh_token_id, data: res.compat_refresh_token_id.into(),
token: res.compat_refresh_token, token: res.compat_refresh_token,
created_at: res.compat_refresh_token_created_at, created_at: res.compat_refresh_token_created_at,
}; };
let access_token = CompatAccessToken { let access_token = CompatAccessToken {
data: res.compat_access_token_id, data: res.compat_access_token_id.into(),
token: res.compat_access_token, token: res.compat_access_token,
created_at: res.compat_access_token_created_at, created_at: res.compat_access_token_created_at,
expires_at: res.compat_access_token_expires_at, expires_at: res.compat_access_token_expires_at,
@ -260,7 +272,7 @@ pub async fn lookup_active_compat_refresh_token(
res.user_email_confirmed_at, res.user_email_confirmed_at,
) { ) {
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
data: id, data: id.into(),
email, email,
created_at, created_at,
confirmed_at, confirmed_at,
@ -269,21 +281,22 @@ pub async fn lookup_active_compat_refresh_token(
_ => return Err(DatabaseInconsistencyError.into()), _ => return Err(DatabaseInconsistencyError.into()),
}; };
let id = Ulid::from(res.user_id);
let user = User { let user = User {
data: res.user_id, data: id,
username: res.user_username, username: res.user_username,
sub: format!("fake-sub-{}", res.user_id), sub: id.to_string(),
primary_email, primary_email,
}; };
let device = Device::try_from(res.compat_session_device_id).unwrap(); let device = Device::try_from(res.compat_session_device_id).unwrap();
let session = CompatSession { let session = CompatSession {
data: res.compat_session_id, data: res.compat_session_id.into(),
user, user,
device, device,
created_at: res.compat_session_created_at, created_at: res.compat_session_created_at,
deleted_at: res.compat_session_deleted_at, finished_at: res.compat_session_finished_at,
}; };
Ok((refresh_token, access_token, session)) Ok((refresh_token, access_token, session))
@ -310,7 +323,7 @@ pub async fn compat_login(
ORDER BY up.created_at DESC ORDER BY up.created_at DESC
LIMIT 1 LIMIT 1
"#, "#,
user.data, Uuid::from(user.data),
) )
.fetch_one(&mut txn) .fetch_one(&mut txn)
.instrument(tracing::info_span!("Lookup hashed password")) .instrument(tracing::info_span!("Lookup hashed password"))
@ -327,27 +340,30 @@ pub async fn compat_login(
.instrument(tracing::info_span!("Verify hashed password")) .instrument(tracing::info_span!("Verify hashed password"))
.await??; .await??;
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO compat_sessions (user_id, device_id) INSERT INTO compat_sessions
VALUES ($1, $2) (compat_session_id, user_id, device_id, created_at)
RETURNING id, created_at VALUES ($1, $2, $3, $4)
"#, "#,
user.data, Uuid::from(id),
Uuid::from(user.data),
device.as_str(), device.as_str(),
created_at,
) )
.fetch_one(&mut txn) .execute(&mut txn)
.instrument(tracing::info_span!("Insert compat session")) .instrument(tracing::info_span!("Insert compat session"))
.await .await
.context("could not insert compat session")?; .context("could not insert compat session")?;
let session = CompatSession { let session = CompatSession {
data: res.id, data: id,
user, user,
device, device,
created_at: res.created_at, created_at,
deleted_at: None, finished_at: None,
}; };
txn.commit().await.context("could not commit transaction")?; txn.commit().await.context("could not commit transaction")?;
@ -361,70 +377,48 @@ pub async fn add_compat_access_token(
token: String, token: String,
expires_after: Option<Duration>, expires_after: Option<Duration>,
) -> Result<CompatAccessToken<PostgresqlBackend>, anyhow::Error> { ) -> Result<CompatAccessToken<PostgresqlBackend>, anyhow::Error> {
if let Some(expires_after) = expires_after { let created_at = Utc::now();
// For some reason, we need to convert the type first let id = Ulid::from_datetime(created_at.into());
let pg_expires_after = PgInterval::try_from(expires_after) let expires_at = expires_after.map(|expires_after| created_at + expires_after);
// For some reason, this error type does not let me to just bubble up the error here
.map_err(|e| anyhow::anyhow!("failed to encode duration: {}", e))?;
let res = sqlx::query_as!( sqlx::query!(
IdAndCreationTime, r#"
r#" INSERT INTO compat_access_tokens
INSERT INTO compat_access_tokens (compat_session_id, token, created_at, expires_at) (compat_access_token_id, compat_session_id, access_token, created_at, expires_at)
VALUES ($1, $2, NOW(), NOW() + $3) VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at "#,
"#, Uuid::from(id),
session.data, Uuid::from(session.data),
token, token,
pg_expires_after, created_at,
) expires_at,
.fetch_one(executor) )
.instrument(tracing::info_span!("Insert compat access token")) .execute(executor)
.await .instrument(tracing::info_span!("Insert compat access token"))
.context("could not insert compat access token")?; .await
.context("could not insert compat access token")?;
Ok(CompatAccessToken { Ok(CompatAccessToken {
data: res.id, data: id,
token, token,
created_at: res.created_at, created_at,
expires_at: Some(res.created_at + expires_after), expires_at,
}) })
} else {
let res = sqlx::query_as!(
IdAndCreationTime,
r#"
INSERT INTO compat_access_tokens (compat_session_id, token)
VALUES ($1, $2)
RETURNING id, created_at
"#,
session.data,
token,
)
.fetch_one(executor)
.instrument(tracing::info_span!("Insert compat access token"))
.await
.context("could not insert compat access token")?;
Ok(CompatAccessToken {
data: res.id,
token,
created_at: res.created_at,
expires_at: None,
})
}
} }
pub async fn expire_compat_access_token( pub async fn expire_compat_access_token(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
access_token: CompatAccessToken<PostgresqlBackend>, access_token: CompatAccessToken<PostgresqlBackend>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let expires_at = Utc::now();
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
UPDATE compat_access_tokens UPDATE compat_access_tokens
SET expires_at = NOW() SET expires_at = $2
WHERE id = $1 WHERE compat_access_token_id = $1
"#, "#,
access_token.data, Uuid::from(access_token.data),
expires_at,
) )
.execute(executor) .execute(executor)
.await .await
@ -445,26 +439,30 @@ pub async fn add_compat_refresh_token(
access_token: &CompatAccessToken<PostgresqlBackend>, access_token: &CompatAccessToken<PostgresqlBackend>,
token: String, token: String,
) -> Result<CompatRefreshToken<PostgresqlBackend>, anyhow::Error> { ) -> Result<CompatRefreshToken<PostgresqlBackend>, anyhow::Error> {
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO compat_refresh_tokens (compat_session_id, compat_access_token_id, token) INSERT INTO compat_refresh_tokens
VALUES ($1, $2, $3) (compat_refresh_token_id, compat_session_id,
RETURNING id, created_at compat_access_token_id, refresh_token, created_at)
VALUES ($1, $2, $3, $4, $5)
"#, "#,
session.data, Uuid::from(id),
access_token.data, Uuid::from(session.data),
Uuid::from(access_token.data),
token, token,
created_at,
) )
.fetch_one(executor) .execute(executor)
.instrument(tracing::info_span!("Insert compat refresh token")) .instrument(tracing::info_span!("Insert compat refresh token"))
.await .await
.context("could not insert compat refresh token")?; .context("could not insert compat refresh token")?;
Ok(CompatRefreshToken { Ok(CompatRefreshToken {
data: res.id, data: id,
token, token,
created_at: res.created_at, created_at,
}) })
} }
@ -473,16 +471,19 @@ pub async fn compat_logout(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
token: &str, token: &str,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let finished_at = Utc::now();
// TODO: this does not check for token expiration
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
UPDATE compat_sessions UPDATE compat_sessions cs
SET deleted_at = NOW() SET finished_at = $2
FROM compat_access_tokens FROM compat_access_tokens ca
WHERE compat_access_tokens.token = $1 WHERE ca.access_token = $1
AND compat_sessions.id = compat_access_tokens.id AND ca.compat_session_id = cs.compat_session_id
AND compat_sessions.deleted_at IS NULL AND cs.finished_at IS NULL
"#, "#,
token, token,
finished_at,
) )
.execute(executor) .execute(executor)
.await .await
@ -495,19 +496,19 @@ pub async fn compat_logout(
} }
} }
pub async fn replace_compat_refresh_token( pub async fn consume_compat_refresh_token(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
refresh_token: &CompatRefreshToken<PostgresqlBackend>, refresh_token: CompatRefreshToken<PostgresqlBackend>,
next_refresh_token: &CompatRefreshToken<PostgresqlBackend>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let consumed_at = Utc::now();
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
UPDATE compat_refresh_tokens UPDATE compat_refresh_tokens
SET next_token_id = $2 SET consumed_at = $2
WHERE id = $1 WHERE compat_refresh_token_id = $1
"#, "#,
refresh_token.data, Uuid::from(refresh_token.data),
next_refresh_token.data consumed_at,
) )
.execute(executor) .execute(executor)
.await .await
@ -524,47 +525,50 @@ pub async fn replace_compat_refresh_token(
pub async fn insert_compat_sso_login( pub async fn insert_compat_sso_login(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
token: String, login_token: String,
redirect_uri: Url, redirect_uri: Url,
) -> anyhow::Result<CompatSsoLogin<PostgresqlBackend>> { ) -> anyhow::Result<CompatSsoLogin<PostgresqlBackend>> {
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO compat_sso_logins (token, redirect_uri) INSERT INTO compat_sso_logins
VALUES ($1, $2) (compat_sso_login_id, login_token, redirect_uri, created_at)
RETURNING id, created_at VALUES ($1, $2, $3, $4)
"#, "#,
&token, Uuid::from(id),
&login_token,
redirect_uri.as_str(), redirect_uri.as_str(),
created_at,
) )
.fetch_one(executor) .execute(executor)
.instrument(tracing::info_span!("Insert compat SSO login")) .instrument(tracing::info_span!("Insert compat SSO login"))
.await .await
.context("could not insert compat SSO login")?; .context("could not insert compat SSO login")?;
Ok(CompatSsoLogin { Ok(CompatSsoLogin {
data: res.id, data: id,
token, login_token,
redirect_uri, redirect_uri,
created_at: res.created_at, created_at,
state: CompatSsoLoginState::Pending, state: CompatSsoLoginState::Pending,
}) })
} }
struct CompatSsoLoginLookup { struct CompatSsoLoginLookup {
compat_sso_login_id: i64, compat_sso_login_id: Uuid,
compat_sso_login_token: String, compat_sso_login_token: String,
compat_sso_login_redirect_uri: String, compat_sso_login_redirect_uri: String,
compat_sso_login_created_at: DateTime<Utc>, compat_sso_login_created_at: DateTime<Utc>,
compat_sso_login_fullfilled_at: Option<DateTime<Utc>>, compat_sso_login_fulfilled_at: Option<DateTime<Utc>>,
compat_sso_login_exchanged_at: Option<DateTime<Utc>>, compat_sso_login_exchanged_at: Option<DateTime<Utc>>,
compat_session_id: Option<i64>, compat_session_id: Option<Uuid>,
compat_session_created_at: Option<DateTime<Utc>>, compat_session_created_at: Option<DateTime<Utc>>,
compat_session_deleted_at: Option<DateTime<Utc>>, compat_session_finished_at: Option<DateTime<Utc>>,
compat_session_device_id: Option<String>, compat_session_device_id: Option<String>,
user_id: Option<i64>, user_id: Option<Uuid>,
user_username: Option<String>, user_username: Option<String>,
user_email_id: Option<i64>, user_email_id: Option<Uuid>,
user_email: Option<String>, user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>, user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -584,7 +588,7 @@ impl TryFrom<CompatSsoLoginLookup> for CompatSsoLogin<PostgresqlBackend> {
res.user_email_confirmed_at, res.user_email_confirmed_at,
) { ) {
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
data: id, data: id.into(),
email, email,
created_at, created_at,
confirmed_at, confirmed_at,
@ -594,12 +598,16 @@ impl TryFrom<CompatSsoLoginLookup> for CompatSsoLogin<PostgresqlBackend> {
}; };
let user = match (res.user_id, res.user_username, primary_email) { let user = match (res.user_id, res.user_username, primary_email) {
(Some(id), Some(username), primary_email) => Some(User { (Some(id), Some(username), primary_email) => {
data: id, let id = Ulid::from(id);
username, Some(User {
sub: format!("fake-sub-{}", id), data: id,
primary_email, username,
}), sub: id.to_string(),
primary_email,
})
}
(None, None, None) => None, (None, None, None) => None,
_ => return Err(DatabaseInconsistencyError), _ => return Err(DatabaseInconsistencyError),
}; };
@ -608,17 +616,17 @@ impl TryFrom<CompatSsoLoginLookup> for CompatSsoLogin<PostgresqlBackend> {
res.compat_session_id, res.compat_session_id,
res.compat_session_device_id, res.compat_session_device_id,
res.compat_session_created_at, res.compat_session_created_at,
res.compat_session_deleted_at, res.compat_session_finished_at,
user, user,
) { ) {
(Some(id), Some(device_id), Some(created_at), deleted_at, Some(user)) => { (Some(id), Some(device_id), Some(created_at), finished_at, Some(user)) => {
let device = Device::try_from(device_id).map_err(|_| DatabaseInconsistencyError)?; let device = Device::try_from(device_id).map_err(|_| DatabaseInconsistencyError)?;
Some(CompatSession { Some(CompatSession {
data: id, data: id.into(),
user, user,
device, device,
created_at, created_at,
deleted_at, finished_at,
}) })
} }
(None, None, None, None, None) => None, (None, None, None, None, None) => None,
@ -626,18 +634,18 @@ impl TryFrom<CompatSsoLoginLookup> for CompatSsoLogin<PostgresqlBackend> {
}; };
let state = match ( let state = match (
res.compat_sso_login_fullfilled_at, res.compat_sso_login_fulfilled_at,
res.compat_sso_login_exchanged_at, res.compat_sso_login_exchanged_at,
session, session,
) { ) {
(None, None, None) => CompatSsoLoginState::Pending, (None, None, None) => CompatSsoLoginState::Pending,
(Some(fullfilled_at), None, Some(session)) => CompatSsoLoginState::Fullfilled { (Some(fulfilled_at), None, Some(session)) => CompatSsoLoginState::Fulfilled {
fullfilled_at, fulfilled_at,
session, session,
}, },
(Some(fullfilled_at), Some(exchanged_at), Some(session)) => { (Some(fulfilled_at), Some(exchanged_at), Some(session)) => {
CompatSsoLoginState::Exchanged { CompatSsoLoginState::Exchanged {
fullfilled_at, fulfilled_at,
exchanged_at, exchanged_at,
session, session,
} }
@ -646,8 +654,8 @@ impl TryFrom<CompatSsoLoginLookup> for CompatSsoLogin<PostgresqlBackend> {
}; };
Ok(CompatSsoLogin { Ok(CompatSsoLogin {
data: res.compat_sso_login_id, data: res.compat_sso_login_id.into(),
token: res.compat_sso_login_token, login_token: res.compat_sso_login_token,
redirect_uri, redirect_uri,
created_at: res.compat_sso_login_created_at, created_at: res.compat_sso_login_created_at,
state, state,
@ -673,38 +681,38 @@ impl CompatSsoLoginLookupError {
#[tracing::instrument(skip(executor), err)] #[tracing::instrument(skip(executor), err)]
pub async fn get_compat_sso_login_by_id( pub async fn get_compat_sso_login_by_id(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
id: i64, id: Ulid,
) -> Result<CompatSsoLogin<PostgresqlBackend>, CompatSsoLoginLookupError> { ) -> Result<CompatSsoLogin<PostgresqlBackend>, CompatSsoLoginLookupError> {
let res = sqlx::query_as!( let res = sqlx::query_as!(
CompatSsoLoginLookup, CompatSsoLoginLookup,
r#" r#"
SELECT SELECT
cl.id AS "compat_sso_login_id", cl.compat_sso_login_id,
cl.token AS "compat_sso_login_token", cl.login_token AS "compat_sso_login_token",
cl.redirect_uri AS "compat_sso_login_redirect_uri", cl.redirect_uri AS "compat_sso_login_redirect_uri",
cl.created_at AS "compat_sso_login_created_at", cl.created_at AS "compat_sso_login_created_at",
cl.fullfilled_at AS "compat_sso_login_fullfilled_at", cl.fulfilled_at AS "compat_sso_login_fulfilled_at",
cl.exchanged_at AS "compat_sso_login_exchanged_at", cl.exchanged_at AS "compat_sso_login_exchanged_at",
cs.id AS "compat_session_id?", cs.compat_session_id AS "compat_session_id?",
cs.created_at AS "compat_session_created_at?", cs.created_at AS "compat_session_created_at?",
cs.deleted_at AS "compat_session_deleted_at?", cs.finished_at AS "compat_session_finished_at?",
cs.device_id AS "compat_session_device_id?", cs.device_id AS "compat_session_device_id?",
u.id AS "user_id?", u.user_id AS "user_id?",
u.username AS "user_username?", u.username AS "user_username?",
ue.id AS "user_email_id?", ue.user_email_id AS "user_email_id?",
ue.email AS "user_email?", ue.email AS "user_email?",
ue.created_at AS "user_email_created_at?", ue.created_at AS "user_email_created_at?",
ue.confirmed_at AS "user_email_confirmed_at?" ue.confirmed_at AS "user_email_confirmed_at?"
FROM compat_sso_logins cl FROM compat_sso_logins cl
LEFT JOIN compat_sessions cs LEFT JOIN compat_sessions cs
ON cs.id = cl.compat_session_id USING (compat_session_id)
LEFT JOIN users u LEFT JOIN users u
ON u.id = cs.user_id USING (user_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE cl.id = $1 WHERE cl.compat_sso_login_id = $1
"#, "#,
id, Uuid::from(id),
) )
.fetch_one(executor) .fetch_one(executor)
.instrument(tracing::info_span!("Lookup compat SSO login")) .instrument(tracing::info_span!("Lookup compat SSO login"))
@ -723,30 +731,30 @@ pub async fn get_compat_sso_login_by_token(
CompatSsoLoginLookup, CompatSsoLoginLookup,
r#" r#"
SELECT SELECT
cl.id AS "compat_sso_login_id", cl.compat_sso_login_id,
cl.token AS "compat_sso_login_token", cl.login_token AS "compat_sso_login_token",
cl.redirect_uri AS "compat_sso_login_redirect_uri", cl.redirect_uri AS "compat_sso_login_redirect_uri",
cl.created_at AS "compat_sso_login_created_at", cl.created_at AS "compat_sso_login_created_at",
cl.fullfilled_at AS "compat_sso_login_fullfilled_at", cl.fulfilled_at AS "compat_sso_login_fulfilled_at",
cl.exchanged_at AS "compat_sso_login_exchanged_at", cl.exchanged_at AS "compat_sso_login_exchanged_at",
cs.id AS "compat_session_id?", cs.compat_session_id AS "compat_session_id?",
cs.created_at AS "compat_session_created_at?", cs.created_at AS "compat_session_created_at?",
cs.deleted_at AS "compat_session_deleted_at?", cs.finished_at AS "compat_session_finished_at?",
cs.device_id AS "compat_session_device_id?", cs.device_id AS "compat_session_device_id?",
u.id AS "user_id?", u.user_id AS "user_id?",
u.username AS "user_username?", u.username AS "user_username?",
ue.id AS "user_email_id?", ue.user_email_id AS "user_email_id?",
ue.email AS "user_email?", ue.email AS "user_email?",
ue.created_at AS "user_email_created_at?", ue.created_at AS "user_email_created_at?",
ue.confirmed_at AS "user_email_confirmed_at?" ue.confirmed_at AS "user_email_confirmed_at?"
FROM compat_sso_logins cl FROM compat_sso_logins cl
LEFT JOIN compat_sessions cs LEFT JOIN compat_sessions cs
ON cs.id = cl.compat_session_id USING (compat_session_id)
LEFT JOIN users u LEFT JOIN users u
ON u.id = cs.user_id USING (user_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE cl.token = $1 WHERE cl.login_token = $1
"#, "#,
token, token,
) )
@ -769,49 +777,52 @@ pub async fn fullfill_compat_sso_login(
let mut txn = conn.begin().await.context("could not start transaction")?; let mut txn = conn.begin().await.context("could not start transaction")?;
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO compat_sessions (user_id, device_id) INSERT INTO compat_sessions (compat_session_id, user_id, device_id, created_at)
VALUES ($1, $2) VALUES ($1, $2, $3, $4)
RETURNING id, created_at
"#, "#,
user.data, Uuid::from(id),
Uuid::from(user.data),
device.as_str(), device.as_str(),
created_at,
) )
.fetch_one(&mut txn) .execute(&mut txn)
.instrument(tracing::info_span!("Insert compat session")) .instrument(tracing::info_span!("Insert compat session"))
.await .await
.context("could not insert compat session")?; .context("could not insert compat session")?;
let session = CompatSession { let session = CompatSession {
data: res.id, data: id,
user, user,
device, device,
created_at: res.created_at, created_at,
deleted_at: None, finished_at: None,
}; };
let res = sqlx::query_scalar!( let fulfilled_at = Utc::now();
sqlx::query!(
r#" r#"
UPDATE compat_sso_logins UPDATE compat_sso_logins
SET SET
fullfilled_at = NOW(), compat_session_id = $2,
compat_session_id = $2 fulfilled_at = $3
WHERE WHERE
id = $1 compat_sso_login_id = $1
RETURNING fullfilled_at AS "fullfilled_at!"
"#, "#,
login.data, Uuid::from(login.data),
session.data, Uuid::from(session.data),
fulfilled_at,
) )
.fetch_one(&mut txn) .execute(&mut txn)
.instrument(tracing::info_span!("Update compat SSO login")) .instrument(tracing::info_span!("Update compat SSO login"))
.await .await
.context("could not update compat SSO login")?; .context("could not update compat SSO login")?;
let state = CompatSsoLoginState::Fullfilled { let state = CompatSsoLoginState::Fulfilled {
fullfilled_at: res, fulfilled_at,
session, session,
}; };
@ -826,33 +837,34 @@ pub async fn mark_compat_sso_login_as_exchanged(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
mut login: CompatSsoLogin<PostgresqlBackend>, mut login: CompatSsoLogin<PostgresqlBackend>,
) -> anyhow::Result<CompatSsoLogin<PostgresqlBackend>> { ) -> anyhow::Result<CompatSsoLogin<PostgresqlBackend>> {
let (fullfilled_at, session) = match login.state { let (fulfilled_at, session) = match login.state {
CompatSsoLoginState::Fullfilled { CompatSsoLoginState::Fulfilled {
fullfilled_at, fulfilled_at,
session, session,
} => (fullfilled_at, session), } => (fulfilled_at, session),
_ => bail!("sso login in wrong state"), _ => bail!("sso login in wrong state"),
}; };
let res = sqlx::query_scalar!( let exchanged_at = Utc::now();
sqlx::query!(
r#" r#"
UPDATE compat_sso_logins UPDATE compat_sso_logins
SET SET
exchanged_at = NOW() exchanged_at = $2
WHERE WHERE
id = $1 compat_sso_login_id = $1
RETURNING exchanged_at AS "exchanged_at!"
"#, "#,
login.data, Uuid::from(login.data),
exchanged_at,
) )
.fetch_one(executor) .execute(executor)
.instrument(tracing::info_span!("Update compat SSO login")) .instrument(tracing::info_span!("Update compat SSO login"))
.await .await
.context("could not update compat SSO login")?; .context("could not update compat SSO login")?;
let state = CompatSsoLoginState::Exchanged { let state = CompatSsoLoginState::Exchanged {
fullfilled_at, fulfilled_at,
exchanged_at: res, exchanged_at,
session, session,
}; };
login.state = state; login.state = state;

View File

@ -23,11 +23,11 @@
clippy::module_name_repetitions clippy::module_name_repetitions
)] )]
use chrono::{DateTime, Utc};
use mas_data_model::{StorageBackend, StorageBackendMarker}; use mas_data_model::{StorageBackend, StorageBackendMarker};
use serde::Serialize; use serde::Serialize;
use sqlx::migrate::Migrator; use sqlx::migrate::Migrator;
use thiserror::Error; use thiserror::Error;
use ulid::Ulid;
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("database query returned an inconsistent state")] #[error("database query returned an inconsistent state")]
@ -37,29 +37,24 @@ pub struct DatabaseInconsistencyError;
pub struct PostgresqlBackend; pub struct PostgresqlBackend;
impl StorageBackend for PostgresqlBackend { impl StorageBackend for PostgresqlBackend {
type AccessTokenData = i64; type AccessTokenData = Ulid;
type AuthenticationData = i64; type AuthenticationData = Ulid;
type AuthorizationGrantData = i64; type AuthorizationGrantData = Ulid;
type BrowserSessionData = i64; type BrowserSessionData = Ulid;
type ClientData = i64; type ClientData = Ulid;
type CompatAccessTokenData = i64; type CompatAccessTokenData = Ulid;
type CompatRefreshTokenData = i64; type CompatRefreshTokenData = Ulid;
type CompatSessionData = i64; type CompatSessionData = Ulid;
type CompatSsoLoginData = i64; type CompatSsoLoginData = Ulid;
type RefreshTokenData = i64; type RefreshTokenData = Ulid;
type SessionData = i64; type SessionData = Ulid;
type UserData = i64; type UserData = Ulid;
type UserEmailData = i64; type UserEmailData = Ulid;
type UserEmailVerificationData = i64; type UserEmailVerificationData = Ulid;
} }
impl StorageBackendMarker for PostgresqlBackend {} impl StorageBackendMarker for PostgresqlBackend {}
struct IdAndCreationTime {
id: i64,
created_at: DateTime<Utc>,
}
pub mod compat; pub mod compat;
pub mod oauth2; pub mod oauth2;
pub mod user; pub mod user;

View File

@ -17,62 +17,76 @@ use chrono::{DateTime, Duration, Utc};
use mas_data_model::{AccessToken, Authentication, BrowserSession, Session, User, UserEmail}; use mas_data_model::{AccessToken, Authentication, BrowserSession, Session, User, UserEmail};
use sqlx::{Acquire, PgExecutor, Postgres}; use sqlx::{Acquire, PgExecutor, Postgres};
use thiserror::Error; use thiserror::Error;
use ulid::Ulid;
use uuid::Uuid;
use super::client::{lookup_client, ClientFetchError}; use super::client::{lookup_client, ClientFetchError};
use crate::{DatabaseInconsistencyError, IdAndCreationTime, PostgresqlBackend}; use crate::{DatabaseInconsistencyError, PostgresqlBackend};
#[tracing::instrument(
skip_all,
fields(
session.id = %session.data,
client.id = %session.client.data,
user.id = %session.browser_session.user.data,
access_token.id,
),
err(Debug),
)]
pub async fn add_access_token( pub async fn add_access_token(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
session: &Session<PostgresqlBackend>, session: &Session<PostgresqlBackend>,
token: &str, access_token: String,
expires_after: Duration, expires_after: Duration,
) -> anyhow::Result<AccessToken<PostgresqlBackend>> { ) -> anyhow::Result<AccessToken<PostgresqlBackend>> {
// Checked convertion of duration to i32, maxing at i32::MAX let created_at = Utc::now();
let expires_after_seconds = i32::try_from(expires_after.num_seconds()).unwrap_or(i32::MAX); let expires_at = created_at + expires_after;
let id = Ulid::from_datetime(created_at.into());
let res = sqlx::query_as!( tracing::Span::current().record("access_token.id", tracing::field::display(id));
IdAndCreationTime,
sqlx::query!(
r#" r#"
INSERT INTO oauth2_access_tokens INSERT INTO oauth2_access_tokens
(oauth2_session_id, token, expires_after) (oauth2_access_token_id, oauth2_session_id, access_token, created_at, expires_at)
VALUES VALUES
($1, $2, $3) ($1, $2, $3, $4, $5)
RETURNING
id, created_at
"#, "#,
session.data, Uuid::from(id),
token, Uuid::from(session.data),
expires_after_seconds, &access_token,
created_at,
expires_at,
) )
.fetch_one(executor) .execute(executor)
.await .await
.context("could not insert oauth2 access token")?; .context("could not insert oauth2 access token")?;
Ok(AccessToken { Ok(AccessToken {
data: res.id, data: id,
expires_after, access_token,
token: token.to_owned(), jti: id.to_string(),
jti: format!("{}", res.id), created_at,
created_at: res.created_at, expires_at,
}) })
} }
#[derive(Debug)] #[derive(Debug)]
pub struct OAuth2AccessTokenLookup { pub struct OAuth2AccessTokenLookup {
access_token_id: i64, oauth2_access_token_id: Uuid,
access_token: String, oauth2_access_token: String,
access_token_expires_after: i32, oauth2_access_token_created_at: DateTime<Utc>,
access_token_created_at: DateTime<Utc>, oauth2_access_token_expires_at: DateTime<Utc>,
session_id: i64, oauth2_session_id: Uuid,
oauth2_client_id: i64, oauth2_client_id: Uuid,
scope: String, scope: String,
user_session_id: i64, user_session_id: Uuid,
user_session_created_at: DateTime<Utc>, user_session_created_at: DateTime<Utc>,
user_id: i64, user_id: Uuid,
user_username: String, user_username: String,
user_session_last_authentication_id: Option<i64>, user_session_last_authentication_id: Option<Uuid>,
user_session_last_authentication_created_at: Option<DateTime<Utc>>, user_session_last_authentication_created_at: Option<DateTime<Utc>>,
user_email_id: Option<i64>, user_email_id: Option<Uuid>,
user_email: Option<String>, user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>, user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -114,40 +128,39 @@ where
OAuth2AccessTokenLookup, OAuth2AccessTokenLookup,
r#" r#"
SELECT SELECT
at.id AS "access_token_id", at.oauth2_access_token_id,
at.token AS "access_token", at.access_token AS "oauth2_access_token",
at.expires_after AS "access_token_expires_after", at.created_at AS "oauth2_access_token_created_at",
at.created_at AS "access_token_created_at", at.expires_at AS "oauth2_access_token_expires_at",
os.id AS "session_id!", os.oauth2_session_id AS "oauth2_session_id!",
os.oauth2_client_id AS "oauth2_client_id!", os.oauth2_client_id AS "oauth2_client_id!",
os.scope AS "scope!", os.scope AS "scope!",
us.id AS "user_session_id!", us.user_session_id AS "user_session_id!",
us.created_at AS "user_session_created_at!", us.created_at AS "user_session_created_at!",
u.id AS "user_id!", u.user_id AS "user_id!",
u.username AS "user_username!", u.username AS "user_username!",
usa.id AS "user_session_last_authentication_id?", usa.user_session_authentication_id AS "user_session_last_authentication_id?",
usa.created_at AS "user_session_last_authentication_created_at?", usa.created_at AS "user_session_last_authentication_created_at?",
ue.id AS "user_email_id?", ue.user_email_id AS "user_email_id?",
ue.email AS "user_email?", ue.email AS "user_email?",
ue.created_at AS "user_email_created_at?", ue.created_at AS "user_email_created_at?",
ue.confirmed_at AS "user_email_confirmed_at?" ue.confirmed_at AS "user_email_confirmed_at?"
FROM oauth2_access_tokens at FROM oauth2_access_tokens at
INNER JOIN oauth2_sessions os INNER JOIN oauth2_sessions os
ON os.id = at.oauth2_session_id USING (oauth2_session_id)
INNER JOIN user_sessions us INNER JOIN user_sessions us
ON us.id = os.user_session_id USING (user_session_id)
INNER JOIN users u INNER JOIN users u
ON u.id = us.user_id USING (user_id)
LEFT JOIN user_session_authentications usa LEFT JOIN user_session_authentications usa
ON usa.session_id = us.id USING (user_session_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE at.token = $1 WHERE at.access_token = $1
AND at.created_at + (at.expires_after * INTERVAL '1 second') >= now() AND at.revoked_at IS NULL
AND us.active AND os.finished_at IS NULL
AND os.ended_at IS NULL
ORDER BY usa.created_at DESC ORDER BY usa.created_at DESC
LIMIT 1 LIMIT 1
@ -158,14 +171,14 @@ where
.await?; .await?;
let access_token = AccessToken { let access_token = AccessToken {
data: res.access_token_id, data: res.oauth2_access_token_id.into(),
jti: format!("{}", res.access_token_id), jti: res.oauth2_access_token_id.to_string(),
token: res.access_token, access_token: res.oauth2_access_token,
created_at: res.access_token_created_at, created_at: res.oauth2_access_token_created_at,
expires_after: Duration::seconds(res.access_token_expires_after.into()), expires_at: res.oauth2_access_token_expires_at,
}; };
let client = lookup_client(&mut *conn, res.oauth2_client_id).await?; let client = lookup_client(&mut *conn, res.oauth2_client_id.into()).await?;
let primary_email = match ( let primary_email = match (
res.user_email_id, res.user_email_id,
@ -174,7 +187,7 @@ where
res.user_email_confirmed_at, res.user_email_confirmed_at,
) { ) {
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
data: id, data: id.into(),
email, email,
created_at, created_at,
confirmed_at, confirmed_at,
@ -183,10 +196,11 @@ where
_ => return Err(DatabaseInconsistencyError.into()), _ => return Err(DatabaseInconsistencyError.into()),
}; };
let id = Ulid::from(res.user_id);
let user = User { let user = User {
data: res.user_id, data: id,
username: res.user_username, username: res.user_username,
sub: format!("fake-sub-{}", res.user_id), sub: id.to_string(),
primary_email, primary_email,
}; };
@ -196,14 +210,14 @@ where
) { ) {
(None, None) => None, (None, None) => None,
(Some(id), Some(created_at)) => Some(Authentication { (Some(id), Some(created_at)) => Some(Authentication {
data: id, data: id.into(),
created_at, created_at,
}), }),
_ => return Err(DatabaseInconsistencyError.into()), _ => return Err(DatabaseInconsistencyError.into()),
}; };
let browser_session = BrowserSession { let browser_session = BrowserSession {
data: res.user_session_id, data: res.user_session_id.into(),
created_at: res.user_session_created_at, created_at: res.user_session_created_at,
user, user,
last_authentication, last_authentication,
@ -212,7 +226,7 @@ where
let scope = res.scope.parse().map_err(|_e| DatabaseInconsistencyError)?; let scope = res.scope.parse().map_err(|_e| DatabaseInconsistencyError)?;
let session = Session { let session = Session {
data: res.session_id, data: res.oauth2_session_id.into(),
client, client,
browser_session, browser_session,
scope, scope,
@ -222,16 +236,24 @@ where
} }
} }
#[tracing::instrument(
skip_all,
fields(access_token.id = %access_token.data),
err(Debug),
)]
pub async fn revoke_access_token( pub async fn revoke_access_token(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
access_token: &AccessToken<PostgresqlBackend>, access_token: AccessToken<PostgresqlBackend>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let revoked_at = Utc::now();
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
DELETE FROM oauth2_access_tokens UPDATE oauth2_access_tokens
WHERE id = $1 SET revoked_at = $2
WHERE oauth2_access_token_id = $1
"#, "#,
access_token.data, Uuid::from(access_token.data),
revoked_at,
) )
.execute(executor) .execute(executor)
.await .await
@ -245,11 +267,14 @@ pub async fn revoke_access_token(
} }
pub async fn cleanup_expired(executor: impl PgExecutor<'_>) -> anyhow::Result<u64> { pub async fn cleanup_expired(executor: impl PgExecutor<'_>) -> anyhow::Result<u64> {
// Cleanup token which expired more than 15 minutes ago
let threshold = Utc::now() - Duration::minutes(15);
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
DELETE FROM oauth2_access_tokens DELETE FROM oauth2_access_tokens
WHERE created_at + (expires_after * INTERVAL '1 second') + INTERVAL '15 minutes' < now() WHERE expires_at < $1
"#, "#,
threshold,
) )
.execute(executor) .execute(executor)
.await .await

View File

@ -25,11 +25,21 @@ use mas_data_model::{
use mas_iana::oauth::PkceCodeChallengeMethod; use mas_iana::oauth::PkceCodeChallengeMethod;
use oauth2_types::{requests::ResponseMode, scope::Scope}; use oauth2_types::{requests::ResponseMode, scope::Scope};
use sqlx::{PgConnection, PgExecutor}; use sqlx::{PgConnection, PgExecutor};
use ulid::Ulid;
use url::Url; use url::Url;
use uuid::Uuid;
use super::client::lookup_client; use super::client::lookup_client;
use crate::{DatabaseInconsistencyError, IdAndCreationTime, PostgresqlBackend}; use crate::{DatabaseInconsistencyError, PostgresqlBackend};
#[tracing::instrument(
skip_all,
fields(
client.id = %client.data,
grant.id,
),
err(Debug),
)]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn new_authorization_grant( pub async fn new_authorization_grant(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
@ -40,7 +50,7 @@ pub async fn new_authorization_grant(
state: Option<String>, state: Option<String>,
nonce: Option<String>, nonce: Option<String>,
max_age: Option<NonZeroU32>, max_age: Option<NonZeroU32>,
acr_values: Option<String>, _acr_values: Option<String>,
response_mode: ResponseMode, response_mode: ResponseMode,
response_type_id_token: bool, response_type_id_token: bool,
requires_consent: bool, requires_consent: bool,
@ -53,26 +63,43 @@ pub async fn new_authorization_grant(
.as_ref() .as_ref()
.and_then(|c| c.pkce.as_ref()) .and_then(|c| c.pkce.as_ref())
.map(|p| p.challenge_method.to_string()); .map(|p| p.challenge_method.to_string());
// TODO: this conversion is a bit ugly
let max_age_i32 = max_age.map(|x| i32::try_from(u32::from(x)).unwrap_or(i32::MAX));
let code_str = code.as_ref().map(|c| &c.code); let code_str = code.as_ref().map(|c| &c.code);
let res = sqlx::query_as!(
IdAndCreationTime, let created_at = Utc::now();
let id = Ulid::from_datetime(created_at.into());
tracing::Span::current().record("grant.id", tracing::field::display(id));
sqlx::query!(
r#" r#"
INSERT INTO oauth2_authorization_grants INSERT INTO oauth2_authorization_grants (
(oauth2_client_id, redirect_uri, scope, state, nonce, max_age, oauth2_authorization_grant_id,
acr_values, response_mode, code_challenge, code_challenge_method, oauth2_client_id,
response_type_code, response_type_id_token, code, requires_consent) redirect_uri,
scope,
state,
nonce,
max_age,
response_mode,
code_challenge,
code_challenge_method,
response_type_code,
response_type_id_token,
authorization_code,
requires_consent,
created_at
)
VALUES VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, created_at
"#, "#,
&client.data, Uuid::from(id),
Uuid::from(client.data),
redirect_uri.to_string(), redirect_uri.to_string(),
scope.to_string(), scope.to_string(),
state, state,
nonce, nonce,
// TODO: this conversion is a bit ugly max_age_i32,
max_age.map(|x| i32::try_from(u32::from(x)).unwrap_or(i32::MAX)),
acr_values,
response_mode.to_string(), response_mode.to_string(),
code_challenge, code_challenge,
code_challenge_method, code_challenge_method,
@ -80,13 +107,14 @@ pub async fn new_authorization_grant(
response_type_id_token, response_type_id_token,
code_str, code_str,
requires_consent, requires_consent,
created_at,
) )
.fetch_one(executor) .execute(executor)
.await .await
.context("could not insert oauth2 authorization grant")?; .context("could not insert oauth2 authorization grant")?;
Ok(AuthorizationGrant { Ok(AuthorizationGrant {
data: res.id, data: id,
stage: AuthorizationGrantStage::Pending, stage: AuthorizationGrantStage::Pending,
code, code,
redirect_uri, redirect_uri,
@ -95,9 +123,8 @@ pub async fn new_authorization_grant(
state, state,
nonce, nonce,
max_age, max_age,
acr_values,
response_mode, response_mode,
created_at: res.created_at, created_at,
response_type_id_token, response_type_id_token,
requires_consent, requires_consent,
}) })
@ -105,33 +132,32 @@ pub async fn new_authorization_grant(
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
struct GrantLookup { struct GrantLookup {
grant_id: i64, oauth2_authorization_grant_id: Uuid,
grant_created_at: DateTime<Utc>, oauth2_authorization_grant_created_at: DateTime<Utc>,
grant_cancelled_at: Option<DateTime<Utc>>, oauth2_authorization_grant_cancelled_at: Option<DateTime<Utc>>,
grant_fulfilled_at: Option<DateTime<Utc>>, oauth2_authorization_grant_fulfilled_at: Option<DateTime<Utc>>,
grant_exchanged_at: Option<DateTime<Utc>>, oauth2_authorization_grant_exchanged_at: Option<DateTime<Utc>>,
grant_scope: String, oauth2_authorization_grant_scope: String,
grant_state: Option<String>, oauth2_authorization_grant_state: Option<String>,
grant_redirect_uri: String, oauth2_authorization_grant_nonce: Option<String>,
grant_response_mode: String, oauth2_authorization_grant_redirect_uri: String,
grant_nonce: Option<String>, oauth2_authorization_grant_response_mode: String,
grant_max_age: Option<i32>, oauth2_authorization_grant_max_age: Option<i32>,
grant_acr_values: Option<String>, oauth2_authorization_grant_response_type_code: bool,
grant_response_type_code: bool, oauth2_authorization_grant_response_type_id_token: bool,
grant_response_type_id_token: bool, oauth2_authorization_grant_code: Option<String>,
grant_code: Option<String>, oauth2_authorization_grant_code_challenge: Option<String>,
grant_code_challenge: Option<String>, oauth2_authorization_grant_code_challenge_method: Option<String>,
grant_code_challenge_method: Option<String>, oauth2_authorization_grant_requires_consent: bool,
grant_requires_consent: bool, oauth2_client_id: Uuid,
oauth2_client_id: i64, oauth2_session_id: Option<Uuid>,
session_id: Option<i64>, user_session_id: Option<Uuid>,
user_session_id: Option<i64>,
user_session_created_at: Option<DateTime<Utc>>, user_session_created_at: Option<DateTime<Utc>>,
user_id: Option<i64>, user_id: Option<Uuid>,
user_username: Option<String>, user_username: Option<String>,
user_session_last_authentication_id: Option<i64>, user_session_last_authentication_id: Option<Uuid>,
user_session_last_authentication_created_at: Option<DateTime<Utc>>, user_session_last_authentication_created_at: Option<DateTime<Utc>>,
user_email_id: Option<i64>, user_email_id: Option<Uuid>,
user_email: Option<String>, user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>, user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -144,12 +170,12 @@ impl GrantLookup {
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
) -> Result<AuthorizationGrant<PostgresqlBackend>, DatabaseInconsistencyError> { ) -> Result<AuthorizationGrant<PostgresqlBackend>, DatabaseInconsistencyError> {
let scope: Scope = self let scope: Scope = self
.grant_scope .oauth2_authorization_grant_scope
.parse() .parse()
.map_err(|_e| DatabaseInconsistencyError)?; .map_err(|_e| DatabaseInconsistencyError)?;
// TODO: don't unwrap // TODO: don't unwrap
let client = lookup_client(executor, self.oauth2_client_id) let client = lookup_client(executor, self.oauth2_client_id.into())
.await .await
.unwrap(); .unwrap();
@ -158,7 +184,7 @@ impl GrantLookup {
self.user_session_last_authentication_created_at, self.user_session_last_authentication_created_at,
) { ) {
(Some(id), Some(created_at)) => Some(Authentication { (Some(id), Some(created_at)) => Some(Authentication {
data: id, data: id.into(),
created_at, created_at,
}), }),
(None, None) => None, (None, None) => None,
@ -172,7 +198,7 @@ impl GrantLookup {
self.user_email_confirmed_at, self.user_email_confirmed_at,
) { ) {
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
data: id, data: id.into(),
email, email,
created_at, created_at,
confirmed_at, confirmed_at,
@ -182,7 +208,7 @@ impl GrantLookup {
}; };
let session = match ( let session = match (
self.session_id, self.oauth2_session_id,
self.user_session_id, self.user_session_id,
self.user_session_created_at, self.user_session_created_at,
self.user_id, self.user_id,
@ -199,15 +225,16 @@ impl GrantLookup {
last_authentication, last_authentication,
primary_email, primary_email,
) => { ) => {
let user_id = Ulid::from(user_id);
let user = User { let user = User {
data: user_id, data: user_id,
username: user_username, username: user_username,
sub: format!("fake-sub-{}", user_id), sub: user_id.to_string(),
primary_email, primary_email,
}; };
let browser_session = BrowserSession { let browser_session = BrowserSession {
data: user_session_id, data: user_session_id.into(),
user, user,
created_at: user_session_created_at, created_at: user_session_created_at,
last_authentication, last_authentication,
@ -217,7 +244,7 @@ impl GrantLookup {
let scope = scope.clone(); let scope = scope.clone();
let session = Session { let session = Session {
data: session_id, data: session_id.into(),
client, client,
browser_session, browser_session,
scope, scope,
@ -230,9 +257,9 @@ impl GrantLookup {
}; };
let stage = match ( let stage = match (
self.grant_fulfilled_at, self.oauth2_authorization_grant_fulfilled_at,
self.grant_exchanged_at, self.oauth2_authorization_grant_exchanged_at,
self.grant_cancelled_at, self.oauth2_authorization_grant_cancelled_at,
session, session,
) { ) {
(None, None, None, None) => AuthorizationGrantStage::Pending, (None, None, None, None) => AuthorizationGrantStage::Pending,
@ -255,7 +282,10 @@ impl GrantLookup {
} }
}; };
let pkce = match (self.grant_code_challenge, self.grant_code_challenge_method) { let pkce = match (
self.oauth2_authorization_grant_code_challenge,
self.oauth2_authorization_grant_code_challenge_method,
) {
(Some(challenge), Some(challenge_method)) if challenge_method == "plain" => { (Some(challenge), Some(challenge_method)) if challenge_method == "plain" => {
Some(Pkce { Some(Pkce {
challenge_method: PkceCodeChallengeMethod::Plain, challenge_method: PkceCodeChallengeMethod::Plain,
@ -272,27 +302,30 @@ impl GrantLookup {
} }
}; };
let code: Option<AuthorizationCode> = let code: Option<AuthorizationCode> = match (
match (self.grant_response_type_code, self.grant_code, pkce) { self.oauth2_authorization_grant_response_type_code,
(false, None, None) => None, self.oauth2_authorization_grant_code,
(true, Some(code), pkce) => Some(AuthorizationCode { code, pkce }), pkce,
_ => { ) {
return Err(DatabaseInconsistencyError); (false, None, None) => None,
} (true, Some(code), pkce) => Some(AuthorizationCode { code, pkce }),
}; _ => {
return Err(DatabaseInconsistencyError);
}
};
let redirect_uri = self let redirect_uri = self
.grant_redirect_uri .oauth2_authorization_grant_redirect_uri
.parse() .parse()
.map_err(|_e| DatabaseInconsistencyError)?; .map_err(|_e| DatabaseInconsistencyError)?;
let response_mode = self let response_mode = self
.grant_response_mode .oauth2_authorization_grant_response_mode
.parse() .parse()
.map_err(|_e| DatabaseInconsistencyError)?; .map_err(|_e| DatabaseInconsistencyError)?;
let max_age = self let max_age = self
.grant_max_age .oauth2_authorization_grant_max_age
.map(u32::try_from) .map(u32::try_from)
.transpose() .transpose()
.map_err(|_e| DatabaseInconsistencyError)? .map_err(|_e| DatabaseInconsistencyError)?
@ -301,82 +334,85 @@ impl GrantLookup {
.map_err(|_e| DatabaseInconsistencyError)?; .map_err(|_e| DatabaseInconsistencyError)?;
Ok(AuthorizationGrant { Ok(AuthorizationGrant {
data: self.grant_id, data: self.oauth2_authorization_grant_id.into(),
stage, stage,
client, client,
code, code,
acr_values: self.grant_acr_values,
scope, scope,
state: self.grant_state, state: self.oauth2_authorization_grant_state,
nonce: self.grant_nonce, nonce: self.oauth2_authorization_grant_nonce,
max_age, // TODO max_age, // TODO
response_mode, response_mode,
redirect_uri, redirect_uri,
created_at: self.grant_created_at, created_at: self.oauth2_authorization_grant_created_at,
response_type_id_token: self.grant_response_type_id_token, response_type_id_token: self.oauth2_authorization_grant_response_type_id_token,
requires_consent: self.grant_requires_consent, requires_consent: self.oauth2_authorization_grant_requires_consent,
}) })
} }
} }
#[tracing::instrument(
skip_all,
fields(grant.id = %id),
err(Debug),
)]
pub async fn get_grant_by_id( pub async fn get_grant_by_id(
conn: &mut PgConnection, conn: &mut PgConnection,
id: i64, id: Ulid,
) -> anyhow::Result<AuthorizationGrant<PostgresqlBackend>> { ) -> anyhow::Result<AuthorizationGrant<PostgresqlBackend>> {
// TODO: handle "not found" cases // TODO: handle "not found" cases
let res = sqlx::query_as!( let res = sqlx::query_as!(
GrantLookup, GrantLookup,
r#" r#"
SELECT SELECT
og.id AS grant_id, og.oauth2_authorization_grant_id,
og.created_at AS grant_created_at, og.created_at AS oauth2_authorization_grant_created_at,
og.cancelled_at AS grant_cancelled_at, og.cancelled_at AS oauth2_authorization_grant_cancelled_at,
og.fulfilled_at AS grant_fulfilled_at, og.fulfilled_at AS oauth2_authorization_grant_fulfilled_at,
og.exchanged_at AS grant_exchanged_at, og.exchanged_at AS oauth2_authorization_grant_exchanged_at,
og.scope AS grant_scope, og.scope AS oauth2_authorization_grant_scope,
og.state AS grant_state, og.state AS oauth2_authorization_grant_state,
og.redirect_uri AS grant_redirect_uri, og.redirect_uri AS oauth2_authorization_grant_redirect_uri,
og.response_mode AS grant_response_mode, og.response_mode AS oauth2_authorization_grant_response_mode,
og.nonce AS grant_nonce, og.nonce AS oauth2_authorization_grant_nonce,
og.max_age AS grant_max_age, og.max_age AS oauth2_authorization_grant_max_age,
og.acr_values AS grant_acr_values, og.oauth2_client_id AS oauth2_client_id,
og.oauth2_client_id AS oauth2_client_id, og.authorization_code AS oauth2_authorization_grant_code,
og.code AS grant_code, og.response_type_code AS oauth2_authorization_grant_response_type_code,
og.response_type_code AS grant_response_type_code, og.response_type_id_token AS oauth2_authorization_grant_response_type_id_token,
og.response_type_id_token AS grant_response_type_id_token, og.code_challenge AS oauth2_authorization_grant_code_challenge,
og.code_challenge AS grant_code_challenge, og.code_challenge_method AS oauth2_authorization_grant_code_challenge_method,
og.code_challenge_method AS grant_code_challenge_method, og.requires_consent AS oauth2_authorization_grant_requires_consent,
og.requires_consent AS grant_requires_consent, os.oauth2_session_id AS "oauth2_session_id?",
os.id AS "session_id?", us.user_session_id AS "user_session_id?",
us.id AS "user_session_id?", us.created_at AS "user_session_created_at?",
us.created_at AS "user_session_created_at?", u.user_id AS "user_id?",
u.id AS "user_id?", u.username AS "user_username?",
u.username AS "user_username?", usa.user_session_authentication_id AS "user_session_last_authentication_id?",
usa.id AS "user_session_last_authentication_id?", usa.created_at AS "user_session_last_authentication_created_at?",
usa.created_at AS "user_session_last_authentication_created_at?", ue.user_email_id AS "user_email_id?",
ue.id AS "user_email_id?", ue.email AS "user_email?",
ue.email AS "user_email?", ue.created_at AS "user_email_created_at?",
ue.created_at AS "user_email_created_at?", ue.confirmed_at AS "user_email_confirmed_at?"
ue.confirmed_at AS "user_email_confirmed_at?"
FROM FROM
oauth2_authorization_grants og oauth2_authorization_grants og
LEFT JOIN oauth2_sessions os LEFT JOIN oauth2_sessions os
ON os.id = og.oauth2_session_id USING (oauth2_session_id)
LEFT JOIN user_sessions us LEFT JOIN user_sessions us
ON us.id = os.user_session_id USING (user_session_id)
LEFT JOIN users u LEFT JOIN users u
ON u.id = us.user_id USING (user_id)
LEFT JOIN user_session_authentications usa LEFT JOIN user_session_authentications usa
ON usa.session_id = us.id USING (user_session_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE og.id = $1 WHERE og.oauth2_authorization_grant_id = $1
ORDER BY usa.created_at DESC ORDER BY usa.created_at DESC
LIMIT 1 LIMIT 1
"#, "#,
id, Uuid::from(id),
) )
.fetch_one(&mut *conn) .fetch_one(&mut *conn)
.await .await
@ -387,6 +423,7 @@ pub async fn get_grant_by_id(
Ok(grant) Ok(grant)
} }
#[tracing::instrument(skip_all, err(Debug))]
pub async fn lookup_grant_by_code( pub async fn lookup_grant_by_code(
conn: &mut PgConnection, conn: &mut PgConnection,
code: &str, code: &str,
@ -396,50 +433,49 @@ pub async fn lookup_grant_by_code(
GrantLookup, GrantLookup,
r#" r#"
SELECT SELECT
og.id AS grant_id, og.oauth2_authorization_grant_id,
og.created_at AS grant_created_at, og.created_at AS oauth2_authorization_grant_created_at,
og.cancelled_at AS grant_cancelled_at, og.cancelled_at AS oauth2_authorization_grant_cancelled_at,
og.fulfilled_at AS grant_fulfilled_at, og.fulfilled_at AS oauth2_authorization_grant_fulfilled_at,
og.exchanged_at AS grant_exchanged_at, og.exchanged_at AS oauth2_authorization_grant_exchanged_at,
og.scope AS grant_scope, og.scope AS oauth2_authorization_grant_scope,
og.state AS grant_state, og.state AS oauth2_authorization_grant_state,
og.redirect_uri AS grant_redirect_uri, og.redirect_uri AS oauth2_authorization_grant_redirect_uri,
og.response_mode AS grant_response_mode, og.response_mode AS oauth2_authorization_grant_response_mode,
og.nonce AS grant_nonce, og.nonce AS oauth2_authorization_grant_nonce,
og.max_age AS grant_max_age, og.max_age AS oauth2_authorization_grant_max_age,
og.acr_values AS grant_acr_values, og.oauth2_client_id AS oauth2_client_id,
og.oauth2_client_id AS oauth2_client_id, og.authorization_code AS oauth2_authorization_grant_code,
og.code AS grant_code, og.response_type_code AS oauth2_authorization_grant_response_type_code,
og.response_type_code AS grant_response_type_code, og.response_type_id_token AS oauth2_authorization_grant_response_type_id_token,
og.response_type_id_token AS grant_response_type_id_token, og.code_challenge AS oauth2_authorization_grant_code_challenge,
og.code_challenge AS grant_code_challenge, og.code_challenge_method AS oauth2_authorization_grant_code_challenge_method,
og.code_challenge_method AS grant_code_challenge_method, og.requires_consent AS oauth2_authorization_grant_requires_consent,
og.requires_consent AS grant_requires_consent, os.oauth2_session_id AS "oauth2_session_id?",
os.id AS "session_id?", us.user_session_id AS "user_session_id?",
us.id AS "user_session_id?", us.created_at AS "user_session_created_at?",
us.created_at AS "user_session_created_at?", u.user_id AS "user_id?",
u.id AS "user_id?", u.username AS "user_username?",
u.username AS "user_username?", usa.user_session_authentication_id AS "user_session_last_authentication_id?",
usa.id AS "user_session_last_authentication_id?", usa.created_at AS "user_session_last_authentication_created_at?",
usa.created_at AS "user_session_last_authentication_created_at?", ue.user_email_id AS "user_email_id?",
ue.id AS "user_email_id?", ue.email AS "user_email?",
ue.email AS "user_email?", ue.created_at AS "user_email_created_at?",
ue.created_at AS "user_email_created_at?", ue.confirmed_at AS "user_email_confirmed_at?"
ue.confirmed_at AS "user_email_confirmed_at?"
FROM FROM
oauth2_authorization_grants og oauth2_authorization_grants og
LEFT JOIN oauth2_sessions os LEFT JOIN oauth2_sessions os
ON os.id = og.oauth2_session_id USING (oauth2_session_id)
LEFT JOIN user_sessions us LEFT JOIN user_sessions us
ON us.id = os.user_session_id USING (user_session_id)
LEFT JOIN users u LEFT JOIN users u
ON u.id = us.user_id USING (user_id)
LEFT JOIN user_session_authentications usa LEFT JOIN user_session_authentications usa
ON usa.session_id = us.id USING (user_session_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE og.code = $1 WHERE og.authorization_code = $1
ORDER BY usa.created_at DESC ORDER BY usa.created_at DESC
LIMIT 1 LIMIT 1
@ -455,41 +491,69 @@ pub async fn lookup_grant_by_code(
Ok(grant) Ok(grant)
} }
#[tracing::instrument(
skip_all,
fields(
grant.id = %grant.data,
client.id = %grant.client.data,
session.id,
user_session.id = %browser_session.data,
user.id = %browser_session.user.data,
),
err(Debug),
)]
pub async fn derive_session( pub async fn derive_session(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
grant: &AuthorizationGrant<PostgresqlBackend>, grant: &AuthorizationGrant<PostgresqlBackend>,
browser_session: BrowserSession<PostgresqlBackend>, browser_session: BrowserSession<PostgresqlBackend>,
) -> anyhow::Result<Session<PostgresqlBackend>> { ) -> anyhow::Result<Session<PostgresqlBackend>> {
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
tracing::Span::current().record("session.id", tracing::field::display(id));
sqlx::query!(
r#" r#"
INSERT INTO oauth2_sessions INSERT INTO oauth2_sessions
(user_session_id, oauth2_client_id, scope) (oauth2_session_id, user_session_id, oauth2_client_id, scope, created_at)
SELECT SELECT
$1, $1,
$2,
og.oauth2_client_id, og.oauth2_client_id,
og.scope og.scope,
$3
FROM FROM
oauth2_authorization_grants og oauth2_authorization_grants og
WHERE WHERE
og.id = $2 og.oauth2_authorization_grant_id = $4
RETURNING id, created_at
"#, "#,
browser_session.data, Uuid::from(id),
grant.data, Uuid::from(browser_session.data),
created_at,
Uuid::from(grant.data),
) )
.fetch_one(executor) .execute(executor)
.await .await
.context("could not insert oauth2 session")?; .context("could not insert oauth2 session")?;
Ok(Session { Ok(Session {
data: res.id, data: id,
browser_session, browser_session,
client: grant.client.clone(), client: grant.client.clone(),
scope: grant.scope.clone(), scope: grant.scope.clone(),
}) })
} }
#[tracing::instrument(
skip_all,
fields(
grant.id = %grant.data,
client.id = %grant.client.data,
session.id = %session.data,
user_session.id = %session.browser_session.data,
user.id = %session.browser_session.user.data,
),
err(Debug),
)]
pub async fn fulfill_grant( pub async fn fulfill_grant(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
mut grant: AuthorizationGrant<PostgresqlBackend>, mut grant: AuthorizationGrant<PostgresqlBackend>,
@ -499,15 +563,16 @@ pub async fn fulfill_grant(
r#" r#"
UPDATE oauth2_authorization_grants AS og UPDATE oauth2_authorization_grants AS og
SET SET
oauth2_session_id = os.id, oauth2_session_id = os.oauth2_session_id,
fulfilled_at = os.created_at fulfilled_at = os.created_at
FROM oauth2_sessions os FROM oauth2_sessions os
WHERE WHERE
og.id = $1 AND os.id = $2 og.oauth2_authorization_grant_id = $1
AND os.oauth2_session_id = $2
RETURNING fulfilled_at AS "fulfilled_at!: DateTime<Utc>" RETURNING fulfilled_at AS "fulfilled_at!: DateTime<Utc>"
"#, "#,
grant.data, Uuid::from(grant.data),
session.data, Uuid::from(session.data),
) )
.fetch_one(executor) .fetch_one(executor)
.await .await
@ -518,6 +583,14 @@ pub async fn fulfill_grant(
Ok(grant) Ok(grant)
} }
#[tracing::instrument(
skip_all,
fields(
grant.id = %grant.data,
client.id = %grant.client.data,
),
err(Debug),
)]
pub async fn give_consent_to_grant( pub async fn give_consent_to_grant(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
mut grant: AuthorizationGrant<PostgresqlBackend>, mut grant: AuthorizationGrant<PostgresqlBackend>,
@ -528,9 +601,9 @@ pub async fn give_consent_to_grant(
SET SET
requires_consent = 'f' requires_consent = 'f'
WHERE WHERE
og.id = $1 og.oauth2_authorization_grant_id = $1
"#, "#,
grant.data, Uuid::from(grant.data),
) )
.execute(executor) .execute(executor)
.await?; .await?;
@ -540,22 +613,29 @@ pub async fn give_consent_to_grant(
Ok(grant) Ok(grant)
} }
#[tracing::instrument(
skip_all,
fields(
grant.id = %grant.data,
client.id = %grant.client.data,
),
err(Debug),
)]
pub async fn exchange_grant( pub async fn exchange_grant(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
mut grant: AuthorizationGrant<PostgresqlBackend>, mut grant: AuthorizationGrant<PostgresqlBackend>,
) -> anyhow::Result<AuthorizationGrant<PostgresqlBackend>> { ) -> anyhow::Result<AuthorizationGrant<PostgresqlBackend>> {
let exchanged_at = sqlx::query_scalar!( let exchanged_at = Utc::now();
sqlx::query!(
r#" r#"
UPDATE oauth2_authorization_grants UPDATE oauth2_authorization_grants
SET SET exchanged_at = $2
exchanged_at = NOW() WHERE oauth2_authorization_grant_id = $1
WHERE
id = $1
RETURNING exchanged_at AS "exchanged_at!: DateTime<Utc>"
"#, "#,
grant.data, Uuid::from(grant.data),
exchanged_at,
) )
.fetch_one(executor) .execute(executor)
.await .await
.context("could not mark grant as exchanged")?; .context("could not mark grant as exchanged")?;

View File

@ -20,23 +20,25 @@ use mas_iana::{
oauth::{OAuthAuthorizationEndpointResponseType, OAuthClientAuthenticationMethod}, oauth::{OAuthAuthorizationEndpointResponseType, OAuthClientAuthenticationMethod},
}; };
use mas_jose::jwk::PublicJsonWebKeySet; use mas_jose::jwk::PublicJsonWebKeySet;
use oauth2_types::{requests::GrantType, response_type::ResponseType}; use oauth2_types::requests::GrantType;
use sqlx::{PgConnection, PgExecutor}; use sqlx::{PgConnection, PgExecutor};
use thiserror::Error; use thiserror::Error;
use ulid::Ulid;
use url::Url; use url::Url;
use uuid::Uuid;
use crate::PostgresqlBackend; use crate::PostgresqlBackend;
// XXX: response_types & contacts
#[derive(Debug)] #[derive(Debug)]
pub struct OAuth2ClientLookup { pub struct OAuth2ClientLookup {
id: i64, oauth2_client_id: Uuid,
client_id: String,
encrypted_client_secret: Option<String>, encrypted_client_secret: Option<String>,
redirect_uris: Vec<String>, redirect_uris: Vec<String>,
response_types: Vec<String>, // response_types: Vec<String>,
grant_type_authorization_code: bool, grant_type_authorization_code: bool,
grant_type_refresh_token: bool, grant_type_refresh_token: bool,
contacts: Vec<String>, // contacts: Vec<String>,
client_name: Option<String>, client_name: Option<String>,
logo_uri: Option<String>, logo_uri: Option<String>,
client_uri: Option<String>, client_uri: Option<String>,
@ -53,6 +55,9 @@ pub struct OAuth2ClientLookup {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ClientFetchError { pub enum ClientFetchError {
#[error("invalid client ID")]
InvalidClientId(#[from] ulid::DecodeError),
#[error("malformed jwks column")] #[error("malformed jwks column")]
MalformedJwks(#[source] serde_json::Error), MalformedJwks(#[source] serde_json::Error),
@ -78,7 +83,10 @@ pub enum ClientFetchError {
impl ClientFetchError { impl ClientFetchError {
#[must_use] #[must_use]
pub fn not_found(&self) -> bool { pub fn not_found(&self) -> bool {
matches!(self, Self::Database(sqlx::Error::RowNotFound)) matches!(
self,
Self::Database(sqlx::Error::RowNotFound) | Self::InvalidClientId(_)
)
} }
} }
@ -94,12 +102,19 @@ impl TryInto<Client<PostgresqlBackend>> for OAuth2ClientLookup {
source, source,
})?; })?;
let response_types = vec![
OAuthAuthorizationEndpointResponseType::Code,
OAuthAuthorizationEndpointResponseType::IdToken,
OAuthAuthorizationEndpointResponseType::None,
];
/* XXX
let response_types: Result<Vec<OAuthAuthorizationEndpointResponseType>, _> = let response_types: Result<Vec<OAuthAuthorizationEndpointResponseType>, _> =
self.response_types.iter().map(|s| s.parse()).collect(); self.response_types.iter().map(|s| s.parse()).collect();
let response_types = response_types.map_err(|source| ClientFetchError::ParseField { let response_types = response_types.map_err(|source| ClientFetchError::ParseField {
field: "response_types", field: "response_types",
source, source,
})?; })?;
*/
let mut grant_types = Vec::new(); let mut grant_types = Vec::new();
if self.grant_type_authorization_code { if self.grant_type_authorization_code {
@ -210,13 +225,14 @@ impl TryInto<Client<PostgresqlBackend>> for OAuth2ClientLookup {
}; };
Ok(Client { Ok(Client {
data: self.id, data: self.oauth2_client_id.into(),
client_id: self.client_id, client_id: self.oauth2_client_id.to_string(),
encrypted_client_secret: self.encrypted_client_secret, encrypted_client_secret: self.encrypted_client_secret,
redirect_uris, redirect_uris,
response_types, response_types,
grant_types, grant_types,
contacts: self.contacts, // contacts: self.contacts,
contacts: vec![],
client_name: self.client_name, client_name: self.client_name,
logo_uri, logo_uri,
client_uri, client_uri,
@ -234,20 +250,21 @@ impl TryInto<Client<PostgresqlBackend>> for OAuth2ClientLookup {
pub async fn lookup_client( pub async fn lookup_client(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
id: i64, id: Ulid,
) -> Result<Client<PostgresqlBackend>, ClientFetchError> { ) -> Result<Client<PostgresqlBackend>, ClientFetchError> {
let res = sqlx::query_as!( let res = sqlx::query_as!(
OAuth2ClientLookup, OAuth2ClientLookup,
r#" r#"
SELECT SELECT
c.id, c.oauth2_client_id,
c.client_id,
c.encrypted_client_secret, c.encrypted_client_secret,
ARRAY(SELECT redirect_uri FROM oauth2_client_redirect_uris r WHERE r.oauth2_client_id = c.id) AS "redirect_uris!", ARRAY(
c.response_types, SELECT redirect_uri
FROM oauth2_client_redirect_uris r
WHERE r.oauth2_client_id = c.oauth2_client_id
) AS "redirect_uris!",
c.grant_type_authorization_code, c.grant_type_authorization_code,
c.grant_type_refresh_token, c.grant_type_refresh_token,
c.contacts,
c.client_name, c.client_name,
c.logo_uri, c.logo_uri,
c.client_uri, c.client_uri,
@ -262,9 +279,9 @@ pub async fn lookup_client(
c.initiate_login_uri c.initiate_login_uri
FROM oauth2_clients c FROM oauth2_clients c
WHERE c.id = $1 WHERE c.oauth2_client_id = $1
"#, "#,
id, Uuid::from(id),
) )
.fetch_one(executor) .fetch_one(executor)
.await?; .await?;
@ -278,53 +295,18 @@ pub async fn lookup_client_by_client_id(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
client_id: &str, client_id: &str,
) -> Result<Client<PostgresqlBackend>, ClientFetchError> { ) -> Result<Client<PostgresqlBackend>, ClientFetchError> {
let res = sqlx::query_as!( let id: Ulid = client_id.parse()?;
OAuth2ClientLookup, lookup_client(executor, id).await
r#"
SELECT
c.id,
c.client_id,
c.encrypted_client_secret,
ARRAY(SELECT redirect_uri FROM oauth2_client_redirect_uris r WHERE r.oauth2_client_id = c.id) AS "redirect_uris!",
c.response_types,
c.grant_type_authorization_code,
c.grant_type_refresh_token,
c.contacts,
c.client_name,
c.logo_uri,
c.client_uri,
c.policy_uri,
c.tos_uri,
c.jwks_uri,
c.jwks,
c.id_token_signed_response_alg,
c.userinfo_signed_response_alg,
c.token_endpoint_auth_method,
c.token_endpoint_auth_signing_alg,
c.initiate_login_uri
FROM oauth2_clients c
WHERE c.client_id = $1
"#,
client_id,
)
.fetch_one(executor)
.await?;
let client = res.try_into()?;
Ok(client)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn insert_client( pub async fn insert_client(
conn: &mut PgConnection, conn: &mut PgConnection,
client_id: &str, client_id: Ulid,
redirect_uris: &[Url], redirect_uris: &[Url],
encrypted_client_secret: Option<&str>, encrypted_client_secret: Option<&str>,
response_types: &[ResponseType],
grant_types: &[GrantType], grant_types: &[GrantType],
contacts: &[String], _contacts: &[String],
client_name: Option<&str>, client_name: Option<&str>,
logo_uri: Option<&Url>, logo_uri: Option<&Url>,
client_uri: Option<&Url>, client_uri: Option<&Url>,
@ -338,7 +320,6 @@ pub async fn insert_client(
token_endpoint_auth_signing_alg: Option<&JsonWebSignatureAlg>, token_endpoint_auth_signing_alg: Option<&JsonWebSignatureAlg>,
initiate_login_uri: Option<&Url>, initiate_login_uri: Option<&Url>,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
let response_types: Vec<String> = response_types.iter().map(ToString::to_string).collect();
let grant_type_authorization_code = grant_types.contains(&GrantType::AuthorizationCode); let grant_type_authorization_code = grant_types.contains(&GrantType::AuthorizationCode);
let grant_type_refresh_token = grant_types.contains(&GrantType::RefreshToken); let grant_type_refresh_token = grant_types.contains(&GrantType::RefreshToken);
let logo_uri = logo_uri.map(Url::as_str); let logo_uri = logo_uri.map(Url::as_str);
@ -353,15 +334,13 @@ pub async fn insert_client(
let token_endpoint_auth_signing_alg = token_endpoint_auth_signing_alg.map(ToString::to_string); let token_endpoint_auth_signing_alg = token_endpoint_auth_signing_alg.map(ToString::to_string);
let initiate_login_uri = initiate_login_uri.map(Url::as_str); let initiate_login_uri = initiate_login_uri.map(Url::as_str);
let id = sqlx::query_scalar!( sqlx::query!(
r#" r#"
INSERT INTO oauth2_clients INSERT INTO oauth2_clients
(client_id, (oauth2_client_id,
encrypted_client_secret, encrypted_client_secret,
response_types,
grant_type_authorization_code, grant_type_authorization_code,
grant_type_refresh_token, grant_type_refresh_token,
contacts,
client_name, client_name,
logo_uri, logo_uri,
client_uri, client_uri,
@ -375,15 +354,12 @@ pub async fn insert_client(
token_endpoint_auth_signing_alg, token_endpoint_auth_signing_alg,
initiate_login_uri) initiate_login_uri)
VALUES VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id
"#, "#,
client_id, Uuid::from(client_id),
encrypted_client_secret, encrypted_client_secret,
&response_types,
grant_type_authorization_code, grant_type_authorization_code,
grant_type_refresh_token, grant_type_refresh_token,
contacts,
client_name, client_name,
logo_uri, logo_uri,
client_uri, client_uri,
@ -397,96 +373,87 @@ pub async fn insert_client(
token_endpoint_auth_signing_alg, token_endpoint_auth_signing_alg,
initiate_login_uri, initiate_login_uri,
) )
.fetch_one(&mut *conn)
.await?;
let redirect_uris: Vec<String> = redirect_uris.iter().map(ToString::to_string).collect();
sqlx::query!(
r#"
INSERT INTO oauth2_client_redirect_uris (oauth2_client_id, redirect_uri)
SELECT $1, uri FROM UNNEST($2::text[]) uri
"#,
id,
&redirect_uris,
)
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
for redirect_uri in redirect_uris {
let id = Ulid::new();
sqlx::query!(
r#"
INSERT INTO oauth2_client_redirect_uris
(oauth2_client_redirect_uri_id, oauth2_client_id, redirect_uri)
VALUES ($1, $2, $3)
"#,
Uuid::from(id),
Uuid::from(client_id),
redirect_uri.as_str(),
)
.execute(&mut *conn)
.await?;
}
Ok(()) Ok(())
} }
pub async fn insert_client_from_config( pub async fn insert_client_from_config(
conn: &mut PgConnection, conn: &mut PgConnection,
client_id: &str, client_id: Ulid,
client_auth_method: OAuthClientAuthenticationMethod, client_auth_method: OAuthClientAuthenticationMethod,
encrypted_client_secret: Option<&str>, encrypted_client_secret: Option<&str>,
jwks: Option<&PublicJsonWebKeySet>, jwks: Option<&PublicJsonWebKeySet>,
jwks_uri: Option<&Url>, jwks_uri: Option<&Url>,
redirect_uris: &[Url], redirect_uris: &[Url],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let response_types = vec![
OAuthAuthorizationEndpointResponseType::Code.to_string(),
OAuthAuthorizationEndpointResponseType::CodeIdToken.to_string(),
OAuthAuthorizationEndpointResponseType::CodeIdTokenToken.to_string(),
OAuthAuthorizationEndpointResponseType::CodeToken.to_string(),
OAuthAuthorizationEndpointResponseType::IdToken.to_string(),
OAuthAuthorizationEndpointResponseType::IdTokenToken.to_string(),
OAuthAuthorizationEndpointResponseType::None.to_string(),
OAuthAuthorizationEndpointResponseType::Token.to_string(),
];
let jwks = jwks.map(serde_json::to_value).transpose()?; let jwks = jwks.map(serde_json::to_value).transpose()?;
let jwks_uri = jwks_uri.map(Url::as_str); let jwks_uri = jwks_uri.map(Url::as_str);
let client_auth_method = client_auth_method.to_string(); let client_auth_method = client_auth_method.to_string();
let id = sqlx::query_scalar!( sqlx::query!(
r#" r#"
INSERT INTO oauth2_clients INSERT INTO oauth2_clients
(client_id, (oauth2_client_id,
encrypted_client_secret, encrypted_client_secret,
response_types,
grant_type_authorization_code, grant_type_authorization_code,
grant_type_refresh_token, grant_type_refresh_token,
token_endpoint_auth_method, token_endpoint_auth_method,
jwks, jwks,
jwks_uri, jwks_uri)
contacts)
VALUES VALUES
($1, $2, $3, $4, $5, $6, $7, $8, '{}') ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
"#, "#,
client_id, Uuid::from(client_id),
encrypted_client_secret, encrypted_client_secret,
&response_types,
true, true,
true, true,
client_auth_method, client_auth_method,
jwks, jwks,
jwks_uri, jwks_uri,
) )
.fetch_one(&mut *conn)
.await?;
let redirect_uris: Vec<String> = redirect_uris.iter().map(ToString::to_string).collect();
sqlx::query!(
r#"
INSERT INTO oauth2_client_redirect_uris (oauth2_client_id, redirect_uri)
SELECT $1, uri FROM UNNEST($2::text[]) uri
"#,
id,
&redirect_uris,
)
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
for redirect_uri in redirect_uris {
let id = Ulid::new();
sqlx::query!(
r#"
INSERT INTO oauth2_client_redirect_uris
(oauth2_client_redirect_uri_id, oauth2_client_id, redirect_uri)
VALUES ($1, $2, $3)
"#,
Uuid::from(id),
Uuid::from(client_id),
redirect_uri.as_str(),
)
.execute(&mut *conn)
.await?;
}
Ok(()) Ok(())
} }
pub async fn truncate_clients(executor: impl PgExecutor<'_>) -> anyhow::Result<()> { pub async fn truncate_clients(executor: impl PgExecutor<'_>) -> anyhow::Result<()> {
sqlx::query!("TRUNCATE oauth2_client_redirect_uris, oauth2_clients RESTART IDENTITY CASCADE") sqlx::query!("TRUNCATE oauth2_client_redirect_uris, oauth2_clients CASCADE")
.execute(executor) .execute(executor)
.await?; .await?;
Ok(()) Ok(())

View File

@ -14,9 +14,12 @@
use std::str::FromStr; use std::str::FromStr;
use chrono::Utc;
use mas_data_model::{Client, User}; use mas_data_model::{Client, User};
use oauth2_types::scope::{Scope, ScopeToken}; use oauth2_types::scope::{Scope, ScopeToken};
use sqlx::PgExecutor; use sqlx::PgExecutor;
use ulid::Ulid;
use uuid::Uuid;
use crate::PostgresqlBackend; use crate::PostgresqlBackend;
@ -31,8 +34,8 @@ pub async fn fetch_client_consent(
FROM oauth2_consents FROM oauth2_consents
WHERE user_id = $1 AND oauth2_client_id = $2 WHERE user_id = $1 AND oauth2_client_id = $2
"#, "#,
user.data, Uuid::from(user.data),
client.data, Uuid::from(client.data),
) )
.fetch_all(executor) .fetch_all(executor)
.await?; .await?;
@ -51,17 +54,29 @@ pub async fn insert_client_consent(
client: &Client<PostgresqlBackend>, client: &Client<PostgresqlBackend>,
scope: &Scope, scope: &Scope,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let tokens: Vec<String> = scope.iter().map(ToString::to_string).collect(); let now = Utc::now();
let (tokens, ids): (Vec<String>, Vec<Uuid>) = scope
.iter()
.map(|token| {
(
token.to_string(),
Uuid::from(Ulid::from_datetime(now.into())),
)
})
.unzip();
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO oauth2_consents (user_id, oauth2_client_id, scope_token) INSERT INTO oauth2_consents
SELECT $1, $2, scope_token FROM UNNEST($3::text[]) scope_token (oauth2_consent_id, user_id, oauth2_client_id, scope_token, created_at)
ON CONFLICT (user_id, oauth2_client_id, scope_token) DO UPDATE SET updated_at = NOW() SELECT id, $2, $3, scope_token, $5 FROM UNNEST($1::uuid[], $4::text[]) u(id, scope_token)
ON CONFLICT (user_id, oauth2_client_id, scope_token) DO UPDATE SET refreshed_at = $5
"#, "#,
user.data, &ids,
client.data, Uuid::from(user.data),
Uuid::from(client.data),
&tokens, &tokens,
now,
) )
.execute(executor) .execute(executor)
.await?; .await?;

View File

@ -12,8 +12,10 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use chrono::Utc;
use mas_data_model::Session; use mas_data_model::Session;
use sqlx::PgExecutor; use sqlx::PgExecutor;
use uuid::Uuid;
use crate::PostgresqlBackend; use crate::PostgresqlBackend;
@ -27,13 +29,15 @@ pub async fn end_oauth_session(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
session: Session<PostgresqlBackend>, session: Session<PostgresqlBackend>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let finished_at = Utc::now();
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
UPDATE oauth2_sessions UPDATE oauth2_sessions
SET ended_at = NOW() SET finished_at = $2
WHERE id = $1 WHERE oauth2_session_id = $1
"#, "#,
session.data, Uuid::from(session.data),
finished_at,
) )
.execute(executor) .execute(executor)
.await?; .await?;

View File

@ -13,66 +13,71 @@
// limitations under the License. // limitations under the License.
use anyhow::Context; use anyhow::Context;
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Utc};
use mas_data_model::{ use mas_data_model::{
AccessToken, Authentication, BrowserSession, RefreshToken, Session, User, UserEmail, AccessToken, Authentication, BrowserSession, RefreshToken, Session, User, UserEmail,
}; };
use sqlx::{PgConnection, PgExecutor}; use sqlx::{PgConnection, PgExecutor};
use thiserror::Error; use thiserror::Error;
use ulid::Ulid;
use uuid::Uuid;
use super::client::{lookup_client, ClientFetchError}; use super::client::{lookup_client, ClientFetchError};
use crate::{DatabaseInconsistencyError, IdAndCreationTime, PostgresqlBackend}; use crate::{DatabaseInconsistencyError, PostgresqlBackend};
pub async fn add_refresh_token( pub async fn add_refresh_token(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
session: &Session<PostgresqlBackend>, session: &Session<PostgresqlBackend>,
access_token: AccessToken<PostgresqlBackend>, access_token: AccessToken<PostgresqlBackend>,
token: &str, refresh_token: String,
) -> anyhow::Result<RefreshToken<PostgresqlBackend>> { ) -> anyhow::Result<RefreshToken<PostgresqlBackend>> {
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO oauth2_refresh_tokens INSERT INTO oauth2_refresh_tokens
(oauth2_session_id, oauth2_access_token_id, token) (oauth2_refresh_token_id, oauth2_session_id, oauth2_access_token_id,
refresh_token, created_at)
VALUES VALUES
($1, $2, $3) ($1, $2, $3, $4, $5)
RETURNING
id, created_at
"#, "#,
session.data, Uuid::from(id),
access_token.data, Uuid::from(session.data),
token, Uuid::from(access_token.data),
refresh_token,
created_at,
) )
.fetch_one(executor) .execute(executor)
.await .await
.context("could not insert oauth2 refresh token")?; .context("could not insert oauth2 refresh token")?;
Ok(RefreshToken { Ok(RefreshToken {
data: res.id, data: id,
token: token.to_owned(), refresh_token,
access_token: Some(access_token), access_token: Some(access_token),
created_at: res.created_at, created_at,
}) })
} }
struct OAuth2RefreshTokenLookup { struct OAuth2RefreshTokenLookup {
refresh_token_id: i64, oauth2_refresh_token_id: Uuid,
refresh_token: String, oauth2_refresh_token: String,
refresh_token_created_at: DateTime<Utc>, oauth2_refresh_token_created_at: DateTime<Utc>,
access_token_id: Option<i64>, oauth2_access_token_id: Option<Uuid>,
access_token: Option<String>, oauth2_access_token: Option<String>,
access_token_expires_after: Option<i32>, oauth2_access_token_created_at: Option<DateTime<Utc>>,
access_token_created_at: Option<DateTime<Utc>>, oauth2_access_token_expires_at: Option<DateTime<Utc>>,
session_id: i64, oauth2_session_id: Uuid,
oauth2_client_id: i64, oauth2_client_id: Uuid,
scope: String, oauth2_session_scope: String,
user_session_id: i64, user_session_id: Uuid,
user_session_created_at: DateTime<Utc>, user_session_created_at: DateTime<Utc>,
user_id: i64, user_id: Uuid,
user_username: String, user_username: String,
user_session_last_authentication_id: Option<i64>, user_session_last_authentication_id: Option<Uuid>,
user_session_last_authentication_created_at: Option<DateTime<Utc>>, user_session_last_authentication_created_at: Option<DateTime<Utc>>,
user_email_id: Option<i64>, user_email_id: Option<Uuid>,
user_email: Option<String>, user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>, user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -103,44 +108,45 @@ pub async fn lookup_active_refresh_token(
OAuth2RefreshTokenLookup, OAuth2RefreshTokenLookup,
r#" r#"
SELECT SELECT
rt.id AS refresh_token_id, rt.oauth2_refresh_token_id,
rt.token AS refresh_token, rt.refresh_token AS oauth2_refresh_token,
rt.created_at AS refresh_token_created_at, rt.created_at AS oauth2_refresh_token_created_at,
at.id AS "access_token_id?", at.oauth2_access_token_id AS "oauth2_access_token_id?",
at.token AS "access_token?", at.access_token AS "oauth2_access_token?",
at.expires_after AS "access_token_expires_after?", at.created_at AS "oauth2_access_token_created_at?",
at.created_at AS "access_token_created_at?", at.expires_at AS "oauth2_access_token_expires_at?",
os.id AS "session_id!", os.oauth2_session_id AS "oauth2_session_id!",
os.oauth2_client_id AS "oauth2_client_id!", os.oauth2_client_id AS "oauth2_client_id!",
os.scope AS "scope!", os.scope AS "oauth2_session_scope!",
us.id AS "user_session_id!", us.user_session_id AS "user_session_id!",
us.created_at AS "user_session_created_at!", us.created_at AS "user_session_created_at!",
u.id AS "user_id!", u.user_id AS "user_id!",
u.username AS "user_username!", u.username AS "user_username!",
usa.id AS "user_session_last_authentication_id?", usa.user_session_authentication_id AS "user_session_last_authentication_id?",
usa.created_at AS "user_session_last_authentication_created_at?", usa.created_at AS "user_session_last_authentication_created_at?",
ue.id AS "user_email_id?", ue.user_email_id AS "user_email_id?",
ue.email AS "user_email?", ue.email AS "user_email?",
ue.created_at AS "user_email_created_at?", ue.created_at AS "user_email_created_at?",
ue.confirmed_at AS "user_email_confirmed_at?" ue.confirmed_at AS "user_email_confirmed_at?"
FROM oauth2_refresh_tokens rt FROM oauth2_refresh_tokens rt
LEFT JOIN oauth2_access_tokens at
ON at.id = rt.oauth2_access_token_id
INNER JOIN oauth2_sessions os INNER JOIN oauth2_sessions os
ON os.id = rt.oauth2_session_id USING (oauth2_session_id)
LEFT JOIN oauth2_access_tokens at
USING (oauth2_access_token_id)
INNER JOIN user_sessions us INNER JOIN user_sessions us
ON us.id = os.user_session_id USING (user_session_id)
INNER JOIN users u INNER JOIN users u
ON u.id = us.user_id USING (user_id)
LEFT JOIN user_session_authentications usa LEFT JOIN user_session_authentications usa
ON usa.session_id = us.id USING (user_session_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE rt.token = $1 WHERE rt.refresh_token = $1
AND rt.next_token_id IS NULL AND rt.consumed_at IS NULL
AND us.active AND rt.revoked_at IS NULL
AND os.ended_at IS NULL AND us.finished_at IS NULL
AND os.finished_at IS NULL
ORDER BY usa.created_at DESC ORDER BY usa.created_at DESC
LIMIT 1 LIMIT 1
@ -151,30 +157,31 @@ pub async fn lookup_active_refresh_token(
.await?; .await?;
let access_token = match ( let access_token = match (
res.access_token_id, res.oauth2_access_token_id,
res.access_token, res.oauth2_access_token,
res.access_token_created_at, res.oauth2_access_token_created_at,
res.access_token_expires_after, res.oauth2_access_token_expires_at,
) { ) {
(None, None, None, None) => None, (None, None, None, None) => None,
(Some(id), Some(token), Some(created_at), Some(expires_after)) => Some(AccessToken { (Some(id), Some(access_token), Some(created_at), Some(expires_at)) => Some(AccessToken {
data: id, data: id.into(),
jti: format!("{}", id), // XXX: are we doing that everywhere?
token, jti: Ulid::from(id).to_string(),
access_token,
created_at, created_at,
expires_after: Duration::seconds(expires_after.into()), expires_at,
}), }),
_ => return Err(DatabaseInconsistencyError.into()), _ => return Err(DatabaseInconsistencyError.into()),
}; };
let refresh_token = RefreshToken { let refresh_token = RefreshToken {
data: res.refresh_token_id, data: res.oauth2_refresh_token_id.into(),
token: res.refresh_token, refresh_token: res.oauth2_refresh_token,
created_at: res.refresh_token_created_at, created_at: res.oauth2_refresh_token_created_at,
access_token, access_token,
}; };
let client = lookup_client(&mut *conn, res.oauth2_client_id).await?; let client = lookup_client(&mut *conn, res.oauth2_client_id.into()).await?;
let primary_email = match ( let primary_email = match (
res.user_email_id, res.user_email_id,
@ -183,7 +190,7 @@ pub async fn lookup_active_refresh_token(
res.user_email_confirmed_at, res.user_email_confirmed_at,
) { ) {
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
data: id, data: id.into(),
email, email,
created_at, created_at,
confirmed_at, confirmed_at,
@ -192,10 +199,11 @@ pub async fn lookup_active_refresh_token(
_ => return Err(DatabaseInconsistencyError.into()), _ => return Err(DatabaseInconsistencyError.into()),
}; };
let id = Ulid::from(res.user_id);
let user = User { let user = User {
data: res.user_id, data: id,
username: res.user_username, username: res.user_username,
sub: format!("fake-sub-{}", res.user_id), sub: id.to_string(),
primary_email, primary_email,
}; };
@ -205,23 +213,26 @@ pub async fn lookup_active_refresh_token(
) { ) {
(None, None) => None, (None, None) => None,
(Some(id), Some(created_at)) => Some(Authentication { (Some(id), Some(created_at)) => Some(Authentication {
data: id, data: id.into(),
created_at, created_at,
}), }),
_ => return Err(DatabaseInconsistencyError.into()), _ => return Err(DatabaseInconsistencyError.into()),
}; };
let browser_session = BrowserSession { let browser_session = BrowserSession {
data: res.user_session_id, data: res.user_session_id.into(),
created_at: res.user_session_created_at, created_at: res.user_session_created_at,
user, user,
last_authentication, last_authentication,
}; };
let scope = res.scope.parse().map_err(|_e| DatabaseInconsistencyError)?; let scope = res
.oauth2_session_scope
.parse()
.map_err(|_e| DatabaseInconsistencyError)?;
let session = Session { let session = Session {
data: res.session_id, data: res.oauth2_session_id.into(),
client, client,
browser_session, browser_session,
scope, scope,
@ -230,19 +241,19 @@ pub async fn lookup_active_refresh_token(
Ok((refresh_token, session)) Ok((refresh_token, session))
} }
pub async fn replace_refresh_token( pub async fn consume_refresh_token(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
refresh_token: &RefreshToken<PostgresqlBackend>, refresh_token: &RefreshToken<PostgresqlBackend>,
next_refresh_token: &RefreshToken<PostgresqlBackend>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let consumed_at = Utc::now();
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
UPDATE oauth2_refresh_tokens UPDATE oauth2_refresh_tokens
SET next_token_id = $2 SET consumed_at = $2
WHERE id = $1 WHERE oauth2_refresh_token_id = $1
"#, "#,
refresh_token.data, Uuid::from(refresh_token.data),
next_refresh_token.data consumed_at,
) )
.execute(executor) .execute(executor)
.await .await

View File

@ -22,20 +22,21 @@ use mas_data_model::{
UserEmailVerificationState, UserEmailVerificationState,
}; };
use password_hash::{PasswordHash, PasswordHasher, SaltString}; use password_hash::{PasswordHash, PasswordHasher, SaltString};
use rand::rngs::OsRng; use rand::thread_rng;
use sqlx::{postgres::types::PgInterval, Acquire, PgExecutor, Postgres, Transaction}; use sqlx::{Acquire, PgExecutor, Postgres, Transaction};
use thiserror::Error; use thiserror::Error;
use tokio::task; use tokio::task;
use tracing::{info_span, Instrument}; use tracing::{info_span, Instrument};
use ulid::Ulid;
use uuid::Uuid;
use super::{DatabaseInconsistencyError, PostgresqlBackend}; use super::{DatabaseInconsistencyError, PostgresqlBackend};
use crate::IdAndCreationTime;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct UserLookup { struct UserLookup {
user_id: i64, user_id: Uuid,
user_username: String, user_username: String,
user_email_id: Option<i64>, user_email_id: Option<Uuid>,
user_email: Option<String>, user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>, user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -114,13 +115,13 @@ impl ActiveSessionLookupError {
} }
struct SessionLookup { struct SessionLookup {
id: i64, user_session_id: Uuid,
user_id: i64, user_id: Uuid,
username: String, username: String,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
last_authentication_id: Option<i64>, last_authentication_id: Option<Uuid>,
last_authd_at: Option<DateTime<Utc>>, last_authd_at: Option<DateTime<Utc>>,
user_email_id: Option<i64>, user_email_id: Option<Uuid>,
user_email: Option<String>, user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>, user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -137,7 +138,7 @@ impl TryInto<BrowserSession<PostgresqlBackend>> for SessionLookup {
self.user_email_confirmed_at, self.user_email_confirmed_at,
) { ) {
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
data: id, data: id.into(),
email, email,
created_at, created_at,
confirmed_at, confirmed_at,
@ -146,16 +147,17 @@ impl TryInto<BrowserSession<PostgresqlBackend>> for SessionLookup {
_ => return Err(DatabaseInconsistencyError), _ => return Err(DatabaseInconsistencyError),
}; };
let id = Ulid::from(self.user_id);
let user = User { let user = User {
data: self.user_id, data: id,
username: self.username, username: self.username,
sub: format!("fake-sub-{}", self.user_id), sub: id.to_string(),
primary_email, primary_email,
}; };
let last_authentication = match (self.last_authentication_id, self.last_authd_at) { let last_authentication = match (self.last_authentication_id, self.last_authd_at) {
(Some(id), Some(created_at)) => Some(Authentication { (Some(id), Some(created_at)) => Some(Authentication {
data: id, data: id.into(),
created_at, created_at,
}), }),
(None, None) => None, (None, None) => None,
@ -163,7 +165,7 @@ impl TryInto<BrowserSession<PostgresqlBackend>> for SessionLookup {
}; };
Ok(BrowserSession { Ok(BrowserSession {
data: self.id, data: self.user_session_id.into(),
user, user,
created_at: self.created_at, created_at: self.created_at,
last_authentication, last_authentication,
@ -171,37 +173,37 @@ impl TryInto<BrowserSession<PostgresqlBackend>> for SessionLookup {
} }
} }
#[tracing::instrument(skip_all, fields(session.id = id))] #[tracing::instrument(skip_all, fields(session.id = %id))]
pub async fn lookup_active_session( pub async fn lookup_active_session(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
id: i64, id: Ulid,
) -> Result<BrowserSession<PostgresqlBackend>, ActiveSessionLookupError> { ) -> Result<BrowserSession<PostgresqlBackend>, ActiveSessionLookupError> {
let res = sqlx::query_as!( let res = sqlx::query_as!(
SessionLookup, SessionLookup,
r#" r#"
SELECT SELECT
s.id, s.user_session_id,
u.id AS user_id, u.user_id,
u.username, u.username,
s.created_at, s.created_at,
a.id AS "last_authentication_id?", a.user_session_authentication_id AS "last_authentication_id?",
a.created_at AS "last_authd_at?", a.created_at AS "last_authd_at?",
ue.id AS "user_email_id?", ue.user_email_id AS "user_email_id?",
ue.email AS "user_email?", ue.email AS "user_email?",
ue.created_at AS "user_email_created_at?", ue.created_at AS "user_email_created_at?",
ue.confirmed_at AS "user_email_confirmed_at?" ue.confirmed_at AS "user_email_confirmed_at?"
FROM user_sessions s FROM user_sessions s
INNER JOIN users u INNER JOIN users u
ON s.user_id = u.id USING (user_id)
LEFT JOIN user_session_authentications a LEFT JOIN user_session_authentications a
ON a.session_id = s.id USING (user_session_id)
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id ON ue.user_email_id = u.primary_user_email_id
WHERE s.id = $1 AND s.active WHERE s.user_session_id = $1 AND s.finished_at IS NULL
ORDER BY a.created_at DESC ORDER BY a.created_at DESC
LIMIT 1 LIMIT 1
"#, "#,
id, Uuid::from(id),
) )
.fetch_one(executor) .fetch_one(executor)
.await? .await?
@ -210,35 +212,37 @@ pub async fn lookup_active_session(
Ok(res) Ok(res)
} }
#[tracing::instrument(skip_all, fields(user.id = user.data))] #[tracing::instrument(skip_all, fields(user.id = %user.data))]
pub async fn start_session( pub async fn start_session(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
user: User<PostgresqlBackend>, user: User<PostgresqlBackend>,
) -> anyhow::Result<BrowserSession<PostgresqlBackend>> { ) -> anyhow::Result<BrowserSession<PostgresqlBackend>> {
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO user_sessions (user_id) INSERT INTO user_sessions (user_session_id, user_id, created_at)
VALUES ($1) VALUES ($1, $2, $3)
RETURNING id, created_at
"#, "#,
user.data, Uuid::from(id),
Uuid::from(user.data),
created_at,
) )
.fetch_one(executor) .execute(executor)
.await .await
.context("could not create session")?; .context("could not create session")?;
let session = BrowserSession { let session = BrowserSession {
data: res.id, data: id,
user, user,
created_at: res.created_at, created_at,
last_authentication: None, last_authentication: None,
}; };
Ok(session) Ok(session)
} }
#[tracing::instrument(skip_all, fields(user.id = user.data))] #[tracing::instrument(skip_all, fields(user.id = %user.data))]
pub async fn count_active_sessions( pub async fn count_active_sessions(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>, user: &User<PostgresqlBackend>,
@ -247,9 +251,9 @@ pub async fn count_active_sessions(
r#" r#"
SELECT COUNT(*) as "count!" SELECT COUNT(*) as "count!"
FROM user_sessions s FROM user_sessions s
WHERE s.user_id = $1 AND s.active WHERE s.user_id = $1 AND s.finished_at IS NULL
"#, "#,
user.data, Uuid::from(user.data),
) )
.fetch_one(executor) .fetch_one(executor)
.await? .await?
@ -273,7 +277,7 @@ pub enum AuthenticationError {
Internal(#[from] tokio::task::JoinError), Internal(#[from] tokio::task::JoinError),
} }
#[tracing::instrument(skip_all, fields(session.id = session.data, user.id = session.user.data))] #[tracing::instrument(skip_all, fields(session.id = %session.data, user.id = %session.user.data))]
pub async fn authenticate_session( pub async fn authenticate_session(
txn: &mut Transaction<'_, Postgres>, txn: &mut Transaction<'_, Postgres>,
session: &mut BrowserSession<PostgresqlBackend>, session: &mut BrowserSession<PostgresqlBackend>,
@ -288,7 +292,7 @@ pub async fn authenticate_session(
ORDER BY up.created_at DESC ORDER BY up.created_at DESC
LIMIT 1 LIMIT 1
"#, "#,
session.user.data, Uuid::from(session.user.data),
) )
.fetch_one(txn.borrow_mut()) .fetch_one(txn.borrow_mut())
.instrument(tracing::info_span!("Lookup hashed password")) .instrument(tracing::info_span!("Lookup hashed password"))
@ -309,44 +313,50 @@ pub async fn authenticate_session(
.await??; .await??;
// That went well, let's insert the auth info // That went well, let's insert the auth info
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO user_session_authentications (session_id) INSERT INTO user_session_authentications
VALUES ($1) (user_session_authentication_id, user_session_id, created_at)
RETURNING id, created_at VALUES ($1, $2, $3)
"#, "#,
session.data, Uuid::from(id),
Uuid::from(session.data),
created_at,
) )
.fetch_one(txn.borrow_mut()) .execute(txn.borrow_mut())
.instrument(tracing::info_span!("Save authentication")) .instrument(tracing::info_span!("Save authentication"))
.await .await
.map_err(AuthenticationError::Save)?; .map_err(AuthenticationError::Save)?;
session.last_authentication = Some(Authentication { session.last_authentication = Some(Authentication {
data: res.id, data: id,
created_at: res.created_at, created_at,
}); });
Ok(()) Ok(())
} }
#[tracing::instrument(skip(txn, phf, password))] #[tracing::instrument(skip(txn, phf, password), err)]
pub async fn register_user( pub async fn register_user(
txn: &mut Transaction<'_, Postgres>, txn: &mut Transaction<'_, Postgres>,
phf: impl PasswordHasher, phf: impl PasswordHasher,
username: &str, username: &str,
password: &str, password: &str,
) -> anyhow::Result<User<PostgresqlBackend>> { ) -> anyhow::Result<User<PostgresqlBackend>> {
let id: i64 = sqlx::query_scalar!( let created_at = Utc::now();
let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO users (username) INSERT INTO users (user_id, username, created_at)
VALUES ($1) VALUES ($1, $2, $3)
RETURNING id
"#, "#,
Uuid::from(id),
username, username,
created_at,
) )
.fetch_one(txn.borrow_mut()) .execute(txn.borrow_mut())
.instrument(info_span!("Register user")) .instrument(info_span!("Register user"))
.await .await
.context("could not insert user")?; .context("could not insert user")?;
@ -354,7 +364,7 @@ pub async fn register_user(
let user = User { let user = User {
data: id, data: id,
username: username.to_owned(), username: username.to_owned(),
sub: format!("fake-sub-{}", id), sub: id.to_string(),
primary_email: None, primary_email: None,
}; };
@ -363,23 +373,28 @@ pub async fn register_user(
Ok(user) Ok(user)
} }
#[tracing::instrument(skip_all, fields(user.id = user.data))] #[tracing::instrument(skip_all, fields(user.id = %user.data))]
pub async fn set_password( pub async fn set_password(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
phf: impl PasswordHasher, phf: impl PasswordHasher,
user: &User<PostgresqlBackend>, user: &User<PostgresqlBackend>,
password: &str, password: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let salt = SaltString::generate(&mut OsRng); let created_at = Utc::now();
let id = Ulid::from_datetime(created_at.into());
let salt = SaltString::generate(thread_rng());
let hashed_password = PasswordHash::generate(phf, password, salt.as_str())?; let hashed_password = PasswordHash::generate(phf, password, salt.as_str())?;
sqlx::query_scalar!( sqlx::query_scalar!(
r#" r#"
INSERT INTO user_passwords (user_id, hashed_password) INSERT INTO user_passwords (user_password_id, user_id, hashed_password, created_at)
VALUES ($1, $2) VALUES ($1, $2, $3, $4)
"#, "#,
user.data, Uuid::from(id),
Uuid::from(user.data),
hashed_password.to_string(), hashed_password.to_string(),
created_at,
) )
.execute(executor) .execute(executor)
.instrument(info_span!("Save user credentials")) .instrument(info_span!("Save user credentials"))
@ -389,14 +404,20 @@ pub async fn set_password(
Ok(()) Ok(())
} }
#[tracing::instrument(skip_all, fields(session.id = session.data))] #[tracing::instrument(skip_all, fields(session.id = %session.data))]
pub async fn end_session( pub async fn end_session(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
session: &BrowserSession<PostgresqlBackend>, session: &BrowserSession<PostgresqlBackend>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let now = Utc::now();
let res = sqlx::query!( let res = sqlx::query!(
"UPDATE user_sessions SET active = FALSE WHERE id = $1", r#"
session.data, UPDATE user_sessions
SET finished_at = $1
WHERE user_session_id = $2
"#,
now,
Uuid::from(session.data),
) )
.execute(executor) .execute(executor)
.instrument(info_span!("End session")) .instrument(info_span!("End session"))
@ -433,16 +454,16 @@ pub async fn lookup_user_by_username(
UserLookup, UserLookup,
r#" r#"
SELECT SELECT
u.id AS user_id, u.user_id,
u.username AS user_username, u.username AS user_username,
ue.id AS "user_email_id?", ue.user_email_id AS "user_email_id?",
ue.email AS "user_email?", ue.email AS "user_email?",
ue.created_at AS "user_email_created_at?", ue.created_at AS "user_email_created_at?",
ue.confirmed_at AS "user_email_confirmed_at?" ue.confirmed_at AS "user_email_confirmed_at?"
FROM users u FROM users u
LEFT JOIN user_emails ue LEFT JOIN user_emails ue
ON ue.id = u.primary_email_id USING (user_id)
WHERE u.username = $1 WHERE u.username = $1
"#, "#,
@ -459,7 +480,7 @@ pub async fn lookup_user_by_username(
res.user_email_confirmed_at, res.user_email_confirmed_at,
) { ) {
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
data: id, data: id.into(),
email, email,
created_at, created_at,
confirmed_at, confirmed_at,
@ -468,10 +489,11 @@ pub async fn lookup_user_by_username(
_ => return Err(DatabaseInconsistencyError.into()), _ => return Err(DatabaseInconsistencyError.into()),
}; };
let id = Ulid::from(res.user_id);
Ok(User { Ok(User {
data: res.user_id, data: id,
username: res.user_username, username: res.user_username,
sub: format!("fake-sub-{}", res.user_id), sub: id.to_string(),
primary_email, primary_email,
}) })
} }
@ -494,7 +516,7 @@ pub async fn username_exists(
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct UserEmailLookup { struct UserEmailLookup {
user_email_id: i64, user_email_id: Uuid,
user_email: String, user_email: String,
user_email_created_at: DateTime<Utc>, user_email_created_at: DateTime<Utc>,
user_email_confirmed_at: Option<DateTime<Utc>>, user_email_confirmed_at: Option<DateTime<Utc>>,
@ -503,7 +525,7 @@ struct UserEmailLookup {
impl From<UserEmailLookup> for UserEmail<PostgresqlBackend> { impl From<UserEmailLookup> for UserEmail<PostgresqlBackend> {
fn from(e: UserEmailLookup) -> UserEmail<PostgresqlBackend> { fn from(e: UserEmailLookup) -> UserEmail<PostgresqlBackend> {
UserEmail { UserEmail {
data: e.user_email_id, data: e.user_email_id.into(),
email: e.user_email, email: e.user_email,
created_at: e.user_email_created_at, created_at: e.user_email_created_at,
confirmed_at: e.user_email_confirmed_at, confirmed_at: e.user_email_confirmed_at,
@ -511,7 +533,7 @@ impl From<UserEmailLookup> for UserEmail<PostgresqlBackend> {
} }
} }
#[tracing::instrument(skip_all, fields(user.id = user.data, %user.username))] #[tracing::instrument(skip_all, fields(user.id = %user.data, %user.username))]
pub async fn get_user_emails( pub async fn get_user_emails(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>, user: &User<PostgresqlBackend>,
@ -520,7 +542,7 @@ pub async fn get_user_emails(
UserEmailLookup, UserEmailLookup,
r#" r#"
SELECT SELECT
ue.id AS "user_email_id", ue.user_email_id,
ue.email AS "user_email", ue.email AS "user_email",
ue.created_at AS "user_email_created_at", ue.created_at AS "user_email_created_at",
ue.confirmed_at AS "user_email_confirmed_at" ue.confirmed_at AS "user_email_confirmed_at"
@ -530,7 +552,7 @@ pub async fn get_user_emails(
ORDER BY ue.email ASC ORDER BY ue.email ASC
"#, "#,
user.data, Uuid::from(user.data),
) )
.fetch_all(executor) .fetch_all(executor)
.instrument(info_span!("Fetch user emails")) .instrument(info_span!("Fetch user emails"))
@ -539,27 +561,27 @@ pub async fn get_user_emails(
Ok(res.into_iter().map(Into::into).collect()) Ok(res.into_iter().map(Into::into).collect())
} }
#[tracing::instrument(skip_all, fields(user.id = user.data, %user.username, email.id = id))] #[tracing::instrument(skip_all, fields(user.id = %user.data, %user.username, email.id = %id))]
pub async fn get_user_email( pub async fn get_user_email(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>, user: &User<PostgresqlBackend>,
id: i64, id: Ulid,
) -> Result<UserEmail<PostgresqlBackend>, anyhow::Error> { ) -> Result<UserEmail<PostgresqlBackend>, anyhow::Error> {
let res = sqlx::query_as!( let res = sqlx::query_as!(
UserEmailLookup, UserEmailLookup,
r#" r#"
SELECT SELECT
ue.id AS "user_email_id", ue.user_email_id,
ue.email AS "user_email", ue.email AS "user_email",
ue.created_at AS "user_email_created_at", ue.created_at AS "user_email_created_at",
ue.confirmed_at AS "user_email_confirmed_at" ue.confirmed_at AS "user_email_confirmed_at"
FROM user_emails ue FROM user_emails ue
WHERE ue.user_id = $1 WHERE ue.user_id = $1
AND ue.id = $2 AND ue.user_email_id = $2
"#, "#,
user.data, Uuid::from(user.data),
id, Uuid::from(id),
) )
.fetch_one(executor) .fetch_one(executor)
.instrument(info_span!("Fetch user emails")) .instrument(info_span!("Fetch user emails"))
@ -568,32 +590,35 @@ pub async fn get_user_email(
Ok(res.into()) Ok(res.into())
} }
#[tracing::instrument(skip(executor, user), fields(user.id = user.data, %user.username))] #[tracing::instrument(skip(executor, user), fields(user.id = %user.data, %user.username))]
pub async fn add_user_email( pub async fn add_user_email(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>, user: &User<PostgresqlBackend>,
email: &str, email: String,
) -> anyhow::Result<UserEmail<PostgresqlBackend>> { ) -> anyhow::Result<UserEmail<PostgresqlBackend>> {
let res = sqlx::query_as!( let created_at = Utc::now();
UserEmailLookup, let id = Ulid::from_datetime(created_at.into());
sqlx::query!(
r#" r#"
INSERT INTO user_emails (user_id, email) INSERT INTO user_emails (user_email_id, user_id, email, created_at)
VALUES ($1, $2) VALUES ($1, $2, $3, $4)
RETURNING
id AS user_email_id,
email AS user_email,
created_at AS user_email_created_at,
confirmed_at AS user_email_confirmed_at
"#, "#,
user.data, Uuid::from(id),
email, Uuid::from(user.data),
&email,
created_at,
) )
.fetch_one(executor) .execute(executor)
.instrument(info_span!("Add user email")) .instrument(info_span!("Add user email"))
.await .await
.context("could not insert user email")?; .context("could not insert user email")?;
Ok(res.into()) Ok(UserEmail {
data: id,
email,
created_at,
confirmed_at: None,
})
} }
#[tracing::instrument(skip(executor))] #[tracing::instrument(skip(executor))]
@ -604,12 +629,12 @@ pub async fn set_user_email_as_primary(
sqlx::query!( sqlx::query!(
r#" r#"
UPDATE users UPDATE users
SET primary_email_id = user_emails.id SET primary_user_email_id = user_emails.user_email_id
FROM user_emails FROM user_emails
WHERE user_emails.id = $1 WHERE user_emails.user_email_id = $1
AND users.id = user_emails.user_id AND users.user_id = user_emails.user_id
"#, "#,
email.data, Uuid::from(email.data),
) )
.execute(executor) .execute(executor)
.instrument(info_span!("Add user email")) .instrument(info_span!("Add user email"))
@ -627,9 +652,9 @@ pub async fn remove_user_email(
sqlx::query!( sqlx::query!(
r#" r#"
DELETE FROM user_emails DELETE FROM user_emails
WHERE user_emails.id = $1 WHERE user_emails.user_email_id = $1
"#, "#,
email.data, Uuid::from(email.data),
) )
.execute(executor) .execute(executor)
.instrument(info_span!("Remove user email")) .instrument(info_span!("Remove user email"))
@ -649,7 +674,7 @@ pub async fn lookup_user_email(
UserEmailLookup, UserEmailLookup,
r#" r#"
SELECT SELECT
ue.id AS "user_email_id", ue.user_email_id,
ue.email AS "user_email", ue.email AS "user_email",
ue.created_at AS "user_email_created_at", ue.created_at AS "user_email_created_at",
ue.confirmed_at AS "user_email_confirmed_at" ue.confirmed_at AS "user_email_confirmed_at"
@ -658,7 +683,7 @@ pub async fn lookup_user_email(
WHERE ue.user_id = $1 WHERE ue.user_id = $1
AND ue.email = $2 AND ue.email = $2
"#, "#,
user.data, Uuid::from(user.data),
email, email,
) )
.fetch_one(executor) .fetch_one(executor)
@ -673,23 +698,23 @@ pub async fn lookup_user_email(
pub async fn lookup_user_email_by_id( pub async fn lookup_user_email_by_id(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>, user: &User<PostgresqlBackend>,
id: i64, id: Ulid,
) -> anyhow::Result<UserEmail<PostgresqlBackend>> { ) -> anyhow::Result<UserEmail<PostgresqlBackend>> {
let res = sqlx::query_as!( let res = sqlx::query_as!(
UserEmailLookup, UserEmailLookup,
r#" r#"
SELECT SELECT
ue.id AS "user_email_id", ue.user_email_id,
ue.email AS "user_email", ue.email AS "user_email",
ue.created_at AS "user_email_created_at", ue.created_at AS "user_email_created_at",
ue.confirmed_at AS "user_email_confirmed_at" ue.confirmed_at AS "user_email_confirmed_at"
FROM user_emails ue FROM user_emails ue
WHERE ue.user_id = $1 WHERE ue.user_id = $1
AND ue.id = $2 AND ue.user_email_id = $2
"#, "#,
user.data, Uuid::from(user.data),
id, Uuid::from(id),
) )
.fetch_one(executor) .fetch_one(executor)
.instrument(info_span!("Lookup user email")) .instrument(info_span!("Lookup user email"))
@ -704,31 +729,32 @@ pub async fn mark_user_email_as_verified(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
mut email: UserEmail<PostgresqlBackend>, mut email: UserEmail<PostgresqlBackend>,
) -> anyhow::Result<UserEmail<PostgresqlBackend>> { ) -> anyhow::Result<UserEmail<PostgresqlBackend>> {
let confirmed_at = sqlx::query_scalar!( let confirmed_at = Utc::now();
sqlx::query!(
r#" r#"
UPDATE user_emails UPDATE user_emails
SET confirmed_at = NOW() SET confirmed_at = $2
WHERE id = $1 WHERE user_email_id = $1
RETURNING confirmed_at
"#, "#,
email.data, Uuid::from(email.data),
confirmed_at,
) )
.fetch_one(executor) .execute(executor)
.instrument(info_span!("Confirm user email")) .instrument(info_span!("Confirm user email"))
.await .await
.context("could not update user email")?; .context("could not update user email")?;
email.confirmed_at = confirmed_at; email.confirmed_at = Some(confirmed_at);
Ok(email) Ok(email)
} }
struct UserEmailVerificationLookup { struct UserEmailConfirmationCodeLookup {
verification_id: i64, user_email_confirmation_code_id: Uuid,
verification_code: String, code: String,
verification_expired: bool, created_at: DateTime<Utc>,
verification_created_at: DateTime<Utc>, expires_at: DateTime<Utc>,
verification_consumed_at: Option<DateTime<Utc>>, consumed_at: Option<DateTime<Utc>>,
} }
#[tracing::instrument(skip(executor))] #[tracing::instrument(skip(executor))]
@ -736,49 +762,46 @@ pub async fn lookup_user_email_verification_code(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
email: UserEmail<PostgresqlBackend>, email: UserEmail<PostgresqlBackend>,
code: &str, code: &str,
max_age: chrono::Duration,
) -> anyhow::Result<UserEmailVerification<PostgresqlBackend>> { ) -> anyhow::Result<UserEmailVerification<PostgresqlBackend>> {
// For some reason, we need to convert the type first let now = Utc::now();
let max_age = PgInterval::try_from(max_age)
// For some reason, this error type does not let me to just bubble up the error here
.map_err(|e| anyhow::anyhow!("failed to encode duration: {}", e))?;
let res = sqlx::query_as!( let res = sqlx::query_as!(
UserEmailVerificationLookup, UserEmailConfirmationCodeLookup,
r#" r#"
SELECT SELECT
ev.id AS "verification_id", ec.user_email_confirmation_code_id,
ev.code AS "verification_code", ec.code,
(ev.created_at + $3 < NOW()) AS "verification_expired!", ec.created_at,
ev.created_at AS "verification_created_at", ec.expires_at,
ev.consumed_at AS "verification_consumed_at" ec.consumed_at
FROM user_email_verifications ev FROM user_email_confirmation_codes ec
WHERE ev.code = $1 WHERE ec.code = $1
AND ev.user_email_id = $2 AND ec.user_email_id = $2
"#, "#,
code, code,
email.data, Uuid::from(email.data),
max_age,
) )
.fetch_one(executor) .fetch_one(executor)
.instrument(info_span!("Lookup user email verification")) .instrument(info_span!("Lookup user email verification"))
.await .await
.context("could not lookup user email verification")?; .context("could not lookup user email verification")?;
let state = if res.verification_expired { let state = if let Some(when) = res.consumed_at {
UserEmailVerificationState::Expired
} else if let Some(when) = res.verification_consumed_at {
UserEmailVerificationState::AlreadyUsed { when } UserEmailVerificationState::AlreadyUsed { when }
} else if res.expires_at < now {
UserEmailVerificationState::Expired {
when: res.expires_at,
}
} else { } else {
UserEmailVerificationState::Valid UserEmailVerificationState::Valid
}; };
Ok(UserEmailVerification { Ok(UserEmailVerification {
data: res.verification_id, data: res.user_email_confirmation_code_id.into(),
code: res.verification_code, code: res.code,
email, email,
state, state,
created_at: res.verification_created_at, created_at: res.created_at,
}) })
} }
@ -791,16 +814,18 @@ pub async fn consume_email_verification(
bail!("user email verification in wrong state"); bail!("user email verification in wrong state");
} }
let consumed_at = sqlx::query_scalar!( let consumed_at = Utc::now();
sqlx::query!(
r#" r#"
UPDATE user_email_verifications UPDATE user_email_confirmation_codes
SET consumed_at = NOW() SET consumed_at = $2
WHERE id = $1 WHERE user_email_confirmation_code_id = $1
RETURNING consumed_at AS "consumed_at!"
"#, "#,
verification.data, Uuid::from(verification.data),
consumed_at
) )
.fetch_one(executor) .execute(executor)
.instrument(info_span!("Consume user email verification")) .instrument(info_span!("Consume user email verification"))
.await .await
.context("could not update user email verification")?; .context("could not update user email verification")?;
@ -810,32 +835,39 @@ pub async fn consume_email_verification(
Ok(verification) Ok(verification)
} }
#[tracing::instrument(skip(executor, email), fields(email.id = email.data, %email.email))] #[tracing::instrument(skip(executor, email), fields(email.id = %email.data, %email.email))]
pub async fn add_user_email_verification_code( pub async fn add_user_email_verification_code(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
email: UserEmail<PostgresqlBackend>, email: UserEmail<PostgresqlBackend>,
max_age: chrono::Duration,
code: String, code: String,
) -> anyhow::Result<UserEmailVerification<PostgresqlBackend>> { ) -> anyhow::Result<UserEmailVerification<PostgresqlBackend>> {
let res = sqlx::query_as!( let created_at = Utc::now();
IdAndCreationTime, let id = Ulid::from_datetime(created_at.into());
let expires_at = created_at + max_age;
sqlx::query!(
r#" r#"
INSERT INTO user_email_verifications (user_email_id, code) INSERT INTO user_email_confirmation_codes
VALUES ($1, $2) (user_email_confirmation_code_id, user_email_id, code, created_at, expires_at)
RETURNING id, created_at VALUES ($1, $2, $3, $4, $5)
"#, "#,
email.data, Uuid::from(id),
Uuid::from(email.data),
code, code,
created_at,
expires_at,
) )
.fetch_one(executor) .execute(executor)
.instrument(info_span!("Add user email verification code")) .instrument(info_span!("Add user email verification code"))
.await .await
.context("could not insert user email verification code")?; .context("could not insert user email verification code")?;
let verification = UserEmailVerification { let verification = UserEmailVerification {
data: res.id, data: id,
email, email,
code, code,
created_at: res.created_at, created_at,
state: UserEmailVerificationState::Valid, state: UserEmailVerificationState::Valid,
}; };

View File

@ -22,6 +22,7 @@ serde_urlencoded = "0.7.1"
chrono = "0.4.22" chrono = "0.4.22"
url = "2.3.1" url = "2.3.1"
ulid = { version = "1.0.0", features = ["serde"] }
oauth2-types = { path = "../oauth2-types" } oauth2-types = { path = "../oauth2-types" }
mas-data-model = { path = "../data-model" } mas-data-model = { path = "../data-model" }

View File

@ -23,6 +23,7 @@ use mas_data_model::{
}; };
use mas_router::PostAuthAction; use mas_router::PostAuthAction;
use serde::{ser::SerializeStruct, Deserialize, Serialize}; use serde::{ser::SerializeStruct, Deserialize, Serialize};
use ulid::Ulid;
use url::Url; use url::Url;
use crate::{FormField, FormState}; use crate::{FormField, FormState};
@ -517,11 +518,11 @@ impl TemplateContext for CompatSsoContext {
login: CompatSsoLogin { login: CompatSsoLogin {
data: (), data: (),
redirect_uri: Url::parse("https://app.element.io/").unwrap(), redirect_uri: Url::parse("https://app.element.io/").unwrap(),
token: "abcdefghijklmnopqrstuvwxyz012345".into(), login_token: "abcdefghijklmnopqrstuvwxyz012345".into(),
created_at: Utc::now(), created_at: Utc::now(),
state: CompatSsoLoginState::Pending, state: CompatSsoLoginState::Pending,
}, },
action: PostAuthAction::ContinueCompatSsoLogin { data: 1 }, action: PostAuthAction::ContinueCompatSsoLogin { data: Ulid::new() },
}] }]
} }
} }