use crate::error::ConfigReason;
use time::Duration;
use url::Url;
use crate::Error;
use crate::account::{Email, LockoutPolicy, PasswordOnly};
use crate::flow::email_link::{DEFAULT_CODE_ATTEMPTS, DEFAULT_CODE_DIGITS};
use crate::flow::invite::DEFAULT_MAX_NAME_LEN;
use crate::flow::step_up::StepUpFailures;
use crate::http::CookieName;
use crate::limits::RateLimits;
use crate::password::{DEFAULT_MAX_LEN, DEFAULT_MIN_LEN, PasswordLength, PasswordParams};
use crate::recovery::DEFAULT_COUNT;
use crate::totp::{DEFAULT_DIGITS, DEFAULT_SKEW, DEFAULT_STEP_SECS, TotpParams};
pub(crate) const DEFAULT_PASSKEY_CEREMONY_TTL: Duration = Duration::minutes(5);
const EMAIL_CODE_DIGITS: std::ops::RangeInclusive<u8> = 6..=10;
const EMAIL_CODE_ATTEMPTS: std::ops::RangeInclusive<u32> = 1..=20;
const RECOVERY_CODE_COUNT: std::ops::RangeInclusive<usize> = 4..=32;
const DISPLAY_NAME_MAX_LEN: std::ops::RangeInclusive<usize> = 1..=1000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
origin: Url,
rp_id: String,
rp_name: String,
owner_email: Email,
owner_name: String,
cookie_name: CookieName,
device_cookie_name: CookieName,
ceremony_cookie_name: CookieName,
email_cookie_name: CookieName,
setup_url: Url,
sign_in_link_url: Url,
invite_url: Url,
sessions_url: Url,
durations: Durations,
lockout: LockoutPolicy,
step_up_failures: StepUpFailures,
password_only: PasswordOnly,
rate_limits: RateLimits,
password: PasswordParams,
password_length: PasswordLength,
totp: TotpParams,
email_code_digits: u8,
email_code_attempts: u32,
recovery_code_count: usize,
display_name_max_len: usize,
insecure_localhost_cookies: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Durations {
device_ttl: Duration,
session_ttl: Duration,
pending_ttl: Duration,
setup_ttl: Duration,
link_ttl: Duration,
setup_link_ttl: Duration,
invite_ttl: Duration,
resend_cooldown: Duration,
step_up_window: Duration,
passkey_ceremony_ttl: Duration,
}
impl Default for Durations {
fn default() -> Self {
Self {
device_ttl: Duration::days(400),
session_ttl: Duration::days(30),
pending_ttl: Duration::minutes(10),
setup_ttl: Duration::hours(1),
link_ttl: Duration::minutes(5),
setup_link_ttl: Duration::hours(1),
invite_ttl: Duration::hours(24),
resend_cooldown: Duration::minutes(1),
step_up_window: Duration::minutes(5),
passkey_ceremony_ttl: DEFAULT_PASSKEY_CEREMONY_TTL,
}
}
}
#[derive(Debug, Clone)]
#[must_use = "call build() to get a Config"]
pub struct ConfigBuilder {
owner_email: String,
origin: String,
rp_id: Option<String>,
rp_name: Option<String>,
owner_name: Option<String>,
cookie_name: String,
device_cookie_name: String,
ceremony_cookie_name: String,
email_cookie_name: String,
setup_path: String,
sign_in_link_path: String,
invite_path: String,
sessions_path: String,
durations: Durations,
lockout: LockoutPolicy,
step_up_failures: StepUpFailures,
password_only: PasswordOnly,
rate_limits: RateLimits,
password: PasswordParams,
password_min_len: usize,
password_max_len: usize,
totp_digits: u8,
totp_step: u64,
totp_skew: u8,
email_code_digits: u8,
email_code_attempts: u32,
recovery_code_count: usize,
display_name_max_len: usize,
insecure_localhost_cookies: bool,
}
macro_rules! durations {
($($(#[$doc:meta])* $name:ident),* $(,)?) => {
impl ConfigBuilder {
$(
$(#[$doc])*
pub fn $name(mut self, value: Duration) -> Self {
self.durations.$name = value;
self
}
)*
}
impl Config {
$(
$(#[$doc])*
#[must_use]
pub fn $name(&self) -> Duration {
self.durations.$name
}
)*
}
};
}
durations!(
device_ttl,
session_ttl,
pending_ttl,
setup_ttl,
link_ttl,
setup_link_ttl,
invite_ttl,
resend_cooldown,
step_up_window,
passkey_ceremony_ttl,
);
impl ConfigBuilder {
pub fn rp_id(mut self, rp_id: &str) -> Self {
self.rp_id = Some(rp_id.to_owned());
self
}
pub fn rp_name(mut self, rp_name: &str) -> Self {
self.rp_name = Some(rp_name.to_owned());
self
}
pub fn owner_name(mut self, name: &str) -> Self {
self.owner_name = Some(name.to_owned());
self
}
pub fn cookie_name(mut self, name: &str) -> Self {
name.clone_into(&mut self.cookie_name);
self
}
pub fn device_cookie_name(mut self, name: &str) -> Self {
name.clone_into(&mut self.device_cookie_name);
self
}
pub fn ceremony_cookie_name(mut self, name: &str) -> Self {
name.clone_into(&mut self.ceremony_cookie_name);
self
}
pub fn email_cookie_name(mut self, name: &str) -> Self {
name.clone_into(&mut self.email_cookie_name);
self
}
pub fn setup_path(mut self, path: &str) -> Self {
path.clone_into(&mut self.setup_path);
self
}
pub fn sign_in_link_path(mut self, path: &str) -> Self {
path.clone_into(&mut self.sign_in_link_path);
self
}
pub fn invite_path(mut self, path: &str) -> Self {
path.clone_into(&mut self.invite_path);
self
}
pub fn sessions_path(mut self, path: &str) -> Self {
path.clone_into(&mut self.sessions_path);
self
}
pub fn lockout(mut self, lockout: LockoutPolicy) -> Self {
self.lockout = lockout;
self
}
pub fn step_up_failures(mut self, policy: StepUpFailures) -> Self {
self.step_up_failures = policy;
self
}
pub fn password_only(mut self, policy: PasswordOnly) -> Self {
self.password_only = policy;
self
}
pub fn danger_insecure_localhost_cookies(mut self) -> Self {
self.insecure_localhost_cookies = true;
self
}
pub fn rate_limits(mut self, rate_limits: RateLimits) -> Self {
self.rate_limits = rate_limits;
self
}
pub fn password_params(mut self, params: PasswordParams) -> Self {
self.password = params;
self
}
pub fn password_min_len(mut self, chars: usize) -> Self {
self.password_min_len = chars;
self
}
pub fn password_max_len(mut self, chars: usize) -> Self {
self.password_max_len = chars;
self
}
pub fn totp_digits(mut self, digits: u8) -> Self {
self.totp_digits = digits;
self
}
pub fn totp_step(mut self, seconds: u64) -> Self {
self.totp_step = seconds;
self
}
pub fn totp_skew(mut self, steps: u8) -> Self {
self.totp_skew = steps;
self
}
pub fn email_code_digits(mut self, digits: u8) -> Self {
self.email_code_digits = digits;
self
}
pub fn email_code_attempts(mut self, attempts: u32) -> Self {
self.email_code_attempts = attempts;
self
}
pub fn recovery_code_count(mut self, count: usize) -> Self {
self.recovery_code_count = count;
self
}
pub fn display_name_max_len(mut self, chars: usize) -> Self {
self.display_name_max_len = chars;
self
}
pub fn build(self) -> Result<Config, Error> {
let origin =
Url::parse(&self.origin).map_err(|_| Error::Config(ConfigReason::OriginNotUrl))?;
let host = origin
.host_str()
.ok_or(Error::Config(ConfigReason::OriginHasNoHost))?
.to_owned();
let local = matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]");
let plain_local = origin.scheme() == "http" && local;
if origin.scheme() != "https" && !plain_local {
return Err(Error::Config(ConfigReason::OriginNotHttps));
}
if self.insecure_localhost_cookies && !plain_local {
return Err(Error::Config(ConfigReason::InsecureCookiesNotLocalhost));
}
let owner_email =
Email::parse(&self.owner_email).map_err(|_| Error::Config(ConfigReason::OwnerEmail))?;
let cookie = |name: &str| {
if self.insecure_localhost_cookies {
CookieName::insecure_localhost(name)
} else {
CookieName::new(name)
}
};
let cookie_name = cookie(&self.cookie_name)?;
let device_cookie_name = cookie(&self.device_cookie_name)?;
let ceremony_cookie_name = cookie(&self.ceremony_cookie_name)?;
let email_cookie_name = cookie(&self.email_cookie_name)?;
let names = [
&cookie_name,
&device_cookie_name,
&ceremony_cookie_name,
&email_cookie_name,
];
let distinct = names
.iter()
.enumerate()
.all(|(i, a)| names.iter().skip(i + 1).all(|b| a != b));
if !distinct {
return Err(Error::Config(ConfigReason::CookieNamesEqual));
}
let join = |path: &str| {
origin
.join(path)
.map_err(|_| Error::Config(ConfigReason::PathDoesNotJoin))
};
if self.step_up_failures == (StepUpFailures::PerSession { limit: 0 }) {
return Err(Error::Config(ConfigReason::StepUpLimitZero));
}
let (password_length, totp) = self.check_numbers()?;
let rp_id = self.rp_id.unwrap_or(host);
Ok(Config {
setup_url: join(&self.setup_path)?,
sign_in_link_url: join(&self.sign_in_link_path)?,
invite_url: join(&self.invite_path)?,
sessions_url: join(&self.sessions_path)?,
rp_name: self.rp_name.unwrap_or_else(|| rp_id.clone()),
rp_id,
owner_name: self
.owner_name
.unwrap_or_else(|| owner_email.as_str().to_owned()),
owner_email,
cookie_name,
device_cookie_name,
ceremony_cookie_name,
email_cookie_name,
origin,
durations: self.durations,
lockout: self.lockout,
step_up_failures: self.step_up_failures,
password_only: self.password_only,
rate_limits: self.rate_limits,
password: self.password,
password_length,
totp,
email_code_digits: self.email_code_digits,
email_code_attempts: self.email_code_attempts,
recovery_code_count: self.recovery_code_count,
display_name_max_len: self.display_name_max_len,
insecure_localhost_cookies: self.insecure_localhost_cookies,
})
}
}
impl ConfigBuilder {
fn check_numbers(&self) -> Result<(PasswordLength, TotpParams), Error> {
let password_length = PasswordLength::new(self.password_min_len, self.password_max_len)?;
let totp = TotpParams::new(self.totp_digits, self.totp_step, self.totp_skew)?;
let checks = [
(
EMAIL_CODE_DIGITS.contains(&self.email_code_digits),
ConfigReason::EmailCodeDigits,
),
(
EMAIL_CODE_ATTEMPTS.contains(&self.email_code_attempts),
ConfigReason::EmailCodeAttempts,
),
(
RECOVERY_CODE_COUNT.contains(&self.recovery_code_count),
ConfigReason::RecoveryCodeCount,
),
(
DISPLAY_NAME_MAX_LEN.contains(&self.display_name_max_len),
ConfigReason::DisplayNameLength,
),
];
match checks.into_iter().find(|(ok, _)| !ok) {
Some((_, reason)) => Err(Error::Config(reason)),
None => Ok((password_length, totp)),
}
}
}
impl Config {
pub fn builder(owner_email: &str, origin: &str) -> ConfigBuilder {
ConfigBuilder {
owner_email: owner_email.to_owned(),
origin: origin.to_owned(),
rp_id: None,
rp_name: None,
owner_name: None,
cookie_name: "__Host-latchkey".to_owned(),
device_cookie_name: "__Host-latchkey_device".to_owned(),
ceremony_cookie_name: "__Host-latchkey_ceremony".to_owned(),
email_cookie_name: "__Host-latchkey_email".to_owned(),
setup_path: "/setup".to_owned(),
sign_in_link_path: "/sign-in/link".to_owned(),
invite_path: "/invite".to_owned(),
sessions_path: "/settings/sessions".to_owned(),
durations: Durations::default(),
lockout: LockoutPolicy::default(),
step_up_failures: StepUpFailures::default(),
password_only: PasswordOnly::default(),
rate_limits: RateLimits::default(),
password: PasswordParams::default(),
password_min_len: DEFAULT_MIN_LEN,
password_max_len: DEFAULT_MAX_LEN,
totp_digits: DEFAULT_DIGITS,
totp_step: DEFAULT_STEP_SECS,
totp_skew: DEFAULT_SKEW,
email_code_digits: DEFAULT_CODE_DIGITS,
email_code_attempts: DEFAULT_CODE_ATTEMPTS,
recovery_code_count: DEFAULT_COUNT,
display_name_max_len: DEFAULT_MAX_NAME_LEN,
insecure_localhost_cookies: false,
}
}
#[must_use]
pub fn origin(&self) -> &str {
self.origin.as_str()
}
#[must_use]
pub fn origin_string(&self) -> String {
self.origin.origin().ascii_serialization()
}
#[must_use]
pub fn rp_id(&self) -> &str {
&self.rp_id
}
#[must_use]
pub fn rp_name(&self) -> &str {
&self.rp_name
}
#[must_use]
pub fn owner_email(&self) -> &Email {
&self.owner_email
}
#[must_use]
pub fn owner_name(&self) -> &str {
&self.owner_name
}
#[must_use]
pub fn cookie_name(&self) -> &CookieName {
&self.cookie_name
}
#[must_use]
pub fn device_cookie_name(&self) -> &CookieName {
&self.device_cookie_name
}
#[must_use]
pub fn insecure_localhost_cookies(&self) -> bool {
self.insecure_localhost_cookies
}
#[must_use]
pub fn ceremony_cookie_name(&self) -> &CookieName {
&self.ceremony_cookie_name
}
#[must_use]
pub fn email_cookie_name(&self) -> &CookieName {
&self.email_cookie_name
}
#[must_use]
pub fn setup_url(&self) -> &str {
self.setup_url.as_str()
}
pub(crate) fn setup_target(&self) -> &Url {
&self.setup_url
}
#[must_use]
pub fn sign_in_link_url(&self) -> &str {
self.sign_in_link_url.as_str()
}
pub(crate) fn sign_in_link_target(&self) -> &Url {
&self.sign_in_link_url
}
#[must_use]
pub fn invite_url(&self) -> &str {
self.invite_url.as_str()
}
pub(crate) fn invite_target(&self) -> &Url {
&self.invite_url
}
#[must_use]
pub fn sessions_url(&self) -> &str {
self.sessions_url.as_str()
}
#[must_use]
pub fn lockout(&self) -> &LockoutPolicy {
&self.lockout
}
#[must_use]
pub fn step_up_failures(&self) -> StepUpFailures {
self.step_up_failures
}
#[must_use]
pub fn password_only(&self) -> PasswordOnly {
self.password_only
}
#[must_use]
pub fn rate_limits(&self) -> &RateLimits {
&self.rate_limits
}
#[must_use]
pub fn password_params(&self) -> PasswordParams {
self.password
}
#[must_use]
pub fn password_length(&self) -> PasswordLength {
self.password_length
}
#[must_use]
pub fn totp_params(&self) -> TotpParams {
self.totp
}
#[must_use]
pub fn email_code_digits(&self) -> u8 {
self.email_code_digits
}
#[must_use]
pub fn email_code_attempts(&self) -> u32 {
self.email_code_attempts
}
#[must_use]
pub fn recovery_code_count(&self) -> usize {
self.recovery_code_count
}
#[must_use]
pub fn display_name_max_len(&self) -> usize {
self.display_name_max_len
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults() -> Result<(), Error> {
let config = Config::builder("o@example.com", "https://home.example.com").build()?;
assert_eq!(config.cookie_name().as_str(), "__Host-latchkey");
assert_eq!(
config.device_cookie_name().as_str(),
"__Host-latchkey_device"
);
assert_eq!(
config.ceremony_cookie_name().as_str(),
"__Host-latchkey_ceremony"
);
assert_eq!(config.session_ttl(), Duration::days(30));
assert_eq!(config.step_up_window(), Duration::minutes(5));
assert_eq!(config.email_cookie_name().as_str(), "__Host-latchkey_email");
assert_eq!(config.link_ttl(), Duration::minutes(5));
assert_eq!(config.invite_ttl(), Duration::hours(24));
assert_eq!(config.setup_link_ttl(), Duration::hours(1));
assert_eq!(config.invite_url(), "https://home.example.com/invite");
assert_eq!(config.owner_name(), "o@example.com");
assert_eq!(config.rp_name(), "home.example.com");
assert_eq!(config.step_up_failures(), StepUpFailures::AccountLockout);
assert_eq!(config.password_only(), PasswordOnly::Refused);
assert_eq!(config.passkey_ceremony_ttl(), Duration::minutes(5));
assert_eq!(config.email_code_digits(), 6);
assert_eq!(config.email_code_attempts(), 5);
assert_eq!(config.recovery_code_count(), 10);
assert_eq!(config.display_name_max_len(), 100);
assert_eq!(config.totp_params(), TotpParams::default());
assert_eq!(
(
config.totp_params().digits(),
config.totp_params().step_secs(),
config.totp_params().skew()
),
(6, 30, 1)
);
assert_eq!(config.password_length(), PasswordLength::default());
assert_eq!(
(
config.password_length().min(),
config.password_length().max()
),
(12, 1024)
);
Ok(())
}
#[test]
fn non_default_numbers_are_kept() -> Result<(), Error> {
let config = Config::builder("o@example.com", "https://home.example.com")
.email_code_digits(8)
.email_code_attempts(3)
.recovery_code_count(16)
.display_name_max_len(40)
.totp_digits(8)
.totp_step(60)
.totp_skew(0)
.password_min_len(16)
.password_max_len(128)
.passkey_ceremony_ttl(Duration::minutes(2))
.build()?;
assert_eq!(config.email_code_digits(), 8);
assert_eq!(config.email_code_attempts(), 3);
assert_eq!(config.recovery_code_count(), 16);
assert_eq!(config.display_name_max_len(), 40);
assert_eq!(config.totp_params(), TotpParams::new(8, 60, 0)?);
assert_eq!(config.password_length(), PasswordLength::new(16, 128)?);
assert_eq!(config.passkey_ceremony_ttl(), Duration::minutes(2));
Ok(())
}
#[test]
fn rejects_bad_cookie_names_and_plain_http() {
let builder = || Config::builder("o@example.com", "https://home.example.com");
assert!(builder().cookie_name("session").build().is_err());
assert!(
builder()
.device_cookie_name("__Host-latchkey")
.build()
.is_err()
);
assert!(
builder()
.ceremony_cookie_name("__Host-latchkey_device")
.build()
.is_err()
);
assert!(builder().ceremony_cookie_name("ceremony").build().is_err());
assert!(
builder()
.email_cookie_name("__Host-latchkey_ceremony")
.build()
.is_err()
);
assert!(
builder()
.email_cookie_name("__Host-latchkey")
.build()
.is_err()
);
assert!(
Config::builder("o@example.com", "http://home.example.com")
.build()
.is_err()
);
assert!(
Config::builder("o@example.com", "http://localhost:3000")
.build()
.is_ok()
);
assert!(
Config::builder("not an email", "https://h.example")
.build()
.is_err()
);
assert!(
Config::builder("o@example.com", "not a url")
.build()
.is_err()
);
assert_eq!(
builder()
.step_up_failures(StepUpFailures::PerSession { limit: 0 })
.build()
.err(),
Some(Error::Config(ConfigReason::StepUpLimitZero))
);
assert!(
builder()
.step_up_failures(StepUpFailures::PerSession { limit: 5 })
.build()
.is_ok()
);
}
}
#[cfg(test)]
mod insecure_localhost_tests {
use super::*;
fn dev(origin: &str) -> Result<Config, Error> {
Config::builder("o@example.com", origin)
.danger_insecure_localhost_cookies()
.build()
}
#[test]
fn insecure_cookies_only_for_plain_http_on_this_machine() -> Result<(), Error> {
for origin in [
"http://localhost",
"http://localhost:3000",
"http://127.0.0.1:8080",
"http://[::1]:8080",
] {
let config = dev(origin)?;
assert!(config.insecure_localhost_cookies(), "{origin}");
assert_eq!(config.cookie_name().as_str(), "latchkey");
assert_eq!(config.device_cookie_name().as_str(), "latchkey_device");
assert_eq!(config.ceremony_cookie_name().as_str(), "latchkey_ceremony");
assert_eq!(config.email_cookie_name().as_str(), "latchkey_email");
assert!(!config.cookie_name().is_secure());
}
let refused = Some(Error::Config(ConfigReason::InsecureCookiesNotLocalhost));
for origin in [
"https://home.example.com",
"https://localhost:3000",
"https://127.0.0.1",
] {
assert_eq!(dev(origin).err(), refused, "{origin}");
}
for origin in [
"http://home.example.com",
"http://localhost.example.com",
"http://127.0.0.2",
"http://10.0.0.1",
] {
assert_eq!(
dev(origin).err(),
Some(Error::Config(ConfigReason::OriginNotHttps)),
"{origin}"
);
}
Ok(())
}
#[test]
fn by_default_cookies_are_host_only_and_secure_even_on_localhost() -> Result<(), Error> {
let config = Config::builder("o@example.com", "http://127.0.0.1:3000").build()?;
assert!(!config.insecure_localhost_cookies());
assert_eq!(config.cookie_name().as_str(), "__Host-latchkey");
assert!(config.cookie_name().is_secure());
Ok(())
}
#[test]
fn insecure_names_still_differ_and_stay_valid() {
let builder = || {
Config::builder("o@example.com", "http://localhost:3000")
.danger_insecure_localhost_cookies()
};
assert!(builder().device_cookie_name("latchkey").build().is_err());
assert!(builder().cookie_name("__Host-").build().is_err());
assert_eq!(
builder()
.cookie_name("__Host-app")
.build()
.map(|config| config.cookie_name().as_str().to_owned()),
Ok("app".to_owned())
);
}
}