ppoppo-sdk-core 0.2.0

Internal shared primitives for the Ppoppo SDK family (pas-external, pas-plims, pcs-external) — verifier port, audit trait, session liveness port, OIDC discovery, perimeter Bearer-auth Layer kit, identity types. Not a stable public API; do not depend on this crate directly. Consume the SDK crates that re-export from it (e.g. `pas-external`).
Documentation
//! [`BearerAuthLayer`] + [`BearerAuthService`] — the tower
//! [`Layer`]/[`Service`] pair carrying the perimeter authentication
//! boundary at the HTTP edge.
//!
//! The layer mounts on an axum router via `.layer(...)`; per request
//! the service extracts the Bearer source (Authorization header > the
//! consumer-named cookie), invokes
//! [`super::AuthProvider::verify_token`] once, and either inserts the
//! resulting session as a request extension or short-circuits with the
//! appropriate HTTP disposition (401 + cookie clearance / 503 cookies
//! preserved).

use std::marker::PhantomData;
use std::sync::Arc;
use std::task::{Context, Poll};

use axum::body::Body;
use axum::http::header::{AUTHORIZATION, COOKIE};
use axum::http::{HeaderMap, Request, Response, StatusCode};
use axum::response::IntoResponse;
use axum_extra::extract::cookie::CookieJar;
use tower::{Layer, Service};

use super::config::BearerAuthConfig;
use super::error::VerifyError;
use super::port::AuthProvider;

/// Tower [`Layer`] mounting the perimeter authentication boundary.
///
/// Wrap an axum router with `.layer(BearerAuthLayer::new(provider, config))`.
/// Mount at HTTP-edge granularity, not per-route — every authenticated
/// transport should sit behind this layer. Routes that need anonymous
/// access (health endpoints, the OIDC RP sub-router itself, public
/// landing pages) stay outside.
///
/// Two generic parameters:
/// - `Sess` — the consumer's perimeter session type. Whatever
///   [`AuthProvider::verify_token`] returns lands in
///   `req.extensions()` for handlers to read via
///   `req.extensions().get::<Sess>()`.
/// - `P: ?Sized` — the [`AuthProvider`] impl. Both concrete types
///   (`Arc<MyProvider>`) and trait objects (`Arc<dyn AuthProvider<Sess>>`)
///   are supported.
pub struct BearerAuthLayer<Sess, P: ?Sized>
where
    Sess: Clone + Send + Sync + 'static,
    P: AuthProvider<Sess> + 'static,
{
    provider: Arc<P>,
    config: BearerAuthConfig,
    // `fn() -> Sess` is `Send + Sync` regardless of `Sess`'s own
    // auto-trait disposition, and signals "this layer logically
    // produces `Sess` values" rather than "owns" them.
    _session: PhantomData<fn() -> Sess>,
}

impl<Sess, P: ?Sized> BearerAuthLayer<Sess, P>
where
    Sess: Clone + Send + Sync + 'static,
    P: AuthProvider<Sess> + 'static,
{
    /// Build the Layer from an [`AuthProvider`] handle and a
    /// [`BearerAuthConfig`]. Production wiring at app boot looks like:
    ///
    /// ```ignore
    /// let provider: Arc<dyn AuthProvider<MySession>> =
    ///     Arc::new(MyConcreteProvider::new(verifier, db_pool));
    /// let layer = BearerAuthLayer::new(
    ///     provider,
    ///     BearerAuthConfig::new(
    ///         my_cookies::ACCESS_COOKIE,
    ///         Arc::new(|jar| my_cookies::clear_session_cookies(jar)),
    ///     ),
    /// );
    /// app.layer(layer)
    /// ```
    #[must_use]
    pub fn new(provider: Arc<P>, config: BearerAuthConfig) -> Self {
        Self {
            provider,
            config,
            _session: PhantomData,
        }
    }
}

// Manual `Clone` — `#[derive(Clone)]` would generate `where P: Clone`,
// which fails for `dyn AuthProvider<Sess>`. The manual impl needs only
// `Arc<P>: Clone` and `BearerAuthConfig: Clone`, both unconditional.
impl<Sess, P: ?Sized> Clone for BearerAuthLayer<Sess, P>
where
    Sess: Clone + Send + Sync + 'static,
    P: AuthProvider<Sess> + 'static,
{
    fn clone(&self) -> Self {
        Self {
            provider: self.provider.clone(),
            config: self.config.clone(),
            _session: PhantomData,
        }
    }
}

impl<Inner, Sess, P: ?Sized> Layer<Inner> for BearerAuthLayer<Sess, P>
where
    Sess: Clone + Send + Sync + 'static,
    P: AuthProvider<Sess> + 'static,
{
    type Service = BearerAuthService<Inner, Sess, P>;

    fn layer(&self, inner: Inner) -> Self::Service {
        BearerAuthService {
            inner,
            provider: self.provider.clone(),
            config: self.config.clone(),
            _session: PhantomData,
        }
    }
}

/// Tower [`Service`] produced by [`BearerAuthLayer::layer`].
///
/// One instance per `Layer::layer` call; cloned by axum across futures
/// per the standard tower middleware idiom.
pub struct BearerAuthService<Inner, Sess, P: ?Sized>
where
    Sess: Clone + Send + Sync + 'static,
    P: AuthProvider<Sess> + 'static,
{
    inner: Inner,
    provider: Arc<P>,
    config: BearerAuthConfig,
    _session: PhantomData<fn() -> Sess>,
}

impl<Inner, Sess, P: ?Sized> Clone for BearerAuthService<Inner, Sess, P>
where
    Inner: Clone,
    Sess: Clone + Send + Sync + 'static,
    P: AuthProvider<Sess> + 'static,
{
    fn clone(&self) -> Self {
        Self {
            inner: self.inner.clone(),
            provider: self.provider.clone(),
            config: self.config.clone(),
            _session: PhantomData,
        }
    }
}

impl<Inner, Sess, P: ?Sized> Service<Request<Body>> for BearerAuthService<Inner, Sess, P>
where
    Inner: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
    Inner::Future: Send + 'static,
    Sess: Clone + Send + Sync + 'static,
    P: AuthProvider<Sess> + 'static,
{
    type Response = Response<Body>;
    type Error = Inner::Error;
    type Future = std::pin::Pin<
        Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
    >;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: Request<Body>) -> Self::Future {
        let provider = self.provider.clone();
        let cookie_name = self.config.access_cookie_name;
        let on_clear = self.config.on_clear.clone();
        // Standard tower middleware idiom: the future captures a clone
        // of `inner` so it doesn't borrow `self`. axum tolerates the
        // re-clone; the alternative (`mem::replace`) introduces more
        // ceremony than value at this layer.
        let mut inner = self.inner.clone();
        Box::pin(async move {
            let token = match extract_bearer(req.headers(), cookie_name) {
                Some(t) => t,
                None => return Ok(unauthenticated_response(false, &on_clear)),
            };

            match provider.verify_token(&token).await {
                Ok(session) => {
                    req.extensions_mut().insert(session);
                    inner.call(req).await
                }
                Err(VerifyError::SubstrateTransient(reason)) => {
                    tracing::warn!(reason = %reason, "auth substrate transient — 503");
                    Ok(transient_response())
                }
                Err(VerifyError::Rejected(reason)) => {
                    tracing::debug!(reason = %reason, "token rejected at perimeter — 401 + clear");
                    Ok(unauthenticated_response(true, &on_clear))
                }
            }
        })
    }
}

/// Extract the Bearer token: Authorization header > consumer's cookie.
///
/// Authorization is preferred and case-insensitive on the scheme
/// (RFC 7235 §2.1). Cookie value is passed verbatim — no %-decoding,
/// no trimming, no transformation. Empty token strings are treated as
/// absent so an attacker can't replay an empty cookie to coerce
/// "clear cookies" behaviour.
fn extract_bearer(headers: &HeaderMap, cookie_name: &'static str) -> Option<String> {
    if let Some(value) = headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok())
        && let Some(token) = value
            .strip_prefix("Bearer ")
            .or_else(|| value.strip_prefix("bearer "))
        && !token.is_empty()
    {
        return Some(token.to_owned());
    }
    let cookie_header = headers.get(COOKIE).and_then(|v| v.to_str().ok())?;
    cookie_header
        .split(';')
        .filter_map(|s| s.trim().split_once('='))
        .find(|(name, _)| *name == cookie_name)
        .map(|(_, value)| value.to_owned())
        .filter(|v| !v.is_empty())
}

/// 401 response. With `clear_cookies` set, the consumer's `on_clear`
/// closure is invoked on a fresh [`CookieJar`] and its add-list
/// becomes the response's Set-Cookie headers — independent of the
/// original request's cookie state.
fn unauthenticated_response(
    clear_cookies: bool,
    on_clear: &Arc<dyn Fn(CookieJar) -> CookieJar + Send + Sync>,
) -> Response<Body> {
    if clear_cookies {
        let jar = on_clear(CookieJar::new());
        (StatusCode::UNAUTHORIZED, jar, "unauthenticated").into_response()
    } else {
        (StatusCode::UNAUTHORIZED, "unauthenticated").into_response()
    }
}

/// 503 response — auth substrate transient. Cookies preserved; browser
/// may retry.
fn transient_response() -> Response<Body> {
    (
        StatusCode::SERVICE_UNAVAILABLE,
        "auth substrate temporarily unavailable",
    )
        .into_response()
}