use std::sync::Arc;
use async_trait::async_trait;
use axum::{
Json, Router,
extract::State,
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
routing::post,
};
use serde::Deserialize;
use crate::{
handlers::{MAX_REFRESH_TOKEN_BYTES, validate_auth_input_len},
session_cookies::{SessionCookies, refresh_token_cookie, with_cookies},
session_postgres::{PostgresSessionStore, Refresh},
};
#[async_trait]
pub trait DenyAccessTokens: Send + Sync {
async fn deny(&self, jtis: &[String]) -> Result<(), String>;
async fn deny_all_for_user(&self, user_id: &str) -> Result<(), String>;
}
#[derive(Clone)]
pub struct SessionRouteState {
pub sessions: Arc<PostgresSessionStore>,
pub denial: Option<Arc<dyn DenyAccessTokens>>,
pub cookies: Option<SessionCookies>,
}
#[derive(Deserialize)]
pub struct SessionTokenRequest {
#[serde(default)]
pub refresh_token: Option<String>,
}
fn presented(
state: &SessionRouteState,
headers: &HeaderMap,
req: SessionTokenRequest,
) -> Option<(String, Option<SessionCookies>)> {
match (req.refresh_token, state.cookies) {
(Some(token), _) => Some((token, None)),
(None, Some(cookies)) => refresh_token_cookie(headers).map(|token| (token, Some(cookies))),
(None, None) => None,
}
}
pub fn session_routes(state: SessionRouteState) -> Router {
Router::new()
.route("/auth/v1/session/refresh", post(session_refresh))
.route("/auth/v1/session/logout", post(session_logout))
.with_state(state)
}
fn refused() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "invalid_refresh_token",
"message": "the refresh token is not valid; sign in again"
})),
)
.into_response()
}
pub async fn session_refresh(
State(state): State<SessionRouteState>,
headers: HeaderMap,
Json(req): Json<SessionTokenRequest>,
) -> Response {
let Some((token, from_cookie)) = presented(&state, &headers, req) else {
return refused();
};
if validate_auth_input_len(&token, MAX_REFRESH_TOKEN_BYTES, "refresh_token").is_err() {
return refused();
}
match state.sessions.refresh(&token).await {
Ok(Refresh::Rotated(pair)) => match from_cookie {
Some(cookies) => {
let expires_in = pair.expires_in;
with_cookies(&cookies, &pair, serde_json::json!({ "expires_in": expires_in }))
},
None => (StatusCode::OK, Json(pair)).into_response(),
},
Ok(Refresh::AlreadyRotated) => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "refresh_token_already_rotated",
"message": "this refresh token was exchanged moments ago; use the pair it returned"
})),
)
.into_response(),
Ok(Refresh::Refused) => refused(),
Err(e) => {
tracing::error!(error = %e, "session refresh failed");
(StatusCode::INTERNAL_SERVER_ERROR, "session refresh failed").into_response()
},
}
}
pub async fn session_logout(
State(state): State<SessionRouteState>,
headers: HeaderMap,
Json(req): Json<SessionTokenRequest>,
) -> Response {
let logged_out = || {
let mut response = StatusCode::NO_CONTENT.into_response();
if state.cookies.is_some() {
for cookie in SessionCookies::clear() {
response.headers_mut().append(header::SET_COOKIE, cookie);
}
}
response
};
let Some((token, _)) = presented(&state, &headers, req) else {
return logged_out();
};
if validate_auth_input_len(&token, MAX_REFRESH_TOKEN_BYTES, "refresh_token").is_err() {
return logged_out();
}
match state.sessions.end_session(&token).await {
Ok(jtis) => match &state.denial {
Some(denial) => match denial.deny(&jtis).await {
Ok(()) => logged_out(),
Err(e) => {
tracing::error!(error = %e, "session logout could not deny its access tokens");
(StatusCode::INTERNAL_SERVER_ERROR, "session logout failed").into_response()
},
},
None => logged_out(),
},
Err(e) => {
tracing::error!(error = %e, "session logout failed");
(StatusCode::INTERNAL_SERVER_ERROR, "session logout failed").into_response()
},
}
}