use std::sync::Arc;
use axum::body::Body;
use axum::http::{
HeaderMap, HeaderValue, Method, StatusCode, header::CONTENT_TYPE, request::Parts,
};
use serde_json::{Map, Value};
use crate::adapter::{
BodyError, Forwarded, Inner, Reply, UpstreamCall, bearer_token, failure, is_json_content_type,
read_json, read_request_body,
};
use crate::cookies::{
CookieWrite, cookie_kind, ephemeral_cookie, raw_cookie, read_cookie, session_cookies,
};
use crate::delivery::parse_delivery;
use crate::jwt::{Claims, str_claim};
use crate::manifest::RouteMatch;
use crate::refresh::SilentRefreshError;
const MAGIC_LINK_VERIFY: (&str, &str) = ("GET", "/magic-link/verify/{token}");
pub(crate) struct Credential {
pub authorization: Option<String>,
pub set: Vec<CookieWrite>,
}
impl Inner {
pub(crate) async fn credential_for(
self: &Arc<Self>,
headers: &HeaderMap,
forwarded: &Forwarded,
kind: &str,
bearer: bool,
) -> Result<Credential, Box<Reply>> {
if kind == "none" {
return Ok(Credential {
authorization: None,
set: Vec::new(),
});
}
if bearer {
return match bearer_token(headers) {
Some(token) => Ok(Credential {
authorization: Some(format!("Bearer {token}")),
set: Vec::new(),
}),
None => Err(Box::new(Reply::error(
StatusCode::UNAUTHORIZED,
&format!("{kind} session required"),
))),
};
}
let name = self.config.cookie_name(kind);
let has_cookie = raw_cookie(headers, name).is_some();
let payload = read_cookie(&self.config, headers, name, cookie_kind(kind));
let valid = payload.is_some();
let token = payload
.as_ref()
.map(|p| str_claim(p, "token"))
.unwrap_or("");
if valid && !token.is_empty() {
return Ok(Credential {
authorization: Some(format!("Bearer {token}")),
set: Vec::new(),
});
}
if kind == "access" && (!has_cookie || valid) {
match self.silent_refresh(headers, forwarded).await {
Ok((session, cookies)) => {
return Ok(Credential {
authorization: Some(format!("Bearer {}", str_claim(&session, "token"))),
set: cookies,
});
}
Err(SilentRefreshError::NoRefreshCookie) => {}
Err(SilentRefreshError::Failed(err)) => {
tracing::debug!("[seamless-auth] Silent refresh failed: {err}");
return Err(Box::new(
Reply::error(StatusCode::UNAUTHORIZED, "Refresh failed").with_clear(vec![
self.config.access_cookie_name.clone(),
self.config.registration_cookie_name.clone(),
self.config.refresh_cookie_name.clone(),
]),
));
}
}
}
let message = if has_cookie {
format!("Invalid or expired {name} cookie")
} else {
format!("Missing required cookie \"{name}\"")
};
Err(Box::new(Reply::error(StatusCode::UNAUTHORIZED, &message)))
}
fn optional_pre_auth(&self, headers: &HeaderMap, bearer: bool) -> Credential {
let token = if bearer {
bearer_token(headers).map(|token| token.to_string())
} else {
let name = self.config.cookie_name("preAuth");
read_cookie(&self.config, headers, name, cookie_kind("preAuth"))
.map(|payload| str_claim(&payload, "token").to_string())
};
Credential {
authorization: token
.filter(|token| !token.is_empty())
.map(|token| format!("Bearer {token}")),
set: Vec::new(),
}
}
pub(crate) async fn verify_session(
&self,
session: &Claims,
typ: &str,
) -> Result<String, String> {
let claims = self
.jwks
.verify(
str_claim(session, "token"),
&self.config.auth_server_issuer,
&self.config.audience,
)
.await?;
if str_claim(&claims, "typ") != typ {
return Err("unexpected token type".into());
}
let subject = str_claim(&claims, "sub");
if subject.is_empty() || subject != str_claim(session, "sub") {
return Err("token subject does not match the response".into());
}
Ok(str_claim(&claims, "sid").to_string())
}
pub(crate) async fn manifest_route(
self: &Arc<Self>,
parts: &Parts,
body: Body,
forwarded: &Forwarded,
found: RouteMatch<'_>,
bearer: bool,
) -> Reply {
let route = found.route;
if route.credential == "refresh" {
return Reply::error(StatusCode::NOT_FOUND, "not_found");
}
let body = match read_request_body(body).await {
Ok(body) => body,
Err(BodyError::TooLarge) => {
return Reply::error(StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large");
}
Err(BodyError::Unreadable) => {
return Reply::error(StatusCode::BAD_REQUEST, "bad_request");
}
};
if body.is_some() && !is_explicit_json(parts.headers.get(CONTENT_TYPE)) {
return Reply::error(StatusCode::UNSUPPORTED_MEDIA_TYPE, "unsupported_media_type");
}
let credential = if (route.method.as_str(), route.path.as_str()) == MAGIC_LINK_VERIFY {
self.optional_pre_auth(&parts.headers, bearer)
} else {
match self
.credential_for(&parts.headers, forwarded, &route.credential, bearer)
.await
{
Ok(credential) => credential,
Err(rejected) => return *rejected,
}
};
let set = credential.set;
let Ok(method) = Method::from_bytes(route.method.as_bytes()) else {
return Reply::error(StatusCode::NOT_FOUND, "not_found");
};
let external = route.delivery && self.config.deliver.is_some();
let mut call = UpstreamCall::new(method, route.upstream_path(&found.params));
call.query = parts.uri.query().map(str::to_string);
call.body = body;
call.authorization = credential.authorization;
call.external_delivery = external;
call.service = if external {
self.tokens.delivery_authorization()
} else {
self.tokens.proxy_authorization()
};
let res = match self.call(forwarded, call).await {
Ok(res) => res,
Err(err) => {
let err = err.without_url();
tracing::warn!("[seamless-auth] {} {}: {err}", route.method, route.path);
return Reply::error(StatusCode::BAD_GATEWAY, "upstream_unavailable").with_set(set);
}
};
let status = res.status();
if status.is_success() && !is_json_content_type(res.headers().get(CONTENT_TYPE)) {
let mut reply = Reply::new(status, None).with_set(set);
reply.raw = Some(res);
return reply;
}
let (mut data, is_json) = read_json(res).await;
if !status.is_success() {
return failure(status, data, is_json).with_set(set);
}
if route.delivery
&& let Some(Value::Object(obj)) = data.as_mut()
{
let delivery = obj.remove("delivery").filter(|d| !d.is_null());
if let (Some(raw), Some(deliver), true) =
(delivery, self.config.deliver.as_ref(), external)
{
let sent = match parse_delivery(&raw) {
Ok(delivery) => deliver(delivery).await.map_err(|e| e.to_string()),
Err(err) => Err(err),
};
if let Err(err) = sent {
tracing::warn!(
"[seamless-auth] Delivery for {} {} failed: {err}",
route.method,
route.path
);
return Reply::error(StatusCode::BAD_GATEWAY, "delivery_failed").with_set(set);
}
}
}
let mut reply = Reply::new(status, None).with_set(set);
for held in &route.clears {
reply.clear.push(self.config.cookie_name(held).to_string());
}
if let (Some(issues), Some(Value::Object(session))) =
(route.issues.as_deref(), data.as_ref())
&& carries_session(session)
{
let session_id = match self
.verify_session(session, issued_token_type(issues))
.await
{
Ok(id) => id,
Err(err) => {
tracing::warn!(
"[seamless-auth] Refusing an unverifiable session from {} {}: {err}",
route.method,
route.path
);
return Reply::error(StatusCode::BAD_GATEWAY, "invalid_upstream_session");
}
};
if !bearer {
let issued = match issues {
"preAuth" | "registration" => {
ephemeral_cookie(&self.config, issues, session).map(|c| vec![c])
}
"session" => session_cookies(&self.config, session, &session_id, true),
_ => session_cookies(&self.config, session, &session_id, false),
};
match issued {
Ok(cookies) => reply.set.extend(cookies),
Err(err) => {
tracing::warn!("[seamless-auth] {} {}: {err}", route.method, route.path);
return Reply::error(StatusCode::BAD_GATEWAY, "invalid_upstream_session");
}
}
}
}
reply.body = match data {
Some(data) if !bearer => Some(cookie_transport_body(
route.body.as_ref().map(|b| b.pick.as_slice()),
data,
)),
data => data,
};
reply
}
}
fn is_explicit_json(content_type: Option<&HeaderValue>) -> bool {
content_type.is_some() && is_json_content_type(content_type)
}
fn issued_token_type(issues: &str) -> &'static str {
match issues {
"preAuth" | "registration" => "ephemeral",
_ => "access",
}
}
pub(crate) fn carries_session(body: &Map<String, Value>) -> bool {
!str_claim(body, "token").is_empty() && !str_claim(body, "sub").is_empty()
}
pub(crate) fn cookie_transport_body(pick: Option<&[String]>, body: Value) -> Value {
let Value::Object(mut obj) = body else {
return body;
};
match pick {
Some(pick) => {
let mut out = Map::new();
for key in pick {
if let Some(value) = obj.remove(key) {
out.insert(key.clone(), value);
}
}
out.remove("token");
out.remove("refreshToken");
Value::Object(out)
}
None => {
obj.remove("token");
obj.remove("refreshToken");
Value::Object(obj)
}
}
}