use crate::core::VerificationOutcome;
use axum::body::Body;
use axum::http::{Request, header};
use axum_extra::extract::cookie::Cookie;
use super::{Verifier, verify_token};
const SESSION_COOKIE_NAME: &str = "__session";
const MAX_SESSION_COOKIE_CANDIDATES: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum RequestCredentials {
Missing,
Bearer(String),
SessionCookies(Vec<String>),
}
impl RequestCredentials {
pub(super) fn from_request(req: &Request<Body>) -> Self {
if let Some(token) = bearer_token(req) {
return Self::Bearer(token.to_string());
}
let cookies = session_cookies(req);
if cookies.is_empty() {
Self::Missing
} else {
Self::SessionCookies(cookies)
}
}
pub(super) async fn verify(self, verifier: Verifier) -> VerificationOutcome {
match self {
Self::Missing => VerificationOutcome::Missing,
Self::Bearer(token) => verify_token(&token, verifier).await,
Self::SessionCookies(tokens) => {
let mut failure = None;
for token in &tokens {
let outcome = verify_token(token, verifier.clone()).await;
match outcome {
VerificationOutcome::Valid(_) => return outcome,
VerificationOutcome::Unavailable => {
failure = Some(VerificationOutcome::Unavailable);
}
_ => {
failure.get_or_insert(outcome);
}
}
}
failure.unwrap_or(VerificationOutcome::Missing)
}
}
}
}
fn bearer_token(req: &Request<Body>) -> Option<&str> {
let header = req
.headers()
.get(header::AUTHORIZATION)?
.to_str()
.ok()?
.trim_start();
let (scheme, token) = header.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("Bearer") {
return None;
}
let token = token.trim();
(!token.is_empty()).then_some(token)
}
fn session_cookies(req: &Request<Body>) -> Vec<String> {
let cookies: Vec<Cookie<'_>> = req
.headers()
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(Cookie::split_parse)
.filter_map(Result::ok)
.collect();
let unsuffixed = cookies.iter().filter(|c| c.name() == SESSION_COOKIE_NAME);
let mut suffixed: Vec<&Cookie<'_>> = cookies
.iter()
.filter(|c| {
c.name()
.strip_prefix(SESSION_COOKIE_NAME)
.is_some_and(|rest| rest.starts_with('_'))
})
.collect();
suffixed.sort_by(|a, b| a.name().cmp(b.name()));
unsuffixed
.chain(suffixed)
.map(|c| c.value().to_string())
.filter(|value| !value.is_empty())
.take(MAX_SESSION_COOKIE_CANDIDATES)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::config::ClerkAuthLayerConfig;
#[test]
fn bearer_token_is_extracted_from_authorization_header() {
let req = Request::builder()
.uri("/")
.header(header::AUTHORIZATION, "Bearer bearer-token")
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::Bearer("bearer-token".into())
);
}
#[test]
fn session_cookie_is_extracted_when_bearer_token_is_absent() {
let req = Request::builder()
.uri("/")
.header(header::COOKIE, "__session=session-token")
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::SessionCookies(vec!["session-token".into()])
);
}
#[test]
fn bearer_scheme_is_case_insensitive() {
let req = Request::builder()
.uri("/")
.header(header::AUTHORIZATION, "bearer bearer-token")
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::Bearer("bearer-token".into())
);
}
#[test]
fn suffixed_session_cookies_are_extracted_after_the_unsuffixed_cookie() {
let req = Request::builder()
.uri("/")
.header(
header::COOKIE,
"__session_def=token-b; other=x; __session=token-main; __session_abc=token-a",
)
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::SessionCookies(vec![
"token-main".into(),
"token-a".into(),
"token-b".into(),
])
);
}
#[test]
fn duplicate_session_cookies_all_stay_candidates_in_header_order() {
let req = Request::builder()
.uri("/")
.header(
header::COOKIE,
"__session=host-token; __session=domain-token",
)
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::SessionCookies(vec!["host-token".into(), "domain-token".into()])
);
}
#[test]
fn session_cookies_split_across_multiple_cookie_headers_all_stay_candidates() {
let req = Request::builder()
.uri("/")
.header(header::COOKIE, "__session=token-one")
.header(header::COOKIE, "other=x; __session=token-two")
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::SessionCookies(vec!["token-one".into(), "token-two".into()])
);
}
#[test]
fn session_cookie_candidates_are_capped_to_bound_verification_fan_out() {
let mut header = String::from("__session=main");
for i in 0..50 {
header.push_str(&format!("; __session_{i:02}=token-{i:02}"));
}
let req = Request::builder()
.uri("/")
.header(header::COOKIE, header)
.body(Body::empty())
.unwrap();
let RequestCredentials::SessionCookies(candidates) = RequestCredentials::from_request(&req)
else {
panic!("session cookies should be extracted");
};
assert_eq!(candidates.len(), MAX_SESSION_COOKIE_CANDIDATES);
assert_eq!(candidates[0], "main");
assert_eq!(candidates[1], "token-00");
assert_eq!(
candidates[MAX_SESSION_COOKIE_CANDIDATES - 1],
format!("token-{:02}", MAX_SESSION_COOKIE_CANDIDATES - 2)
);
}
#[test]
fn empty_session_cookie_value_is_treated_as_missing_not_invalid() {
let req = Request::builder()
.uri("/")
.header(header::COOKIE, "__session=")
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::Missing
);
}
#[test]
fn empty_session_cookie_does_not_shadow_a_valid_duplicate() {
let req = Request::builder()
.uri("/")
.header(header::COOKIE, "__session=; __session=real-token")
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::SessionCookies(vec!["real-token".into()])
);
}
#[test]
fn bearer_token_takes_precedence_over_session_cookie() {
let req = Request::builder()
.uri("/")
.header(header::AUTHORIZATION, "Bearer bearer-token")
.header(header::COOKIE, "__session=session-token")
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::Bearer("bearer-token".into())
);
}
#[test]
fn malformed_authorization_header_falls_back_to_session_cookie() {
let req = Request::builder()
.uri("/")
.header(header::AUTHORIZATION, "Basic not-a-bearer-token")
.header(header::COOKIE, "__session=session-token")
.body(Body::empty())
.unwrap();
assert_eq!(
RequestCredentials::from_request(&req),
RequestCredentials::SessionCookies(vec!["session-token".into()])
);
}
#[tokio::test]
async fn missing_credentials_map_to_missing_outcome() {
let verifier = Verifier::new(ClerkAuthLayerConfig::new("sk_test_unused")).unwrap();
let outcome = RequestCredentials::Missing.verify(verifier).await;
assert!(matches!(outcome, VerificationOutcome::Missing));
}
#[tokio::test]
async fn malformed_credentials_map_to_invalid_outcome() {
let verifier = Verifier::new(ClerkAuthLayerConfig::new("sk_test_unused")).unwrap();
let outcome = RequestCredentials::Bearer("invalid-token".into())
.verify(verifier)
.await;
assert!(matches!(outcome, VerificationOutcome::Invalid(_)));
}
}