use axum::{
Json,
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use crate::session::TokenPair;
pub const ACCESS_COOKIE: &str = "__Host-access_token";
pub const REFRESH_COOKIE: &str = "__Secure-refresh_token";
pub const REFRESH_COOKIE_PATH: &str = "/auth/v1/session";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionCookies {
pub refresh_max_age_secs: u64,
}
impl SessionCookies {
#[must_use]
pub fn set(&self, pair: &TokenPair) -> Option<[HeaderValue; 2]> {
Some([
cookie(ACCESS_COOKIE, &pair.access_token, "/", pair.expires_in)?,
cookie(
REFRESH_COOKIE,
&pair.refresh_token,
REFRESH_COOKIE_PATH,
self.refresh_max_age_secs,
)?,
])
}
#[must_use]
pub const fn clear() -> [HeaderValue; 2] {
[
HeaderValue::from_static(
"__Host-access_token=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0",
),
HeaderValue::from_static(
"__Secure-refresh_token=; Path=/auth/v1/session; HttpOnly; Secure; \
SameSite=Strict; Max-Age=0",
),
]
}
}
#[must_use]
pub fn refresh_token_cookie(headers: &HeaderMap) -> Option<String> {
let prefix = format!("{REFRESH_COOKIE}=");
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|cookies| cookies.split(';'))
.find_map(|part| part.trim().strip_prefix(prefix.as_str()).map(ToOwned::to_owned))
.filter(|token| !token.is_empty())
}
#[must_use]
pub fn session_response(cookies: Option<&SessionCookies>, pair: TokenPair) -> Response {
let Some(cookies) = cookies else {
return (StatusCode::OK, Json(pair)).into_response();
};
let expires_in = pair.expires_in;
with_cookies(cookies, &pair, serde_json::json!({ "expires_in": expires_in }))
}
#[must_use]
pub fn with_cookies(
cookies: &SessionCookies,
pair: &TokenPair,
body: serde_json::Value,
) -> Response {
let Some([access, refresh]) = cookies.set(pair) else {
tracing::error!("a minted token holds a byte no cookie value may carry");
return (StatusCode::INTERNAL_SERVER_ERROR, "session delivery failed").into_response();
};
let mut response = (StatusCode::OK, Json(body)).into_response();
let headers = response.headers_mut();
headers.append(header::SET_COOKIE, access);
headers.append(header::SET_COOKIE, refresh);
response
}
fn cookie(name: &str, value: &str, path: &str, max_age_secs: u64) -> Option<HeaderValue> {
let cookie_octet =
|b: u8| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E);
if value.is_empty() || !value.bytes().all(cookie_octet) {
return None;
}
HeaderValue::from_str(&format!(
"{name}={value}; Path={path}; HttpOnly; Secure; SameSite=Strict; Max-Age={max_age_secs}"
))
.ok()
}
#[cfg(test)]
mod tests;