use async_trait::async_trait;
use sqlx::{Row, postgres::PgPool};
use crate::{
error::{AuthError, Result},
session::{SessionData, SessionStore, TokenPair, generate_refresh_token, hash_token, unix_now},
};
pub const ROTATION_GRACE_SECS: i64 = 10;
pub const DEFAULT_ACCESS_TTL_SECS: u64 = 3_600;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SessionLifetime {
pub access_ttl_secs: Option<u64>,
pub absolute_ttl_secs: Option<u64>,
}
#[derive(Debug)]
pub enum Refresh {
Rotated(TokenPair),
AlreadyRotated,
Refused,
}
pub const DEFAULT_TOKEN_ISSUER: &str = "fraiseql";
pub const DEFAULT_TOKEN_AUDIENCE: &str = "fraiseql-api";
pub const DEFAULT_TENANT_CLAIM: &str = "tenant_id";
pub struct PostgresSessionStore {
db: PgPool,
hs256_secret: Option<Vec<u8>>,
signer: Option<std::sync::Arc<dyn crate::AccessTokenSigner>>,
token_issuer: String,
token_audience: String,
tenant_claim: String,
lifetime: SessionLifetime,
}
pub const SESSIONS_SCHEMA_SQL: &str = concat!(
claim_auth_schema!(),
r"
-- One row per refresh token. `expires_at` is when that refresh token stops working, and
-- what the sweep deletes by. It means one of two things:
-- * a session that cannot be refreshed (no `[auth.session] absolute_ttl_secs`): the
-- access token's expiry, as it always has;
-- * a refreshable session (#1853): the session's absolute limit, counted from sign-in
-- and copied unchanged to every row its refreshes create. The access token's own
-- expiry is in the token, not here.
CREATE TABLE IF NOT EXISTS auth.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
refresh_token_hash TEXT NOT NULL UNIQUE,
issued_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
-- The account's tenant when the session was minted (#1450), for revocation and
-- audit. NULL for a platform account or a principal with no account row.
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- A refreshable session (#1853): every row a sign-in and its refreshes create shares a
-- family. NULL for a session that cannot be refreshed.
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS family_id UUID;
-- When this row's refresh token was exchanged for the next one, in Unix seconds on the
-- server's clock. A rotated token used again is answered by how long ago this was.
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS rotated_at BIGINT;
-- The `jti` of the access token minted with this row, so logout can deny it where
-- `[security.token_revocation]` is configured.
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS access_jti TEXT;
CREATE INDEX IF NOT EXISTS idx_sessions_family_id ON auth.sessions(family_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON auth.sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON auth.sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_revoked_at ON auth.sessions(revoked_at);
"
);
impl PostgresSessionStore {
#[must_use]
pub fn new(db: PgPool) -> Self {
Self {
db,
hs256_secret: None,
signer: None,
token_issuer: DEFAULT_TOKEN_ISSUER.to_string(),
token_audience: DEFAULT_TOKEN_AUDIENCE.to_string(),
tenant_claim: DEFAULT_TENANT_CLAIM.to_string(),
lifetime: SessionLifetime::default(),
}
}
#[must_use]
pub fn with_hs256_secret(db: PgPool, secret: Vec<u8>) -> Self {
Self {
hs256_secret: Some(secret),
..Self::new(db)
}
}
#[must_use]
pub fn with_signer(db: PgPool, signer: std::sync::Arc<dyn crate::AccessTokenSigner>) -> Self {
Self {
signer: Some(signer),
..Self::new(db)
}
}
#[must_use]
pub fn with_tenant_claim(mut self, claim: impl Into<String>) -> Self {
self.tenant_claim = claim.into();
self
}
#[must_use]
pub const fn with_lifetime(mut self, lifetime: SessionLifetime) -> Self {
self.lifetime = lifetime;
self
}
#[must_use]
pub fn with_token_claims(
mut self,
issuer: impl Into<String>,
audience: impl Into<String>,
) -> Self {
self.token_issuer = issuer.into();
self.token_audience = audience.into();
self
}
pub async fn init(&self) -> Result<()> {
sqlx::raw_sql(SESSIONS_SCHEMA_SQL).execute(&self.db).await.map_err(|e| {
AuthError::DatabaseError {
message: format!("Failed to initialize sessions table: {}", e),
}
})?;
Ok(())
}
fn generate_access_token(
&self,
user_id: &str,
tenant: Option<uuid::Uuid>,
expires_in: u64,
) -> Result<(String, String)> {
let now = unix_now()?;
let exp = now + expires_in;
let mut claims = crate::Claims {
sub: user_id.to_string(),
iat: now,
exp,
nbf: self.signer.as_ref().map(|_| now),
iss: self.token_issuer.clone(),
aud: vec![self.token_audience.clone()],
extra: std::collections::HashMap::new(),
};
let jti = uuid::Uuid::new_v4().to_string();
claims.extra.insert("jti".to_string(), serde_json::json!(jti));
if let Some(tenant) = tenant {
claims.extra.insert(
self.tenant_claim.clone(),
serde_json::json!(tenant.as_simple().to_string()),
);
}
if let Some(signer) = &self.signer {
return Ok((signer.sign_access_token(&claims)?, jti));
}
match &self.hs256_secret {
Some(secret) => Ok((crate::jwt::generate_hs256_token(&claims, secret)?, jti)),
None => Err(AuthError::ConfigError {
message: "JWT signing is not configured for this PostgresSessionStore — \
construct it with with_signer. Refusing to issue an access \
token that no validator could verify."
.to_string(),
}),
}
}
}
async fn account_for_session(db: &PgPool, user_id: &str) -> Result<Option<uuid::Uuid>> {
let row = sqlx::query("SELECT active, tenant_id FROM auth.tb_user WHERE user_id = $1")
.bind(user_id)
.fetch_optional(db)
.await;
let (active, tenant): (Option<bool>, Option<uuid::Uuid>) = match row {
Ok(Some(row)) => (Some(row.get("active")), row.get("tenant_id")),
Ok(None) => (None, None),
Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("42P01") => {
return Ok(None);
},
Err(e) => {
return Err(AuthError::DatabaseError {
message: format!("Failed to check account activation: {e}"),
});
},
};
if active == Some(false) {
tracing::warn!(
user_id = %user_id,
"refused a session for a deactivated account (SCIM active = false)"
);
return Err(AuthError::AccountDeactivated);
}
Ok(tenant)
}
impl PostgresSessionStore {
pub async fn sweep_expired(&self) -> Result<u64> {
let now = unix_now()?;
let swept = sqlx::query("DELETE FROM auth.sessions WHERE expires_at < $1")
.bind(now.cast_signed())
.execute(&self.db)
.await
.map_err(|e| AuthError::DatabaseError {
message: format!("Failed to sweep sessions: {e}"),
})?
.rows_affected();
Ok(swept)
}
pub async fn refresh(&self, refresh_token: &str) -> Result<Refresh> {
if self.lifetime.absolute_ttl_secs.is_none() {
return Ok(Refresh::Refused);
}
let now = unix_now()?;
let mut tx = self.db.begin().await.map_err(|e| db_error("begin a refresh", &e))?;
let Some(row) = sqlx::query(
r"
SELECT id, user_id, family_id, expires_at, rotated_at, revoked_at IS NOT NULL AS revoked
FROM auth.sessions
WHERE refresh_token_hash = $1
FOR UPDATE
",
)
.bind(hash_token(refresh_token))
.fetch_optional(&mut *tx)
.await
.map_err(|e| db_error("read a session", &e))?
else {
return Ok(Refresh::Refused);
};
let id: uuid::Uuid = row.get("id");
let user_id: String = row.get("user_id");
let family: Option<uuid::Uuid> = row.get("family_id");
let limit: i64 = row.get("expires_at");
let rotated_at: Option<i64> = row.get("rotated_at");
let revoked: bool = row.get("revoked");
let Some(family) = family else {
return Ok(Refresh::Refused);
};
if revoked {
return Ok(Refresh::Refused);
}
if let Some(rotated_at) = rotated_at {
if now.cast_signed() - rotated_at <= ROTATION_GRACE_SECS {
return Ok(Refresh::AlreadyRotated);
}
end_family(&mut tx, family).await?;
tx.commit().await.map_err(|e| db_error("end a session", &e))?;
tracing::warn!(
user_id = %user_id,
"a rotated refresh token was used again after the grace window; \
the session is ended"
);
return Ok(Refresh::Refused);
}
if now.cast_signed() >= limit {
return Ok(Refresh::Refused);
}
let tenant = match account_for_session(&self.db, &user_id).await {
Ok(tenant) => tenant,
Err(AuthError::AccountDeactivated) => {
end_family(&mut tx, family).await?;
tx.commit().await.map_err(|e| db_error("end a session", &e))?;
return Ok(Refresh::Refused);
},
Err(e) => return Err(e),
};
sqlx::query("UPDATE auth.sessions SET rotated_at = $2 WHERE id = $1")
.bind(id)
.bind(now.cast_signed())
.execute(&mut *tx)
.await
.map_err(|e| db_error("rotate a session", &e))?;
let pair = self
.insert_session(
&mut tx,
&user_id,
tenant,
now,
self.access_expiry(now, now),
Some((family, limit.cast_unsigned())),
)
.await?;
tx.commit().await.map_err(|e| db_error("commit a refresh", &e))?;
Ok(Refresh::Rotated(pair))
}
pub async fn end_session(&self, refresh_token: &str) -> Result<Vec<String>> {
let mut tx = self.db.begin().await.map_err(|e| db_error("begin a logout", &e))?;
let Some(row) =
sqlx::query("SELECT id, family_id FROM auth.sessions WHERE refresh_token_hash = $1")
.bind(hash_token(refresh_token))
.fetch_optional(&mut *tx)
.await
.map_err(|e| db_error("read a session", &e))?
else {
return Ok(Vec::new());
};
let id: uuid::Uuid = row.get("id");
let family: Option<uuid::Uuid> = row.get("family_id");
let jtis: Vec<Option<String>> = sqlx::query_scalar(
r"
UPDATE auth.sessions
SET revoked_at = COALESCE(revoked_at, NOW())
WHERE id = $1 OR family_id = $2
RETURNING access_jti
",
)
.bind(id)
.bind(family)
.fetch_all(&mut *tx)
.await
.map_err(|e| db_error("end a session", &e))?;
tx.commit().await.map_err(|e| db_error("commit a logout", &e))?;
Ok(jtis.into_iter().flatten().collect())
}
#[must_use]
pub const fn refreshable_access_ttl_secs(&self) -> Option<u64> {
match self.lifetime {
SessionLifetime {
absolute_ttl_secs: None,
..
} => None,
SessionLifetime {
access_ttl_secs: Some(ttl),
..
} => Some(ttl),
SessionLifetime { .. } => Some(DEFAULT_ACCESS_TTL_SECS),
}
}
const fn access_expiry(&self, now: u64, requested: u64) -> u64 {
match self.lifetime {
SessionLifetime {
access_ttl_secs: Some(ttl),
..
} => now + ttl,
SessionLifetime {
absolute_ttl_secs: Some(_),
..
} => now + DEFAULT_ACCESS_TTL_SECS,
SessionLifetime { .. } => requested,
}
}
async fn mint(
&self,
user_id: &str,
tenant: Option<uuid::Uuid>,
expires_at: u64,
) -> Result<TokenPair> {
let now = unix_now()?;
let access_expires_at = self.access_expiry(now, expires_at);
let family = self
.lifetime
.absolute_ttl_secs
.map(|absolute| (uuid::Uuid::new_v4(), now + absolute));
let mut conn = self.db.acquire().await.map_err(|e| db_error("acquire a connection", &e))?;
self.insert_session(&mut conn, user_id, tenant, now, access_expires_at, family)
.await
}
async fn insert_session(
&self,
conn: &mut sqlx::PgConnection,
user_id: &str,
tenant: Option<uuid::Uuid>,
now: u64,
access_expires_at: u64,
family: Option<(uuid::Uuid, u64)>,
) -> Result<TokenPair> {
let refresh_token = generate_refresh_token();
let refresh_token_hash = hash_token(&refresh_token);
let expires_in = access_expires_at.saturating_sub(now);
let (access_token, jti) = self.generate_access_token(user_id, tenant, expires_in)?;
let row_expires_at = family.map_or(access_expires_at, |(_, limit)| limit);
sqlx::query(
r"
INSERT INTO auth.sessions
(user_id, refresh_token_hash, issued_at, expires_at, tenant_id, family_id, access_jti)
VALUES ($1, $2, $3, $4, $5, $6, $7)
",
)
.bind(user_id)
.bind(&refresh_token_hash)
.bind(now.cast_signed())
.bind(row_expires_at.cast_signed())
.bind(tenant)
.bind(family.map(|(family, _)| family))
.bind(&jti)
.execute(&mut *conn)
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
AuthError::SessionError {
message: "Refresh token already exists".to_string(),
}
} else {
AuthError::DatabaseError {
message: format!("Failed to create session: {}", e),
}
}
})?;
Ok(TokenPair {
access_token,
refresh_token,
expires_in,
})
}
}
async fn end_family(conn: &mut sqlx::PgConnection, family: uuid::Uuid) -> Result<()> {
sqlx::query(
"UPDATE auth.sessions SET revoked_at = NOW() WHERE family_id = $1 AND revoked_at IS NULL",
)
.bind(family)
.execute(&mut *conn)
.await
.map_err(|e| db_error("end a session", &e))?;
Ok(())
}
fn db_error(doing: &str, e: &sqlx::Error) -> AuthError {
AuthError::DatabaseError {
message: format!("Failed to {doing}: {e}"),
}
}
#[async_trait]
impl SessionStore for PostgresSessionStore {
async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair> {
let tenant = account_for_session(&self.db, user_id).await?;
if let Some(challenge) = crate::totp_mfa::postgres::PgMfaStore::new(self.db.clone())
.challenge_if_enrolled(user_id)
.await?
{
return Err(AuthError::SecondFactorRequired { challenge });
}
self.mint(user_id, tenant, expires_at).await
}
async fn create_session_after_second_factor(
&self,
passed: crate::totp_mfa::SecondFactorPassed,
expires_at: u64,
) -> Result<TokenPair> {
let tenant = account_for_session(&self.db, passed.user_id()).await?;
self.mint(passed.user_id(), tenant, expires_at).await
}
async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData> {
let row = sqlx::query(
r"
SELECT user_id, issued_at, expires_at, refresh_token_hash
FROM auth.sessions
WHERE refresh_token_hash = $1 AND revoked_at IS NULL
",
)
.bind(refresh_token_hash)
.fetch_optional(&self.db)
.await
.map_err(|e| AuthError::DatabaseError {
message: format!("Failed to get session: {}", e),
})?
.ok_or(AuthError::TokenNotFound)?;
let user_id: String = row.get("user_id");
let issued_at: i64 = row.get("issued_at");
let expires_at: i64 = row.get("expires_at");
let refresh_token_hash: String = row.get("refresh_token_hash");
Ok(SessionData {
user_id,
issued_at: issued_at.cast_unsigned(),
expires_at: expires_at.cast_unsigned(),
refresh_token_hash,
})
}
async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()> {
let result = sqlx::query(
r"
UPDATE auth.sessions
SET revoked_at = NOW()
WHERE refresh_token_hash = $1 AND revoked_at IS NULL
",
)
.bind(refresh_token_hash)
.execute(&self.db)
.await
.map_err(|e| AuthError::DatabaseError {
message: format!("Failed to revoke session: {}", e),
})?;
if result.rows_affected() == 0 {
return Err(AuthError::SessionError {
message: "Session not found or already revoked".to_string(),
});
}
Ok(())
}
async fn revoke_all_sessions(&self, user_id: &str) -> Result<()> {
sqlx::query(
r"
UPDATE auth.sessions
SET revoked_at = NOW()
WHERE user_id = $1 AND revoked_at IS NULL
",
)
.bind(user_id)
.execute(&self.db)
.await
.map_err(|e| AuthError::DatabaseError {
message: format!("Failed to revoke all sessions: {}", e),
})?;
Ok(())
}
}
#[cfg(test)]
mod tests;