use std::time::Duration;
use crate::signed_url::{sign, verify, SignedUrlError};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuthFlowError {
#[error("token is missing or malformed")]
Malformed,
#[error("token signature does not match")]
InvalidSignature,
#[error("token has expired")]
Expired,
#[error("token is for the wrong purpose ({0})")]
WrongPurpose(String),
#[error("password rejected: {0}")]
WeakPassword(String),
#[error("database error: {0}")]
Database(String),
#[error("token already used")]
AlreadyUsed,
#[error("token is for another scope")]
WrongScope,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkScope(String);
impl LinkScope {
const PARAM: &'static str = "scope";
#[must_use]
pub fn audience(audience: &str) -> Self {
Self(format!("aud:{audience}"))
}
fn link(&self, base: &str, query: &str) -> String {
let (path, fragment) = base.find('#').map_or((base, ""), |i| base.split_at(i));
let path = path.trim_end_matches(['?', '&']);
let joiner = if path.contains('?') { '&' } else { '?' };
let scope = url_encode(&self.0);
format!("{path}{joiner}{query}&{}={scope}{fragment}", Self::PARAM)
}
fn check(&self, url: &str) -> Result<(), AuthFlowError> {
match extract_query(url, Self::PARAM) {
Some(s) if s == self.0 => Ok(()),
_ => Err(AuthFlowError::WrongScope),
}
}
}
#[cfg(feature = "tenancy")]
impl From<&crate::tenancy::Org> for LinkScope {
fn from(org: &crate::tenancy::Org) -> Self {
let id = org.id.get().copied().unwrap_or_default();
Self(format!("tenant:{}:{id}", org.slug))
}
}
#[cfg(feature = "tenancy")]
impl<DB: crate::sql::sqlx::Database> From<&crate::extractors::Tenant<DB>> for LinkScope {
fn from(tenant: &crate::extractors::Tenant<DB>) -> Self {
Self::from(&tenant.org)
}
}
#[cfg(feature = "tenancy")]
impl From<&crate::extractors::TenantScope> for LinkScope {
fn from(tenant: &crate::extractors::TenantScope) -> Self {
Self::from(&tenant.org)
}
}
#[cfg(feature = "passwords")]
pub struct LinkTarget<'a> {
pool: &'a crate::sql::Pool,
scope: LinkScope,
}
#[cfg(feature = "passwords")]
impl<'a> LinkTarget<'a> {
#[must_use]
pub fn audience(pool: &'a crate::sql::Pool, audience: &str) -> Self {
Self {
pool,
scope: LinkScope::audience(audience),
}
}
}
#[cfg(all(feature = "passwords", feature = "tenancy", feature = "admin"))]
impl<'a> LinkTarget<'a> {
pub(crate) fn tenant(pool: &'a crate::sql::Pool, org: &crate::tenancy::Org) -> Self {
Self {
pool,
scope: LinkScope::from(org),
}
}
}
#[cfg(all(feature = "passwords", feature = "tenancy"))]
impl<'a, DB: crate::sql::sqlx::Database> From<&'a crate::extractors::Tenant<DB>>
for LinkTarget<'a>
{
fn from(tenant: &'a crate::extractors::Tenant<DB>) -> Self {
Self {
pool: tenant.pool(),
scope: LinkScope::from(tenant),
}
}
}
#[cfg(all(feature = "passwords", feature = "tenancy"))]
impl<'a> From<&'a crate::extractors::TenantScope> for LinkTarget<'a> {
fn from(tenant: &'a crate::extractors::TenantScope) -> Self {
Self {
pool: tenant.pool(),
scope: LinkScope::from(tenant),
}
}
}
fn check_link(
url: &str,
purpose: &str,
scope: &LinkScope,
secret: &[u8],
) -> Result<(), AuthFlowError> {
verify(url, secret)?;
let found = extract_query(url, "purpose").ok_or(AuthFlowError::Malformed)?;
if found != purpose {
return Err(AuthFlowError::WrongPurpose(found));
}
scope.check(url)
}
impl From<SignedUrlError> for AuthFlowError {
fn from(e: SignedUrlError) -> Self {
match e {
SignedUrlError::MissingSignature | SignedUrlError::MalformedSignature => {
Self::Malformed
}
SignedUrlError::InvalidSignature => Self::InvalidSignature,
SignedUrlError::Expired => Self::Expired,
}
}
}
pub struct PasswordReset;
impl PasswordReset {
const PURPOSE: &'static str = "pwreset";
#[must_use]
pub fn issue(
scope: &LinkScope,
base_url: &str,
user_id: i64,
secret: &[u8],
ttl: Duration,
) -> String {
let query = format!(
"user_id={}&purpose={}&{}={}",
user_id,
Self::PURPOSE,
Self::ISSUED_AT,
chrono::Utc::now().timestamp_micros(),
);
sign(&scope.link(base_url, &query), secret, Some(ttl))
}
const ISSUED_AT: &'static str = "iat";
#[cfg(feature = "passwords")]
pub(crate) fn verify_issued(
scope: &LinkScope,
url: &str,
secret: &[u8],
) -> Result<(i64, i64), AuthFlowError> {
let user_id = Self::verify(scope, url, secret)?;
let iat = extract_query(url, Self::ISSUED_AT)
.and_then(|s| s.parse::<i64>().ok())
.ok_or(AuthFlowError::Expired)?;
Ok((user_id, iat))
}
pub fn verify(scope: &LinkScope, url: &str, secret: &[u8]) -> Result<i64, AuthFlowError> {
check_link(url, Self::PURPOSE, scope, secret)?;
let user_id_str = extract_query(url, "user_id").ok_or(AuthFlowError::Malformed)?;
user_id_str
.parse::<i64>()
.map_err(|_| AuthFlowError::Malformed)
}
#[cfg(feature = "cache")]
pub async fn verify_single_use(
scope: &LinkScope,
url: &str,
secret: &[u8],
cache: &std::sync::Arc<dyn crate::cache::Cache>,
) -> Result<i64, AuthFlowError> {
let user_id = Self::verify(scope, url, secret)?;
consume_single_use(url, cache).await?;
Ok(user_id)
}
}
#[cfg(feature = "passwords")]
pub async fn confirm_password_reset_pool(
target: impl Into<LinkTarget<'_>>,
url: &str,
new_password: &str,
secret: &[u8],
) -> Result<i64, AuthFlowError> {
let LinkTarget { pool, scope } = target.into();
let (user_id, iat) = PasswordReset::verify_issued(&scope, url, secret)?;
check_password_strength(new_password)?;
write_password_hash(
pool,
user_id,
new_password,
"rustango_users",
"id",
"password_hash",
Some(Rotation::since(iat)),
)
.await
}
#[cfg(feature = "passwords")]
#[allow(clippy::too_many_arguments)]
pub async fn confirm_password_reset_pool_into(
target: impl Into<LinkTarget<'_>>,
url: &str,
new_password: &str,
secret: &[u8],
user_table: &str,
pk_column: &str,
password_column: &str,
) -> Result<i64, AuthFlowError> {
let LinkTarget { pool, scope } = target.into();
let user_id = PasswordReset::verify(&scope, url, secret)?;
check_password_strength(new_password)?;
write_password_hash(
pool,
user_id,
new_password,
user_table,
pk_column,
password_column,
None,
)
.await
}
#[cfg(all(feature = "passwords", feature = "cache"))]
pub async fn confirm_password_reset_single_use(
target: impl Into<LinkTarget<'_>>,
url: &str,
new_password: &str,
secret: &[u8],
cache: &std::sync::Arc<dyn crate::cache::Cache>,
) -> Result<i64, AuthFlowError> {
let LinkTarget { pool, scope } = target.into();
let (user_id, iat) = PasswordReset::verify_issued(&scope, url, secret)?;
check_password_strength(new_password)?;
consume_single_use(url, cache).await?;
write_password_hash(
pool,
user_id,
new_password,
"rustango_users",
"id",
"password_hash",
Some(Rotation::since(iat)),
)
.await
}
#[cfg(all(feature = "passwords", feature = "cache"))]
#[allow(clippy::too_many_arguments)]
pub async fn confirm_password_reset_single_use_into(
target: impl Into<LinkTarget<'_>>,
url: &str,
new_password: &str,
secret: &[u8],
cache: &std::sync::Arc<dyn crate::cache::Cache>,
user_table: &str,
pk_column: &str,
password_column: &str,
) -> Result<i64, AuthFlowError> {
let LinkTarget { pool, scope } = target.into();
let user_id = PasswordReset::verify(&scope, url, secret)?;
check_password_strength(new_password)?;
consume_single_use(url, cache).await?;
write_password_hash(
pool,
user_id,
new_password,
user_table,
pk_column,
password_column,
None,
)
.await
}
#[cfg(feature = "passwords")]
fn check_password_strength(new_password: &str) -> Result<(), AuthFlowError> {
crate::password_validators::check_builtin_form_password(new_password)
.map_err(|e| AuthFlowError::WeakPassword(e.message))
}
#[cfg(feature = "passwords")]
struct Rotation {
link_issued_at: i64,
}
#[cfg(feature = "passwords")]
impl Rotation {
const COLUMN: &'static str = "password_changed_at";
fn since(link_issued_at: i64) -> Self {
Self { link_issued_at }
}
fn issued(&self) -> Result<chrono::DateTime<chrono::Utc>, AuthFlowError> {
chrono::DateTime::from_timestamp_micros(self.link_issued_at).ok_or(AuthFlowError::Malformed)
}
}
#[cfg(feature = "passwords")]
async fn write_password_hash(
pool: &crate::sql::Pool,
user_id: i64,
new_password: &str,
user_table: &str,
pk_column: &str,
password_column: &str,
rotation: Option<Rotation>,
) -> Result<i64, AuthFlowError> {
let hash = crate::passwords::hash_async(new_password)
.await
.map_err(|e| AuthFlowError::Database(e.to_string()))?;
if let Some(rotation) = rotation {
let written = rotate_user_password(pool, user_id, hash, &rotation).await?;
return if written == 0 {
Err(AuthFlowError::Expired)
} else {
Ok(user_id)
};
}
let dialect = pool.dialect();
let sql = format!(
"UPDATE {} SET {} = {} WHERE {} = {}",
dialect.quote_ident(user_table),
dialect.quote_ident(password_column),
dialect.placeholder(1),
dialect.quote_ident(pk_column),
dialect.placeholder(2),
);
let args = vec![
crate::core::SqlValue::String(hash),
crate::core::SqlValue::I64(user_id),
];
crate::sql::raw_execute_pool(pool, &sql, args)
.await
.map_err(|e| AuthFlowError::Database(e.to_string()))?;
Ok(user_id)
}
#[cfg(feature = "passwords")]
static RESET_USERS: crate::core::ModelSchema = {
use crate::core::{FieldSchema, FieldType};
const FIELDS: &[FieldSchema] = &[
{
let mut f = FieldSchema::new("id", "id", FieldType::I64);
f.primary_key = true;
f
},
FieldSchema::new("password_hash", "password_hash", FieldType::String),
{
let mut f = FieldSchema::new(Rotation::COLUMN, Rotation::COLUMN, FieldType::DateTime);
f.nullable = true;
f
},
];
let mut s = crate::core::ModelSchema::new("User", "rustango_users");
s.fields = FIELDS;
s
};
#[cfg(feature = "passwords")]
async fn rotate_user_password(
pool: &crate::sql::Pool,
user_id: i64,
hash: String,
rotation: &Rotation,
) -> Result<u64, AuthFlowError> {
use crate::core::{Assignment, SqlValue, UpdateQuery};
use crate::query::Q;
let since = rotation.issued()?;
let query = UpdateQuery {
model: &RESET_USERS,
set: vec![
Assignment::new("password_hash", SqlValue::String(hash)),
Assignment::new(Rotation::COLUMN, SqlValue::DateTime(chrono::Utc::now())),
],
where_clause: (Q::eq("id", user_id)
& (Q::is_null(Rotation::COLUMN) | Q::lt(Rotation::COLUMN, since)))
.into(),
};
crate::sql::update_pool(pool, &query)
.await
.map_err(|e| AuthFlowError::Database(e.to_string()))
}
pub struct EmailVerification;
impl EmailVerification {
const PURPOSE: &'static str = "verify_email";
#[must_use]
pub fn issue(
scope: &LinkScope,
base_url: &str,
user_id: i64,
email: &str,
secret: &[u8],
ttl: Duration,
) -> String {
let query = format!(
"user_id={}&email={}&purpose={}",
user_id,
url_encode(email),
Self::PURPOSE,
);
sign(&scope.link(base_url, &query), secret, Some(ttl))
}
pub fn verify(
scope: &LinkScope,
url: &str,
secret: &[u8],
) -> Result<(i64, String), AuthFlowError> {
check_link(url, Self::PURPOSE, scope, secret)?;
let user_id_str = extract_query(url, "user_id").ok_or(AuthFlowError::Malformed)?;
let user_id = user_id_str
.parse::<i64>()
.map_err(|_| AuthFlowError::Malformed)?;
let email = extract_query(url, "email").ok_or(AuthFlowError::Malformed)?;
Ok((user_id, email))
}
#[cfg(feature = "cache")]
pub async fn verify_single_use(
scope: &LinkScope,
url: &str,
secret: &[u8],
cache: &std::sync::Arc<dyn crate::cache::Cache>,
) -> Result<(i64, String), AuthFlowError> {
let out = Self::verify(scope, url, secret)?;
consume_single_use(url, cache).await?;
Ok(out)
}
}
pub struct MagicLink;
impl MagicLink {
const PURPOSE: &'static str = "magic_link";
#[must_use]
pub fn issue(
scope: &LinkScope,
base_url: &str,
email: &str,
secret: &[u8],
ttl: Duration,
) -> String {
let query = format!("email={}&purpose={}", url_encode(email), Self::PURPOSE);
sign(&scope.link(base_url, &query), secret, Some(ttl))
}
pub fn verify(scope: &LinkScope, url: &str, secret: &[u8]) -> Result<String, AuthFlowError> {
check_link(url, Self::PURPOSE, scope, secret)?;
extract_query(url, "email").ok_or(AuthFlowError::Malformed)
}
#[cfg(feature = "cache")]
pub async fn verify_single_use(
scope: &LinkScope,
url: &str,
secret: &[u8],
cache: &std::sync::Arc<dyn crate::cache::Cache>,
) -> Result<String, AuthFlowError> {
let email = Self::verify(scope, url, secret)?;
consume_single_use(url, cache).await?;
Ok(email)
}
}
fn extract_query(url: &str, key: &str) -> Option<String> {
let url = url.split_once('#').map_or(url, |(u, _)| u);
let query = url.split_once('?')?.1;
for pair in query.split('&') {
let Some((k, v)) = pair.split_once('=') else {
continue;
};
let k = url_decode(k);
if k == key {
return Some(url_decode(v));
}
}
None
}
#[cfg(feature = "cache")]
async fn consume_single_use(
url: &str,
cache: &std::sync::Arc<dyn crate::cache::Cache>,
) -> Result<(), AuthFlowError> {
let sig = extract_query(url, "signature").ok_or(AuthFlowError::Malformed)?;
if cache.stores_nothing() {
tracing::error!(
target: "rustango::auth_flows",
"single-use token refused: the cache keeps nothing (`NullCache`); use a shared cache"
);
return Err(AuthFlowError::AlreadyUsed);
}
let key = format!("authflow_used:{sig}");
match cache.add(&key, "1", Some(single_use_ttl(url))).await {
Ok(true) => Ok(()),
Ok(false) => Err(AuthFlowError::AlreadyUsed),
Err(e) => {
tracing::error!(target: "rustango::auth_flows", error = %e, "single-use token refused: cache failed");
Err(AuthFlowError::AlreadyUsed)
}
}
}
#[cfg(feature = "cache")]
fn single_use_ttl(url: &str) -> Duration {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
extract_query(url, "expires")
.and_then(|s| s.parse::<u64>().ok())
.map(|exp| Duration::from_secs(exp.saturating_sub(now)))
.filter(|d| !d.is_zero())
.unwrap_or(Duration::from_secs(3600))
}
use crate::url_codec::{url_decode, url_encode};
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &[u8] = b"my-test-secret";
fn scope() -> LinkScope {
LinkScope::audience("acme")
}
#[cfg(all(feature = "passwords", feature = "tenancy"))]
#[test]
fn reset_users_schema_matches_the_user_model() {
use crate::core::Model as _;
let user = crate::tenancy::User::SCHEMA;
assert_eq!(RESET_USERS.table, user.table);
for f in RESET_USERS.fields {
let u = user.field_by_column(f.column).expect(f.column);
assert_eq!((f.ty, f.nullable), (u.ty, u.nullable), "{}", f.column);
}
}
#[test]
fn password_reset_issue_and_verify_roundtrip() {
let url = PasswordReset::issue(
&scope(),
"https://app.example.com/reset",
42,
SECRET,
Duration::from_secs(3600),
);
let user_id = PasswordReset::verify(&scope(), &url, SECRET).unwrap();
assert_eq!(user_id, 42);
}
#[test]
fn links_keep_the_base_query_and_fragment() {
let ttl = Duration::from_secs(600);
for base in [
"https://app.example.com/reset?lang=en",
"https://app.example.com/reset?next=/x&",
"https://app.example.com/reset#/spa",
"https://app.example.com/reset?lang=en#/spa?x=1",
] {
let reset = PasswordReset::issue(&scope(), base, 42, SECRET, ttl);
let verify = EmailVerification::issue(&scope(), base, 42, "a@b.c", SECRET, ttl);
let magic = MagicLink::issue(&scope(), base, "a@b.c", SECRET, ttl);
for url in [&reset, &verify, &magic] {
let sent = url.split_once('#').map_or(url.as_str(), |(u, _)| u);
if let Some((_, frag)) = base.split_once('#') {
assert!(url.ends_with(&format!("#{frag}")), "{url}");
}
if base.contains("lang=en") {
assert_eq!(extract_query(sent, "lang").as_deref(), Some("en"), "{url}");
}
assert_eq!(
PasswordReset::verify(&scope(), sent, SECRET).is_ok(),
url == &reset
);
}
assert_eq!(
PasswordReset::verify(&scope(), &reset, SECRET),
Ok(42),
"{reset}"
);
assert_eq!(
EmailVerification::verify(&scope(), &verify, SECRET),
Ok((42, "a@b.c".to_owned())),
"{verify}"
);
let sent = magic.split_once('#').map_or(magic.as_str(), |(u, _)| u);
assert_eq!(
MagicLink::verify(&scope(), sent, SECRET).as_deref(),
Ok("a@b.c")
);
}
}
#[test]
fn links_are_refused_in_another_scope() {
let other = LinkScope::audience("globex");
let ttl = Duration::from_secs(600);
let reset = PasswordReset::issue(&scope(), "https://x/r", 1, SECRET, ttl);
let verify = EmailVerification::issue(&scope(), "https://x/v", 1, "a@x.com", SECRET, ttl);
let magic = MagicLink::issue(&scope(), "https://x/l", "a@x.com", SECRET, ttl);
assert_eq!(
PasswordReset::verify(&other, &reset, SECRET),
Err(AuthFlowError::WrongScope)
);
assert_eq!(
EmailVerification::verify(&other, &verify, SECRET),
Err(AuthFlowError::WrongScope)
);
assert_eq!(
MagicLink::verify(&other, &magic, SECRET),
Err(AuthFlowError::WrongScope)
);
}
#[test]
fn a_rewritten_scope_fails_the_signature() {
let url =
PasswordReset::issue(&scope(), "https://x/r", 1, SECRET, Duration::from_secs(600));
let forged = url.replace("aud%3Aacme", "aud%3Aglobex");
assert_ne!(forged, url);
let r = PasswordReset::verify(&LinkScope::audience("globex"), &forged, SECRET);
assert_eq!(r, Err(AuthFlowError::InvalidSignature));
}
#[cfg(feature = "tenancy")]
fn org(slug: &str, id: i64) -> crate::tenancy::Org {
crate::tenancy::Org {
id: crate::sql::Auto::Set(id),
slug: slug.into(),
..crate::testkit::org()
}
}
#[cfg(all(feature = "tenancy", feature = "testkit"))]
#[test]
fn a_tenant_scope_is_its_org_not_its_slug() {
let old = LinkScope::from(&org("acme", 1));
let url = PasswordReset::issue(&old, "https://x/r", 1, SECRET, Duration::from_secs(600));
for other in [
LinkScope::from(&org("acme", 2)),
LinkScope::audience("acme"),
] {
assert_eq!(
PasswordReset::verify(&other, &url, SECRET),
Err(AuthFlowError::WrongScope)
);
}
assert_eq!(PasswordReset::verify(&old, &url, SECRET), Ok(1));
}
#[cfg(all(feature = "tenancy", feature = "sqlite", feature = "testkit"))]
#[tokio::test]
async fn confirm_through_a_tenant_checks_its_scope() {
use crate::sql::sqlx;
let sq = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
let pool = crate::sql::Pool::from(sq.clone());
crate::testkit::create_tables_for::<crate::tenancy::User>(&pool)
.await
.unwrap();
let mut u = crate::tenancy::User {
username: "ada".into(),
password_hash: "OLD".into(),
..crate::testkit::user()
};
u.insert_pool(&pool).await.unwrap();
let id = *u.id.get().unwrap();
let tenant = crate::extractors::Tenant::for_test_sqlite(org("b", 2), sq);
let ttl = Duration::from_secs(600);
let strong = "brand-new-strong-password-7!";
let foreign = PasswordReset::issue(
&LinkScope::from(&org("x", 1)),
"https://x/r",
id,
SECRET,
ttl,
);
let r = confirm_password_reset_pool(&tenant, &foreign, strong, SECRET).await;
assert_eq!(r, Err(AuthFlowError::WrongScope));
let own = PasswordReset::issue(&LinkScope::from(&tenant), "https://b/r", id, SECRET, ttl);
assert_eq!(
confirm_password_reset_pool(&tenant, &own, strong, SECRET).await,
Ok(id)
);
}
#[cfg(all(feature = "tenancy", feature = "sqlite", feature = "testkit"))]
#[tokio::test]
async fn confirm_through_a_tenant_scope_checks_its_scope() {
use crate::sql::sqlx;
let sq = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
let pool = crate::sql::Pool::from(sq.clone());
crate::testkit::create_tables_for::<crate::tenancy::User>(&pool)
.await
.unwrap();
let mut u = crate::tenancy::User {
username: "ada".into(),
password_hash: "OLD".into(),
..crate::testkit::user()
};
u.insert_pool(&pool).await.unwrap();
let id = *u.id.get().unwrap();
let scope = crate::extractors::TenantScope::for_test(org("b", 2), pool);
let ttl = Duration::from_secs(600);
let strong = "brand-new-strong-password-7!";
let audience =
PasswordReset::issue(&LinkScope::audience("app"), "https://b/r", id, SECRET, ttl);
let r = confirm_password_reset_pool(&scope, &audience, strong, SECRET).await;
assert_eq!(r, Err(AuthFlowError::WrongScope));
let own = PasswordReset::issue(&LinkScope::from(&scope), "https://b/r", id, SECRET, ttl);
assert_eq!(
confirm_password_reset_pool(&scope, &own, strong, SECRET).await,
Ok(id)
);
}
#[test]
fn an_unscoped_link_is_refused() {
let url = sign(
"https://x/r?user_id=1&purpose=pwreset&iat=1",
SECRET,
Some(Duration::from_secs(60)),
);
let r = PasswordReset::verify(&scope(), &url, SECRET);
assert_eq!(r, Err(AuthFlowError::WrongScope));
}
#[test]
fn password_reset_wrong_secret_fails() {
let url = PasswordReset::issue(
&scope(),
"https://x/r",
42,
SECRET,
Duration::from_secs(3600),
);
let r = PasswordReset::verify(&scope(), &url, b"different");
assert_eq!(r.unwrap_err(), AuthFlowError::InvalidSignature);
}
#[test]
fn password_reset_tampered_user_id_fails() {
let url = PasswordReset::issue(
&scope(),
"https://x/r",
42,
SECRET,
Duration::from_secs(3600),
);
let tampered = url.replace("user_id=42", "user_id=99");
let r = PasswordReset::verify(&scope(), &tampered, SECRET);
assert_eq!(r.unwrap_err(), AuthFlowError::InvalidSignature);
}
#[test]
fn password_reset_rejects_email_verification_token() {
let url = EmailVerification::issue(
&scope(),
"https://x/r",
42,
"alice@x.com",
SECRET,
Duration::from_secs(3600),
);
let r = PasswordReset::verify(&scope(), &url, SECRET);
assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
}
#[test]
fn email_verification_roundtrip() {
let url = EmailVerification::issue(
&scope(),
"https://x/v",
42,
"alice@example.com",
SECRET,
Duration::from_secs(86_400),
);
let (uid, email) = EmailVerification::verify(&scope(), &url, SECRET).unwrap();
assert_eq!(uid, 42);
assert_eq!(email, "alice@example.com");
}
#[test]
fn email_verification_handles_special_chars() {
let url = EmailVerification::issue(
&scope(),
"https://x/v",
42,
"a+b@example.com",
SECRET,
Duration::from_secs(86_400),
);
let (_, email) = EmailVerification::verify(&scope(), &url, SECRET).unwrap();
assert_eq!(email, "a+b@example.com");
}
#[test]
fn email_verification_rejects_password_reset_token() {
let url = PasswordReset::issue(
&scope(),
"https://x/v",
42,
SECRET,
Duration::from_secs(3600),
);
let r = EmailVerification::verify(&scope(), &url, SECRET);
assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
}
#[test]
fn magic_link_roundtrip() {
let url = MagicLink::issue(
&scope(),
"https://x/login",
"alice@example.com",
SECRET,
Duration::from_secs(900),
);
let email = MagicLink::verify(&scope(), &url, SECRET).unwrap();
assert_eq!(email, "alice@example.com");
}
#[test]
fn magic_link_rejects_password_reset_token() {
let url = PasswordReset::issue(
&scope(),
"https://x/r",
42,
SECRET,
Duration::from_secs(3600),
);
let r = MagicLink::verify(&scope(), &url, SECRET);
assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
}
#[cfg(feature = "cache")]
#[tokio::test]
async fn magic_link_single_use_rejects_replay() {
use crate::cache::InMemoryCache;
let cache: std::sync::Arc<dyn crate::cache::Cache> =
std::sync::Arc::new(InMemoryCache::new());
let url = MagicLink::issue(
&scope(),
"https://x/login",
"alice@example.com",
SECRET,
Duration::from_secs(900),
);
let email = MagicLink::verify_single_use(&scope(), &url, SECRET, &cache)
.await
.unwrap();
assert_eq!(email, "alice@example.com");
let replay = MagicLink::verify_single_use(&scope(), &url, SECRET, &cache).await;
assert!(
matches!(replay, Err(AuthFlowError::AlreadyUsed)),
"{replay:?}"
);
let other = MagicLink::issue(
&scope(),
"https://x/login",
"bob@example.com",
SECRET,
Duration::from_secs(900),
);
assert!(
MagicLink::verify_single_use(&scope(), &other, SECRET, &cache)
.await
.is_ok()
);
}
#[cfg(feature = "cache")]
fn link() -> String {
MagicLink::issue(
&scope(),
"https://x/login",
"alice@example.com",
SECRET,
Duration::from_secs(900),
)
}
#[cfg(feature = "cache")]
struct Racy(crate::cache::InMemoryCache, tokio::sync::Barrier);
#[cfg(feature = "cache")]
#[async_trait::async_trait]
impl crate::cache::Cache for Racy {
async fn get(&self, k: &str) -> Result<Option<String>, crate::cache::CacheError> {
self.0.get(k).await
}
async fn set(
&self,
k: &str,
v: &str,
t: Option<Duration>,
) -> Result<(), crate::cache::CacheError> {
self.0.set(k, v, t).await
}
async fn delete(&self, k: &str) -> Result<(), crate::cache::CacheError> {
self.0.delete(k).await
}
async fn exists(&self, k: &str) -> Result<bool, crate::cache::CacheError> {
let seen = self.0.exists(k).await;
self.1.wait().await;
seen
}
async fn clear(&self) -> Result<(), crate::cache::CacheError> {
self.0.clear().await
}
async fn add(
&self,
k: &str,
v: &str,
t: Option<Duration>,
) -> Result<bool, crate::cache::CacheError> {
self.0.add(k, v, t).await
}
}
#[cfg(feature = "cache")]
#[tokio::test]
async fn concurrent_redemptions_of_one_link_yield_one() {
let cache: std::sync::Arc<dyn crate::cache::Cache> = std::sync::Arc::new(Racy(
crate::cache::InMemoryCache::new(),
tokio::sync::Barrier::new(2),
));
let (url, scope) = (link(), scope());
let (a, b) = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(
MagicLink::verify_single_use(&scope, &url, SECRET, &cache),
MagicLink::verify_single_use(&scope, &url, SECRET, &cache),
)
})
.await
.expect("no deadlock");
assert_eq!(u8::from(a.is_ok()) + u8::from(b.is_ok()), 1, "{a:?} {b:?}");
}
#[cfg(feature = "cache")]
#[tokio::test]
async fn a_null_cache_refuses_single_use() {
let cache: std::sync::Arc<dyn crate::cache::Cache> =
std::sync::Arc::new(crate::cache::NullCache);
let r = MagicLink::verify_single_use(&scope(), &link(), SECRET, &cache).await;
assert_eq!(r, Err(AuthFlowError::AlreadyUsed));
}
#[cfg(feature = "cache")]
struct WriteFails;
#[cfg(feature = "cache")]
#[async_trait::async_trait]
impl crate::cache::Cache for WriteFails {
async fn get(&self, _: &str) -> Result<Option<String>, crate::cache::CacheError> {
Ok(None)
}
async fn set(
&self,
_: &str,
_: &str,
_: Option<Duration>,
) -> Result<(), crate::cache::CacheError> {
Err(crate::cache::CacheError::Connection("down".into()))
}
async fn delete(&self, _: &str) -> Result<(), crate::cache::CacheError> {
Ok(())
}
async fn exists(&self, _: &str) -> Result<bool, crate::cache::CacheError> {
Ok(false)
}
async fn clear(&self) -> Result<(), crate::cache::CacheError> {
Ok(())
}
}
#[cfg(feature = "cache")]
#[tokio::test]
async fn a_failed_marker_write_refuses() {
let cache: std::sync::Arc<dyn crate::cache::Cache> = std::sync::Arc::new(WriteFails);
let r = MagicLink::verify_single_use(&scope(), &link(), SECRET, &cache).await;
assert_eq!(r, Err(AuthFlowError::AlreadyUsed));
}
#[test]
fn extract_query_picks_right_param() {
let url = "https://x/path?a=1&b=2&c=3";
assert_eq!(extract_query(url, "b"), Some("2".to_owned()));
assert_eq!(extract_query(url, "missing"), None);
}
#[test]
fn extract_query_handles_url_encoded_value() {
let url = "https://x/path?email=alice%40x.com";
assert_eq!(extract_query(url, "email"), Some("alice@x.com".to_owned()));
}
#[test]
fn missing_purpose_param_treated_as_malformed() {
let url = sign(
"https://x/r?user_id=42",
SECRET,
Some(Duration::from_secs(60)),
);
let r = PasswordReset::verify(&scope(), &url, SECRET);
assert_eq!(r.unwrap_err(), AuthFlowError::Malformed);
}
}