use axum::http::HeaderMap; use base64::{Engine, engine::general_purpose::URL_SAFE}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use regex::Regex; use std::{env, sync::LazyLock}; use thiserror::Error; use crate::util::now_millis; pub const TOKEN_MAX_DURATION: i64 = 86_400 * 1_000; const BEARER_PREFIX: &str = "Bearer "; static TOKEN_RE: LazyLock = LazyLock::new(|| Regex::new(r"^([a-fA-F0-9]+)\|([a-fA-F0-9]+)\|([a-fA-F0-9]+)$").unwrap()); #[derive(Debug, Error)] pub enum AuthError { #[error("not a valid token")] InvalidToken, #[error("invalid token format")] InvalidTokenFormat, #[error("token is expired")] Expired, #[error("signature verification failed")] Signature, #[error("this client ID is blocked")] Blocked, #[error("{0}")] Decode(String), } pub fn authenticate(token: &str) -> Result { let decoded = URL_SAFE .decode(token) .map_err(|err| AuthError::Decode(format!("error doing base64 decoding: {err}")))?; let decoded = std::str::from_utf8(&decoded) .map_err(|err| AuthError::Decode(format!("error decoding token as utf8: {err}")))?; let captures = TOKEN_RE .captures(decoded) .ok_or(AuthError::InvalidTokenFormat)?; let timestamp_hex = &captures[1]; let signed_timestamp_hex = &captures[2]; let public_key_hex = &captures[3]; let public_key = hex::decode(public_key_hex) .map_err(|err| AuthError::Decode(format!("error decoding hex string: {err}")))?; let timestamp_bytes = hex::decode(timestamp_hex) .map_err(|err| AuthError::Decode(format!("error decoding hex string: {err}")))?; let signature_bytes = hex::decode(signed_timestamp_hex) .map_err(|err| AuthError::Decode(format!("error decoding hex string: {err}")))?; let key = VerifyingKey::from_bytes( public_key .as_slice() .try_into() .map_err(|_| AuthError::InvalidTokenFormat)?, ) .map_err(|_| AuthError::InvalidTokenFormat)?; let signature = Signature::from_slice(&signature_bytes).map_err(|_| AuthError::InvalidTokenFormat)?; key.verify(×tamp_bytes, &signature) .map_err(|_| AuthError::Signature)?; let timestamp = std::str::from_utf8(×tamp_bytes) .map_err(|err| AuthError::Decode(format!("error parsing timestamp: {err}")))? .parse::() .map_err(|err| AuthError::Decode(format!("error parsing timestamp: {err}")))?; if (now_millis() - timestamp).abs() > TOKEN_MAX_DURATION { return Err(AuthError::Expired); } let public_key_lc = public_key_hex.to_ascii_lowercase(); let blocked = env::var("BLOCKED_CLIENT_IDS").unwrap_or_default(); if blocked .split(',') .any(|id| id.trim().eq_ignore_ascii_case(&public_key_lc)) { return Err(AuthError::Blocked); } Ok(public_key_lc) } pub fn authorize(headers: &HeaderMap) -> Result { let token = headers .get("authorization") .and_then(|value| value.to_str().ok()) .ok_or(AuthError::InvalidToken)?; let token = token .strip_prefix(BEARER_PREFIX) .ok_or(AuthError::InvalidToken)?; if token.is_empty() { return Err(AuthError::InvalidToken); } authenticate(token).map_err(|err| AuthError::Decode(format!("error authorizing: {err}"))) } #[cfg(test)] mod tests { use super::*; use base64::engine::general_purpose::URL_SAFE; use ed25519_dalek::{Signer, SigningKey}; fn generate_token(timestamp: i64) -> (String, String) { let key = SigningKey::from_bytes(&[7u8; 32]); let timestamp_bytes = timestamp.to_string().into_bytes(); let signature = key.sign(×tamp_bytes); let public_key_hex = hex::encode(key.verifying_key().to_bytes()); let token_hex = format!( "{}|{}|{}", hex::encode(timestamp_bytes), hex::encode(signature.to_bytes()), public_key_hex ); (URL_SAFE.encode(token_hex), public_key_hex) } #[test] fn authenticate_accepts_valid_token() { let (token, public_key_hex) = generate_token(now_millis()); let client_id = authenticate(&token).expect("token should authenticate"); assert_eq!(client_id, public_key_hex); } #[test] fn authenticate_rejects_expired_token() { let (token, _) = generate_token(now_millis() - TOKEN_MAX_DURATION - 1); assert!(matches!(authenticate(&token), Err(AuthError::Expired))); } }