affine_core 0.0.4

AFFiNE primitive core.
Documentation
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;