use std::fmt::Write as _;
use std::time::Duration;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[allow(clippy::duration_suboptimal_units)]
pub const SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 3600);
pub const COOKIE_NAME: &str = "dockdoe_session";
#[must_use]
pub fn generate_secret() -> Vec<u8> {
let mut buf = [0u8; 32];
getrandom::fill(&mut buf).expect("OS random source unavailable");
buf.to_vec()
}
#[derive(Clone)]
pub struct Auth {
user: String,
password: String,
secret: Vec<u8>,
secure_cookie: bool,
}
impl Auth {
#[must_use]
pub fn new(user: String, password: String, secret: Vec<u8>, secure_cookie: bool) -> Self {
Self {
user,
password,
secret,
secure_cookie,
}
}
#[must_use]
pub fn credentials_valid(&self, user: &str, password: &str) -> bool {
let expected = self.credential_tag(&self.user, &self.password);
let mut mac = self.hmac();
feed_credentials(&mut mac, user, password);
mac.verify_slice(&expected).is_ok()
}
#[must_use]
pub fn issue_cookie(&self, now_unix: u64) -> String {
let expiry = now_unix + SESSION_TTL.as_secs();
let token = self.sign(expiry);
self.cookie(&token, SESSION_TTL.as_secs())
}
#[must_use]
pub fn clear_cookie(&self) -> String {
self.cookie("", 0)
}
#[must_use]
pub fn token_valid(&self, token: &str, now_unix: u64) -> bool {
let Some((expiry_str, sig)) = token.split_once('.') else {
return false;
};
let Ok(expiry) = expiry_str.parse::<u64>() else {
return false;
};
if expiry <= now_unix {
return false;
}
let Ok(sig_bytes) = decode_hex(sig) else {
return false;
};
let mut mac = self.hmac();
mac.update(expiry_str.as_bytes());
mac.verify_slice(&sig_bytes).is_ok()
}
fn sign(&self, expiry: u64) -> String {
let mut mac = self.hmac();
let expiry_str = expiry.to_string();
mac.update(expiry_str.as_bytes());
format!("{expiry_str}.{}", encode_hex(&mac.finalize().into_bytes()))
}
fn cookie(&self, token: &str, max_age: u64) -> String {
let mut c =
format!("{COOKIE_NAME}={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}");
if self.secure_cookie {
c.push_str("; Secure");
}
c
}
fn credential_tag(&self, user: &str, password: &str) -> Vec<u8> {
let mut mac = self.hmac();
feed_credentials(&mut mac, user, password);
mac.finalize().into_bytes().to_vec()
}
fn hmac(&self) -> HmacSha256 {
HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts any key length")
}
}
#[derive(Clone)]
pub struct ApiToken {
token: String,
}
impl ApiToken {
#[must_use]
pub fn new(token: String) -> Self {
Self { token }
}
#[must_use]
pub fn matches(&self, presented: &str) -> bool {
let expected = self.tag(&self.token);
let mut mac = self.hmac();
mac.update(presented.as_bytes());
mac.verify_slice(&expected).is_ok()
}
fn tag(&self, value: &str) -> Vec<u8> {
let mut mac = self.hmac();
mac.update(value.as_bytes());
mac.finalize().into_bytes().to_vec()
}
fn hmac(&self) -> HmacSha256 {
HmacSha256::new_from_slice(self.token.as_bytes()).expect("HMAC accepts any key length")
}
}
fn feed_credentials(mac: &mut HmacSha256, user: &str, password: &str) {
mac.update(user.as_bytes());
mac.update(&[0]);
mac.update(password.as_bytes());
}
#[must_use]
pub fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
header.split(';').map(str::trim).find_map(|kv| {
let (k, v) = kv.split_once('=')?;
(k == name).then_some(v)
})
}
fn encode_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn decode_hex(s: &str) -> Result<Vec<u8>, ()> {
if !s.len().is_multiple_of(2) {
return Err(());
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn auth() -> Auth {
Auth::new(
"admin".into(),
"s3cret".into(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
false,
)
}
#[test]
fn credentials_check_is_exact() {
let a = auth();
assert!(a.credentials_valid("admin", "s3cret"));
assert!(!a.credentials_valid("admin", "wrong"));
assert!(!a.credentials_valid("root", "s3cret"));
assert!(!a.credentials_valid("", ""));
assert!(!a.credentials_valid("admins", "3cret"));
}
#[test]
fn token_roundtrips_and_respects_expiry() {
let a = auth();
let now = 1_000_000;
let cookie = a.issue_cookie(now);
let token = cookie_value(&cookie, COOKIE_NAME).unwrap();
assert!(a.token_valid(token, now));
let expiry = now + SESSION_TTL.as_secs();
assert!(a.token_valid(token, expiry - 1));
assert!(!a.token_valid(token, expiry));
}
#[test]
fn token_rejects_tampering_and_wrong_secret() {
let a = auth();
let now = 1_000_000;
let token = a.sign(now + 999);
assert!(a.token_valid(&token, now));
let (_, sig) = token.split_once('.').unwrap();
let forged = format!("{}.{sig}", now + 999_999);
assert!(!a.token_valid(&forged, now));
assert!(!a.token_valid("nonsense", now));
assert!(!a.token_valid("123.zz", now));
let other = Auth::new("admin".into(), "s3cret".into(), vec![9, 9, 9], false);
assert!(!other.token_valid(&token, now));
}
#[test]
fn secure_flag_controls_cookie_attribute() {
let plain = Auth::new("u".into(), "p".into(), vec![1, 2, 3], false);
let secure = Auth::new("u".into(), "p".into(), vec![1, 2, 3], true);
assert!(!plain.issue_cookie(0).contains("; Secure"));
assert!(secure.issue_cookie(0).contains("; Secure"));
assert!(secure.clear_cookie().contains("Max-Age=0"));
assert!(secure.clear_cookie().contains("; Secure"));
}
#[test]
fn api_token_matches_exactly() {
let t = ApiToken::new("hub-secret".into());
assert!(t.matches("hub-secret"));
assert!(!t.matches("hub-secret "));
assert!(!t.matches("hub-secre"));
assert!(!t.matches("hub-secrets"));
assert!(!t.matches(""));
}
#[test]
fn cookie_value_picks_the_right_pair() {
let header = "foo=bar; dockdoe_session=abc.def; baz=qux";
assert_eq!(cookie_value(header, COOKIE_NAME), Some("abc.def"));
assert_eq!(cookie_value(header, "missing"), None);
}
}