use std::future::Future;
use axum::Router;
use axum::extract::{Query, Request, State};
use axum::middleware::Next;
use axum::response::{IntoResponse as _, Response};
use axum::routing::{get, post};
use ferrohealth_smart::oidc::{Callback, OidcError, RelyingParty};
use http::header::{CACHE_CONTROL, CONTENT_TYPE, LOCATION, SET_COOKIE};
use http::request::Parts;
use http::{HeaderValue, Method, StatusCode};
use serde::Deserialize;
use crate::paths::{CALLBACK, LOGIN, LOGOUT, SESSION, is_own};
use crate::record::SignedIn;
use crate::refresh::Freshness;
use crate::return_path::ReturnPath;
use crate::sessions::{SessionError, Sessions};
#[derive(Debug)]
#[non_exhaustive]
pub enum Stopped {
NotConfigured,
NoSignInPending,
StateMismatch,
NoCode,
Rejected(OidcError),
NotCompleted(OidcError),
Full,
Store(SessionError),
}
impl Stopped {
#[must_use]
pub const fn status(&self) -> StatusCode {
match self {
Self::NotConfigured | Self::Full => StatusCode::SERVICE_UNAVAILABLE,
Self::NoSignInPending | Self::StateMismatch | Self::NoCode => StatusCode::BAD_REQUEST,
Self::Rejected(_) => StatusCode::UNAUTHORIZED,
Self::NotCompleted(_) => StatusCode::BAD_GATEWAY,
Self::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[must_use]
pub const fn retry(&self) -> bool {
matches!(self, Self::NotCompleted(_) | Self::NoSignInPending)
}
}
pub trait Pages: Clone + Send + Sync + 'static {
fn stopped(&self, parts: Parts, why: Stopped) -> impl Future<Output = Response> + Send;
fn sign_in_page(&self) -> &str;
fn signed_out(&self) -> &str;
fn home(&self) -> &str;
fn session_gone(&self, _parts: &Parts) -> String {
String::from("The session has ended. Sign in again to go on.")
}
}
#[derive(Debug, Clone)]
pub struct SignIn<P> {
sessions: Sessions,
party: Option<RelyingParty>,
pages: P,
}
impl<P: Pages> SignIn<P> {
#[must_use]
pub const fn new(sessions: Sessions, party: Option<RelyingParty>, pages: P) -> Self {
Self {
sessions,
party,
pages,
}
}
#[must_use]
pub const fn sessions(&self) -> &Sessions {
&self.sessions
}
#[must_use]
pub const fn party(&self) -> Option<&RelyingParty> {
self.party.as_ref()
}
async fn stop(&self, parts: Parts, why: Stopped, cookies: Vec<HeaderValue>) -> Response {
let mut response = self.pages.stopped(parts, why).await;
append(&mut response, cookies);
response
}
}
pub fn router<P: Pages, S: Clone + Send + Sync + 'static>(sign_in: SignIn<P>) -> Router<S> {
Router::new()
.route(LOGIN, get(login::<P>))
.route(CALLBACK, get(callback::<P>))
.route(LOGOUT, post(logout::<P>))
.route(SESSION, get(watch::<P>).post(watch::<P>))
.with_state(sign_in)
}
pub async fn keep_session<P: Pages>(
State(sign_in): State<SignIn<P>>,
request: Request,
next: Next,
) -> Response {
if is_own(request.uri().path()) {
return next.run(request).await;
}
if let Some(party) = sign_in.party() {
match sign_in.sessions.keep_fresh(party, request.headers()).await {
Ok(Freshness::Ended(error)) => {
tracing::warn!(%error, "the OpenID Provider refused a refresh, so the session is ended");
}
Ok(Freshness::Unreached(error)) => {
tracing::warn!(%error, "an access token could not be refreshed at the OpenID Provider");
}
Ok(_) => {}
Err(error) => return store_failed(&sign_in, request, error).await,
}
}
match sign_in.sessions.held(request.headers()) {
Ok(held) => {
let mut request = request;
request.extensions_mut().insert(held.value);
let mut response = next.run(request).await;
append(&mut response, held.cookies);
response
}
Err(error) => store_failed(&sign_in, request, error).await,
}
}
async fn store_failed<P: Pages>(
sign_in: &SignIn<P>,
request: Request,
error: SessionError,
) -> Response {
tracing::error!(%error, "the session store could not be read");
let (parts, _body) = request.into_parts();
sign_in.stop(parts, Stopped::Store(error), Vec::new()).await
}
#[derive(Debug, Default, Deserialize)]
struct LoginQuery {
#[serde(rename = "return")]
back: Option<String>,
}
async fn login<P: Pages>(State(sign_in): State<SignIn<P>>, request: Request) -> Response {
let (parts, _body) = request.into_parts();
let Some(relying_party) = sign_in.party() else {
return sign_in
.stop(parts, Stopped::NotConfigured, Vec::new())
.await;
};
let back = Query::<LoginQuery>::try_from_uri(&parts.uri)
.ok()
.and_then(|Query(query)| query.back)
.and_then(|back| ReturnPath::parse(&back, sign_in.pages.sign_in_page()));
let authorization = match relying_party.authorization_request() {
Ok(authorization) => authorization,
Err(error) => {
tracing::error!(%error, "a sign-in could not be minted");
return sign_in
.stop(parts, Stopped::Store(SessionError::Random), Vec::new())
.await;
}
};
match sign_in.sessions.begin(authorization.pending, back) {
Ok(cookie) => redirect(authorization.url.as_str(), vec![cookie]),
Err(error) => sign_in.stop(parts, Stopped::Store(error), Vec::new()).await,
}
}
async fn callback<P: Pages>(State(sign_in): State<SignIn<P>>, request: Request) -> Response {
let (parts, _body) = request.into_parts();
let taken = match sign_in.sessions.take_pending(&parts.headers) {
Ok(taken) => taken,
Err(error) => return sign_in.stop(parts, Stopped::Store(error), Vec::new()).await,
};
let removal = taken.cookies;
let Some(pending) = taken.value else {
return sign_in.stop(parts, Stopped::NoSignInPending, removal).await;
};
let Some(relying_party) = sign_in.party() else {
return sign_in.stop(parts, Stopped::NotConfigured, removal).await;
};
let query = Query::<Callback>::try_from_uri(&parts.uri)
.map(|Query(query)| query)
.unwrap_or_default();
let signed_in = match relying_party.exchange(&pending.sign_in, &query).await {
Ok(signed_in) => signed_in,
Err(error) => return sign_in.stop(parts, refused(error), removal).await,
};
match sign_in
.sessions
.establish(&parts.headers, SignedIn::from_oidc(signed_in))
{
Ok(cookie) => {
let landing = pending
.back
.as_ref()
.map_or_else(|| sign_in.pages.home(), ReturnPath::as_str);
let mut cookies = removal;
cookies.push(cookie);
redirect(landing, cookies)
}
Err(SessionError::Full) => sign_in.stop(parts, Stopped::Full, removal).await,
Err(error) => sign_in.stop(parts, Stopped::Store(error), removal).await,
}
}
fn refused(error: OidcError) -> Stopped {
match error {
OidcError::StateMismatch => Stopped::StateMismatch,
OidcError::NoCode => Stopped::NoCode,
error
if matches!(error, OidcError::Authorization { .. }) || error.refuses_the_operator() =>
{
tracing::warn!(%error, "a sign-in was refused");
Stopped::Rejected(error)
}
error => {
tracing::error!(%error, "a sign-in could not be completed at the OpenID Provider");
Stopped::NotCompleted(error)
}
}
}
async fn logout<P: Pages>(State(sign_in): State<SignIn<P>>, request: Request) -> Response {
let (parts, _body) = request.into_parts();
let ended = match sign_in.sessions.end(&parts.headers) {
Ok(ended) => ended,
Err(error) => return sign_in.stop(parts, Stopped::Store(error), Vec::new()).await,
};
let mut location = None;
if let Some(party) = sign_in.party() {
let held = ended.value.as_ref();
if let Some((token, kind)) = held.and_then(|ended| ended.revocable.as_ref())
&& let Err(error) = party.revoke(token, *kind).await
{
tracing::warn!(%error, "the OpenID Provider did not revoke an operator's token");
}
if let Some(hint) = held.and_then(|ended| ended.id_token.as_ref()) {
location = party.end_session_url(Some(hint), None).map(String::from);
}
}
let location = location.unwrap_or_else(|| sign_in.pages.signed_out().to_owned());
redirect(&location, ended.cookies)
}
async fn watch<P: Pages>(
State(sign_in): State<SignIn<P>>,
method: Method,
request: Request,
) -> Response {
let extend = method == Method::POST;
let (parts, _body) = request.into_parts();
match sign_in.sessions.left(&parts.headers, extend) {
Ok(left) => {
let mut response = match left.value {
Some(left) => axum::Json(left).into_response(),
None => gone(&sign_in.pages.session_gone(&parts)),
};
no_store(&mut response);
append(&mut response, left.cookies);
response
}
Err(error) => {
tracing::error!(%error, "the session store could not be read");
let mut response = StatusCode::INTERNAL_SERVER_ERROR.into_response();
no_store(&mut response);
response
}
}
}
fn gone(detail: &str) -> Response {
let problem = serde_json::json!({
"type": "about:blank",
"title": "Unauthorized",
"status": StatusCode::UNAUTHORIZED.as_u16(),
"detail": detail,
});
(
StatusCode::UNAUTHORIZED,
[(CONTENT_TYPE, HeaderValue::from_static(PROBLEM_JSON))],
problem.to_string(),
)
.into_response()
}
const PROBLEM_JSON: &str = "application/problem+json";
fn redirect(location: &str, cookies: Vec<HeaderValue>) -> Response {
let Ok(location) = HeaderValue::from_str(location) else {
tracing::error!("a sign-in redirect could not be written as a header");
let mut response = StatusCode::INTERNAL_SERVER_ERROR.into_response();
no_store(&mut response);
return response;
};
let mut response = StatusCode::SEE_OTHER.into_response();
response.headers_mut().insert(LOCATION, location);
no_store(&mut response);
append(&mut response, cookies);
response
}
fn no_store(response: &mut Response) {
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
fn append(response: &mut Response, cookies: Vec<HeaderValue>) {
for cookie in cookies {
response.headers_mut().append(SET_COOKIE, cookie);
}
}