Skip to main content

ordinary_auth/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3#![warn(clippy::all, clippy::pedantic)]
4#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
5
6// Copyright (C) 2026 Ordinary Labs, LLC.
7//
8// SPDX-License-Identifier: AGPL-3.0-only
9
10use anyhow::bail;
11use regex::Regex;
12
13/// `^[a-z][a-z0-9_]{0,13}[a-z0-9]$`
14///
15/// allows lowercase ascii letters, numbers 0-9 (but not leading), underscores (but not leading
16/// or trailing) and at least 2 chars but no more than 15.
17pub static ACCOUNT_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
18    Regex::new(r"^[a-z][a-z0-9_]{0,13}[a-z0-9]$").expect("failed to create regex")
19});
20
21/// `^[a-z][a-z0-9\-]{0,35}[a-z0-9][.]$`
22///
23/// allows lowercase ascii letters, numbers 0-9 (but not leading), hyphens (but not leading
24/// or trailing) and at least 2 chars but no more than 37 (accommodates a stringified
25/// UUID with leading "a-z").
26///
27/// note: includes a check for "." at the end of the subdomain, if using in another application
28/// that strips or splits on the "." it will need to be re-appended before `.is_match` is called.
29pub static SUBDOMAIN_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
30    Regex::new(r"^[a-z][a-z0-9\-]{0,35}[a-z0-9][.]$").expect("failed to create regex")
31});
32
33use argon2::Argon2;
34use opaque_ke::ciphersuite::CipherSuite;
35
36pub use chacha20poly1305::aead::OsRng;
37pub use opaque_ke::ServerSetup;
38
39pub(crate) const ZEROED_KEY: [u8; 32] = [0u8; 32];
40pub const EXP_LEN: usize = 8;
41pub(crate) const SIG_LEN: usize = 64;
42pub(crate) const MAC_LEN: usize = 32;
43
44#[cfg(feature = "core")]
45pub mod keys;
46
47pub mod login;
48pub mod registration;
49pub mod token;
50
51#[cfg(feature = "core")]
52mod core;
53#[cfg(feature = "core")]
54pub use core::Auth;
55
56#[cfg(feature = "core")]
57pub mod recovery;
58
59#[cfg(feature = "client")]
60mod client;
61#[cfg(feature = "client")]
62pub use client::AuthClient;
63
64pub struct DefaultCipherSuite;
65
66impl CipherSuite for DefaultCipherSuite {
67    type OprfCs = opaque_ke::Ristretto255;
68    type KeyExchange = opaque_ke::TripleDh<opaque_ke::Ristretto255, sha2::Sha512>;
69    type Ksf = Argon2<'static>;
70}
71
72pub fn validate_account(account: &str) -> anyhow::Result<String> {
73    let lowercase_account = account.to_ascii_lowercase();
74
75    if !ACCOUNT_REGEX.is_match(&lowercase_account) {
76        bail!("invalid account name");
77    }
78
79    Ok(lowercase_account)
80}
81
82pub fn validate_domain(base_domains: &Vec<String>, domain: &str) -> bool {
83    for base_domain in base_domains {
84        if domain.ends_with(base_domain) {
85            let mut domain = domain.to_string();
86            domain.truncate(domain.len() - base_domain.len());
87
88            if SUBDOMAIN_REGEX.is_match(&domain) {
89                return true;
90            }
91        }
92    }
93
94    false
95}
96
97#[cfg(test)]
98mod test {
99    use super::*;
100
101    #[test]
102    fn validate_account_test() -> anyhow::Result<()> {
103        // valid
104        let account = validate_account("my_account")?;
105        assert_eq!(account, "my_account");
106
107        let account = validate_account("MyAccount")?;
108        assert_eq!(account, "myaccount");
109
110        let account = validate_account("myAccount123")?;
111        assert_eq!(account, "myaccount123");
112
113        // invalid
114        let account = validate_account("_my_account");
115        assert!(account.is_err());
116
117        let account = validate_account("my_account_");
118        assert!(account.is_err());
119
120        let account = validate_account("1my_account");
121        assert!(account.is_err());
122
123        let account = validate_account("my-account");
124        assert!(account.is_err());
125
126        let account = validate_account("@#$%1234");
127        assert!(account.is_err());
128
129        Ok(())
130    }
131
132    #[test]
133    fn validate_domain_test() {
134        let app_domains = vec!["example.com".to_string(), "sub.example.com".to_string()];
135
136        // valid
137        assert!(validate_domain(&app_domains, "my.example.com"));
138        assert!(validate_domain(&app_domains, "asdf.example.com"));
139        assert!(validate_domain(&app_domains, "asdf.sub.example.com"));
140
141        assert!(validate_domain(&app_domains, "as-df.example.com"));
142        assert!(validate_domain(&app_domains, "a-sdf.sub.example.com"));
143
144        // invalid
145        assert!(!validate_domain(&app_domains, "asdf..example.com"));
146        assert!(!validate_domain(&app_domains, "asdf-example.com"));
147        assert!(!validate_domain(&app_domains, "asdf-.example.com"));
148        assert!(!validate_domain(&app_domains, "asdf-.example.com"));
149    }
150}