use crate::domain::auth::RefreshToken;
use crate::error::{AppError, Result};
use crate::storage::records::RefreshToken as RefreshTokenRecord;
use sqlx::{Executor, PgConnection, Postgres};
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Clone, Default)]
pub struct RefreshTokenRepository {}
impl RefreshTokenRepository {
pub fn new() -> Self {
Self {}
}
#[tracing::instrument(level = "debug", skip(self, executor, token_hash))]
pub async fn create<'e, E>(&self, executor: E, user_id: Uuid, token_hash: &str, ttl_days: i64) -> Result<()>
where
E: Executor<'e, Database = Postgres>,
{
let expires_at = OffsetDateTime::now_utc() + time::Duration::days(ttl_days);
sqlx::query("INSERT INTO refresh_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)")
.bind(token_hash)
.bind(user_id)
.bind(expires_at)
.execute(executor)
.await
.map_err(AppError::Database)?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(self, executor, token_hash), fields(user_id = tracing::field::Empty))]
pub async fn verify_and_consume(&self, executor: &mut PgConnection, token_hash: &str) -> Result<Option<Uuid>> {
let record: Option<RefreshTokenRecord> = sqlx::query_as(
r#"
SELECT token_hash, user_id, expires_at, created_at
FROM refresh_tokens
WHERE token_hash = $1
FOR UPDATE SKIP LOCKED
"#,
)
.bind(token_hash)
.fetch_optional(&mut *executor)
.await
.map_err(AppError::Database)?;
if let Some(record) = record {
let token: RefreshToken = record.into();
tracing::Span::current().record("user_id", tracing::field::display(token.user_id));
if token.is_expired_at(OffsetDateTime::now_utc()) {
tracing::warn!("Refresh token expired during rotation attempt");
sqlx::query("DELETE FROM refresh_tokens WHERE token_hash = $1")
.bind(token_hash)
.execute(&mut *executor)
.await?;
return Ok(None);
}
sqlx::query("DELETE FROM refresh_tokens WHERE token_hash = $1")
.bind(token_hash)
.execute(&mut *executor)
.await?;
Ok(Some(token.user_id))
} else {
tracing::warn!("Refresh token not found or already consumed (potential reuse attack)");
Ok(None)
}
}
#[tracing::instrument(level = "debug", skip(self, executor, token_hash))]
pub async fn delete_owned<'e, E>(&self, executor: E, token_hash: &str, user_id: Uuid) -> Result<()>
where
E: Executor<'e, Database = Postgres>,
{
sqlx::query("DELETE FROM refresh_tokens WHERE token_hash = $1 AND user_id = $2")
.bind(token_hash)
.bind(user_id)
.execute(executor)
.await
.map_err(AppError::Database)?;
Ok(())
}
}