Use SQLite for persistence and generate Rust protobuf bindings at build time. Add Docker image support for containerized deployment. Co-Authored-By: codex <[email protected]>
132 lines
4.5 KiB
Rust
132 lines
4.5 KiB
Rust
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<Regex> =
|
|
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<String, AuthError> {
|
|
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::<i64>()
|
|
.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<String, AuthError> {
|
|
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)));
|
|
}
|
|
}
|