ordinary-auth 0.6.0

Auth for Ordinary
Documentation
#![cfg_attr(docsrs, feature(doc_cfg))]
#![doc = include_str!("../README.md")]
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]

// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

use anyhow::bail;
use regex::Regex;

/// `^[a-z][a-z0-9_]{0,13}[a-z0-9]$`
///
/// allows lowercase ascii letters, numbers 0-9 (but not leading), underscores (but not leading
/// or trailing) and at least 2 chars but no more than 15.
pub static ACCOUNT_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
    Regex::new(r"^[a-z][a-z0-9_]{0,13}[a-z0-9]$").expect("failed to create regex")
});

/// `^[a-z][a-z0-9\-]{0,35}[a-z0-9][.]$`
///
/// allows lowercase ascii letters, numbers 0-9 (but not leading), hyphens (but not leading
/// or trailing) and at least 2 chars but no more than 37 (accommodates a stringified
/// UUID with leading "a-z").
///
/// note: includes a check for "." at the end of the subdomain, if using in another application
/// that strips or splits on the "." it will need to be re-appended before `.is_match` is called.
pub static SUBDOMAIN_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
    Regex::new(r"^[a-z][a-z0-9\-]{0,35}[a-z0-9][.]$").expect("failed to create regex")
});

use argon2::Argon2;
use opaque_ke::ciphersuite::CipherSuite;

pub use chacha20poly1305::aead::OsRng;
pub use opaque_ke::ServerSetup;

pub(crate) const ZEROED_KEY: [u8; 32] = [0u8; 32];
pub const EXP_LEN: usize = 8;
#[cfg(feature = "core")]
pub(crate) const SIG_LEN: usize = 64;
pub(crate) const MAC_LEN: usize = 32;

#[cfg(feature = "core")]
pub mod keys;

pub mod login;
pub mod registration;
pub mod token;

#[cfg(feature = "core")]
mod core;
#[cfg(feature = "core")]
pub use core::Auth;

#[cfg(feature = "core")]
pub mod recovery;

#[cfg(feature = "client")]
mod client;
#[cfg(feature = "client")]
pub use client::AuthClient;

pub struct DefaultCipherSuite;

impl CipherSuite for DefaultCipherSuite {
    type OprfCs = opaque_ke::Ristretto255;
    type KeyExchange = opaque_ke::TripleDh<opaque_ke::Ristretto255, opaque_sha2::Sha512>;
    type Ksf = Argon2<'static>;
}

pub fn validate_account(account: &str) -> anyhow::Result<String> {
    let lowercase_account = account.to_ascii_lowercase();

    if !ACCOUNT_REGEX.is_match(&lowercase_account) {
        bail!("invalid account name");
    }

    Ok(lowercase_account)
}

pub fn validate_domain(base_domains: &Vec<String>, domain: &str) -> bool {
    for base_domain in base_domains {
        if domain.ends_with(base_domain) {
            let mut domain = domain.to_string();
            domain.truncate(domain.len() - base_domain.len());

            return SUBDOMAIN_REGEX.is_match(&domain);
        }
    }

    false
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn validate_account_test() -> anyhow::Result<()> {
        // valid
        let account = validate_account("my_account")?;
        assert_eq!(account, "my_account");

        let account = validate_account("MyAccount")?;
        assert_eq!(account, "myaccount");

        let account = validate_account("myAccount123")?;
        assert_eq!(account, "myaccount123");

        // invalid
        let account = validate_account("_my_account");
        assert!(account.is_err());

        let account = validate_account("my_account_");
        assert!(account.is_err());

        let account = validate_account("1my_account");
        assert!(account.is_err());

        let account = validate_account("my-account");
        assert!(account.is_err());

        let account = validate_account("@#$%1234");
        assert!(account.is_err());

        Ok(())
    }

    #[test]
    fn validate_domain_test() {
        let app_domains = vec!["example.com".to_string(), "sub.example.com".to_string()];

        // valid
        assert!(validate_domain(&app_domains, "my.example.com"));
        assert!(validate_domain(&app_domains, "asdf.example.com"));
        assert!(validate_domain(&app_domains, "as-df.example.com"));

        // invalid
        assert!(!validate_domain(&app_domains, "asdf..example.com"));
        assert!(!validate_domain(&app_domains, "asdf-example.com"));
        assert!(!validate_domain(&app_domains, "asdf-.example.com"));
        assert!(!validate_domain(&app_domains, "asdf-.example.com"));
        assert!(!validate_domain(&app_domains, "asdf.sub.example.com"));
        assert!(!validate_domain(&app_domains, "a-sdf.sub.example.com"));
    }
}