use std::sync::OnceLock;
use axum::extract::Request;
use axum::http::{HeaderValue, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
pub const LAUNCH_PARAM: &str = "__rahti_native";
pub fn launch_token() -> &'static str {
static TOKEN: OnceLock<String> = OnceLock::new();
TOKEN.get_or_init(|| {
let mut bytes = [0u8; 24];
match getrandom::fill(&mut bytes) {
Ok(()) => bytes.iter().map(|b| format!("{b:02x}")).collect(),
Err(_) => String::new(),
}
})
}
pub struct LaunchToken;
impl LaunchToken {
pub fn launch_url(base: &str) -> String {
format!(
"{}/?{LAUNCH_PARAM}={}",
base.trim_end_matches('/'),
launch_token()
)
}
}
pub async fn gate(request: Request, next: Next) -> Response {
let token = launch_token();
if token.is_empty() {
return refuse();
}
if cookie_matches(&request, token) {
return next.run(request).await;
}
if let Some(clean) = query_matches(&request, token) {
return admit(&clean, token);
}
refuse()
}
fn cookie_matches(request: &Request, token: &str) -> bool {
let Some(header) = request.headers().get(header::COOKIE) else {
return false;
};
let Ok(header) = header.to_str() else {
return false;
};
header
.split(';')
.filter_map(|pair| pair.split_once('='))
.any(|(name, value)| name.trim() == LAUNCH_PARAM && constant_time_eq(value.trim(), token))
}
fn query_matches(request: &Request, token: &str) -> Option<String> {
if request.method() != axum::http::Method::GET {
return None;
}
let uri = request.uri();
let query = uri.query()?;
let mut carried = false;
let mut rest: Vec<&str> = Vec::new();
for pair in query.split('&') {
match pair.split_once('=') {
Some((LAUNCH_PARAM, value)) => carried = constant_time_eq(value, token),
_ => rest.push(pair),
}
}
if !carried {
return None;
}
let path = uri.path();
Some(if rest.is_empty() {
path.to_string()
} else {
format!("{path}?{}", rest.join("&"))
})
}
fn admit(clean: &str, token: &str) -> Response {
let cookie = format!("{LAUNCH_PARAM}={token}; Path=/; HttpOnly; SameSite=Strict");
let mut response = (StatusCode::SEE_OTHER, "").into_response();
let headers = response.headers_mut();
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.insert(header::SET_COOKIE, value);
}
if let Ok(value) = HeaderValue::from_str(clean) {
headers.insert(header::LOCATION, value);
}
response
}
fn refuse() -> Response {
StatusCode::FORBIDDEN.into_response()
}
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter()
.zip(b)
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
.eq(&0)
}