use chrono::{DateTime, Duration, Utc};
pub const ACCESS_TOKEN_TYPE: &str = "session_access";
pub const ACCESS_TOKEN_ISSUER: &str = "affine";
pub const ACCESS_TOKEN_AUDIENCE: &str = "affine-client";
pub const REFRESH_TOKEN_PREFIX: &str = "aff_rt_v1";
pub const CLOCK_TOLERANCE_SECONDS: i64 = 30;
pub const MAGIC_LINK_TTL_SECONDS: i64 = 30 * 60;
pub const OPEN_APP_TTL_SECONDS: i64 = 5 * 60;
pub const SECURITY_CHALLENGE_TTL_SECONDS: i64 = 30 * 60;
pub const OAUTH_STATE_TTL_SECONDS: i64 = 3 * 60 * 60;
pub const MAX_OTP_ATTEMPTS: i32 = 10;
pub const CAPTCHA_CHALLENGE_TTL_SECONDS: i64 = 5 * 60;
pub const SESSION_EXCHANGE_TTL_SECONDS: i32 = 60;
pub const AUTH_REFRESH_LIMIT: i32 = 30;
pub const AUTH_REFRESH_WINDOW_SECONDS: i32 = 60;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuthChallengePurpose {
MagicLinkOtp,
OpenAppSignIn,
AuthSessionExchange,
ChangePassword,
ChangeEmail,
VerifyChangeEmail,
VerifyEmail,
OAuthState,
Captcha,
}
impl AuthChallengePurpose {
pub const fn as_str(self) -> &'static str {
match self {
Self::MagicLinkOtp => "magic_link_otp",
Self::OpenAppSignIn => "auth_challenge:open_app_sign_in",
Self::AuthSessionExchange => "auth_challenge:auth_session_exchange",
Self::ChangePassword => "auth_challenge:change_password",
Self::ChangeEmail => "auth_challenge:change_email",
Self::VerifyChangeEmail => "auth_challenge:verify_change_email",
Self::VerifyEmail => "auth_challenge:verify_email",
Self::OAuthState => "auth_challenge:oauth_state",
Self::Captcha => "auth_challenge:captcha",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpAttemptDecision {
Accept,
RejectNonce,
RejectExpired,
RejectProof { next_attempts: i32, consume: bool },
}
pub fn challenge_identity_matches(
stored_user_id: Option<&str>,
expected_user_id: &str,
stored_auth_epoch: Option<i64>,
expected_auth_epoch: i32,
) -> bool {
stored_user_id == Some(expected_user_id) && stored_auth_epoch == Some(i64::from(expected_auth_epoch))
}
pub fn challenge_active(consumed_at: Option<DateTime<Utc>>, expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
consumed_at.is_none() && expires_at > now
}
pub fn otp_attempt_decision(
now: DateTime<Utc>,
expires_at: DateTime<Utc>,
attempts: i32,
stored_nonce: Option<&str>,
supplied_nonce: Option<&str>,
proof_matches: bool,
) -> OtpAttemptDecision {
if expires_at <= now || attempts >= MAX_OTP_ATTEMPTS {
OtpAttemptDecision::RejectExpired
} else if stored_nonce.is_some_and(|stored| Some(stored) != supplied_nonce) {
OtpAttemptDecision::RejectNonce
} else if proof_matches {
OtpAttemptDecision::Accept
} else {
let next_attempts = attempts.saturating_add(1);
OtpAttemptDecision::RejectProof {
next_attempts,
consume: next_attempts >= MAX_OTP_ATTEMPTS,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SessionDeadlines {
pub access_expires_at: DateTime<Utc>,
pub refresh_idle_expires_at: DateTime<Utc>,
pub refresh_absolute_expires_at: DateTime<Utc>,
}
pub fn session_deadlines(
now: DateTime<Utc>,
access_token_ttl_seconds: i64,
refresh_idle_ttl_seconds: i64,
refresh_absolute_ttl_seconds: i64,
) -> SessionDeadlines {
let refresh_absolute_expires_at = now + Duration::seconds(refresh_absolute_ttl_seconds);
SessionDeadlines {
access_expires_at: now + Duration::seconds(access_token_ttl_seconds),
refresh_idle_expires_at: (now + Duration::seconds(refresh_idle_ttl_seconds)).min(refresh_absolute_expires_at),
refresh_absolute_expires_at,
}
}
pub fn refreshed_idle_deadline(
now: DateTime<Utc>,
refresh_idle_ttl_seconds: i64,
absolute_expires_at: DateTime<Utc>,
) -> DateTime<Utc> {
(now + Duration::seconds(refresh_idle_ttl_seconds)).min(absolute_expires_at)
}
pub fn access_token_deadline(now: DateTime<Utc>, ttl_seconds: i64) -> DateTime<Utc> {
now + Duration::seconds(ttl_seconds)
}
pub fn cookie_session_deadline(now: DateTime<Utc>, ttl_seconds: i64) -> DateTime<Utc> {
now + Duration::seconds(ttl_seconds)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SigningKeyState {
Active,
Retiring,
}
pub const fn signing_key_minimum_verify_seconds(access_token_ttl_seconds: i64) -> i64 {
access_token_ttl_seconds + CLOCK_TOLERANCE_SECONDS
}
pub fn signing_key_verify_until(now: DateTime<Utc>, access_token_ttl_seconds: i64) -> DateTime<Utc> {
now + Duration::seconds(signing_key_minimum_verify_seconds(access_token_ttl_seconds))
}
pub fn signing_key_can_verify(state: SigningKeyState, verify_until: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
state == SigningKeyState::Active || verify_until.is_some_and(|until| until >= now)
}
pub fn signing_key_can_delete(state: SigningKeyState, verify_until: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
state == SigningKeyState::Retiring && verify_until.is_some_and(|until| until < now)
}
pub fn signing_key_window_valid(
state: SigningKeyState,
retired_at: Option<DateTime<Utc>>,
verify_until: Option<DateTime<Utc>>,
minimum_verify_seconds: i64,
) -> bool {
match state {
SigningKeyState::Active => retired_at.is_none() && verify_until.is_none(),
SigningKeyState::Retiring => retired_at
.zip(verify_until)
.is_some_and(|(retired, verify)| verify - retired >= Duration::seconds(minimum_verify_seconds)),
}
}
pub const fn turnstile_allowed(success: bool, action_matches: bool, hostname_allowed: bool, dev: bool) -> bool {
success && action_matches && (dev || hostname_allowed)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LoginMethodFacts {
pub identity_count: usize,
pub registered: bool,
pub disabled: bool,
pub has_password: bool,
pub allow_signup: bool,
pub allow_signup_for_oauth: bool,
pub email_domain_allowed: bool,
pub oauth_available: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LoginMethodDecision {
pub registered: bool,
pub password: bool,
pub magic_link: bool,
pub oauth: bool,
}
pub const fn login_methods(facts: LoginMethodFacts) -> LoginMethodDecision {
let unique = facts.identity_count <= 1;
let existing = facts.identity_count == 1;
let enabled = unique && existing && !facts.disabled;
LoginMethodDecision {
registered: enabled && facts.registered,
password: enabled && facts.has_password,
magic_link: if existing {
enabled
} else {
unique && facts.allow_signup && facts.email_domain_allowed
},
oauth: facts.oauth_available
&& if existing {
enabled
} else {
unique && facts.allow_signup_for_oauth
},
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionState {
Active,
Expired,
Revoked,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RefreshState {
Rotate,
Grace,
Replay,
Unavailable,
}
pub struct SessionFacts {
pub now: DateTime<Utc>,
pub user_disabled: bool,
pub session_revoked: bool,
pub token_revoked: bool,
pub token_expires_at: DateTime<Utc>,
pub idle_expires_at: DateTime<Utc>,
pub absolute_expires_at: DateTime<Utc>,
pub user_session_expires_at: Option<DateTime<Utc>>,
}
pub fn session_state(facts: &SessionFacts) -> SessionState {
if facts.user_disabled || facts.session_revoked || facts.token_revoked {
SessionState::Revoked
} else if facts.token_expires_at <= facts.now
|| facts.idle_expires_at <= facts.now
|| facts.absolute_expires_at <= facts.now
|| facts
.user_session_expires_at
.is_some_and(|expires_at| expires_at <= facts.now)
{
SessionState::Expired
} else {
SessionState::Active
}
}
pub fn refresh_state(
used_at: Option<DateTime<Utc>>,
grace_used_at: Option<DateTime<Utc>>,
has_replacement: bool,
successor_available: bool,
replacement_unused: bool,
now: DateTime<Utc>,
grace: Duration,
) -> RefreshState {
let Some(used_at) = used_at else {
return RefreshState::Rotate;
};
if grace_used_at.is_some() || !has_replacement || now - used_at > grace || !replacement_unused {
RefreshState::Replay
} else if successor_available {
RefreshState::Grace
} else {
RefreshState::Unavailable
}
}
#[cfg(test)]
#[path = "tests/auth/tests.rs"]
mod tests;