Skip to main content

ppoppo_sdk_core/bearer/
layer.rs

1//! [`BearerAuthLayer`] + [`BearerAuthService`] — the tower
2//! [`Layer`]/[`Service`] pair carrying the perimeter authentication
3//! boundary at the HTTP edge.
4//!
5//! The layer mounts on an axum router via `.layer(...)`; per request
6//! the service extracts the Bearer source (Authorization header > the
7//! consumer-named cookie), invokes
8//! [`super::AuthProvider::verify_token`] once, and either inserts the
9//! resulting session as a request extension or short-circuits with the
10//! appropriate HTTP disposition (401 + cookie clearance / 503 cookies
11//! preserved).
12
13use std::marker::PhantomData;
14use std::sync::Arc;
15use std::task::{Context, Poll};
16
17use axum::body::Body;
18use axum::http::header::{AUTHORIZATION, COOKIE};
19use axum::http::{HeaderMap, Request, Response, StatusCode};
20use axum::response::IntoResponse;
21use axum_extra::extract::cookie::CookieJar;
22use tower::{Layer, Service};
23
24use super::config::BearerAuthConfig;
25use super::error::VerifyError;
26use super::port::AuthProvider;
27
28/// Tower [`Layer`] mounting the perimeter authentication boundary.
29///
30/// Wrap an axum router with `.layer(BearerAuthLayer::new(provider, config))`.
31/// Mount at HTTP-edge granularity, not per-route — every authenticated
32/// transport should sit behind this layer. Routes that need anonymous
33/// access (health endpoints, the OIDC RP sub-router itself, public
34/// landing pages) stay outside.
35///
36/// Two generic parameters:
37/// - `Sess` — the consumer's perimeter session type. Whatever
38///   [`AuthProvider::verify_token`] returns lands in
39///   `req.extensions()` for handlers to read via
40///   `req.extensions().get::<Sess>()`.
41/// - `P: ?Sized` — the [`AuthProvider`] impl. Both concrete types
42///   (`Arc<MyProvider>`) and trait objects (`Arc<dyn AuthProvider<Sess>>`)
43///   are supported.
44pub struct BearerAuthLayer<Sess, P: ?Sized>
45where
46    Sess: Clone + Send + Sync + 'static,
47    P: AuthProvider<Sess> + 'static,
48{
49    provider: Arc<P>,
50    config: BearerAuthConfig,
51    // `fn() -> Sess` is `Send + Sync` regardless of `Sess`'s own
52    // auto-trait disposition, and signals "this layer logically
53    // produces `Sess` values" rather than "owns" them.
54    _session: PhantomData<fn() -> Sess>,
55}
56
57impl<Sess, P: ?Sized> BearerAuthLayer<Sess, P>
58where
59    Sess: Clone + Send + Sync + 'static,
60    P: AuthProvider<Sess> + 'static,
61{
62    /// Build the Layer from an [`AuthProvider`] handle and a
63    /// [`BearerAuthConfig`]. Production wiring at app boot looks like:
64    ///
65    /// ```ignore
66    /// let provider: Arc<dyn AuthProvider<MySession>> =
67    ///     Arc::new(MyConcreteProvider::new(verifier, db_pool));
68    /// let layer = BearerAuthLayer::new(
69    ///     provider,
70    ///     BearerAuthConfig::new(
71    ///         my_cookies::ACCESS_COOKIE,
72    ///         Arc::new(|jar| my_cookies::clear_session_cookies(jar)),
73    ///         [].into(), // path_exempt — exempt nothing (fail-closed default)
74    ///     ),
75    /// );
76    /// app.layer(layer)
77    /// ```
78    #[must_use]
79    pub fn new(provider: Arc<P>, config: BearerAuthConfig) -> Self {
80        Self {
81            provider,
82            config,
83            _session: PhantomData,
84        }
85    }
86}
87
88// Manual `Clone` — `#[derive(Clone)]` would generate `where P: Clone`,
89// which fails for `dyn AuthProvider<Sess>`. The manual impl needs only
90// `Arc<P>: Clone` and `BearerAuthConfig: Clone`, both unconditional.
91impl<Sess, P: ?Sized> Clone for BearerAuthLayer<Sess, P>
92where
93    Sess: Clone + Send + Sync + 'static,
94    P: AuthProvider<Sess> + 'static,
95{
96    fn clone(&self) -> Self {
97        Self {
98            provider: self.provider.clone(),
99            config: self.config.clone(),
100            _session: PhantomData,
101        }
102    }
103}
104
105impl<Inner, Sess, P: ?Sized> Layer<Inner> for BearerAuthLayer<Sess, P>
106where
107    Sess: Clone + Send + Sync + 'static,
108    P: AuthProvider<Sess> + 'static,
109{
110    type Service = BearerAuthService<Inner, Sess, P>;
111
112    fn layer(&self, inner: Inner) -> Self::Service {
113        BearerAuthService {
114            inner,
115            provider: self.provider.clone(),
116            config: self.config.clone(),
117            _session: PhantomData,
118        }
119    }
120}
121
122/// Tower [`Service`] produced by [`BearerAuthLayer::layer`].
123///
124/// One instance per `Layer::layer` call; cloned by axum across futures
125/// per the standard tower middleware idiom.
126pub struct BearerAuthService<Inner, Sess, P: ?Sized>
127where
128    Sess: Clone + Send + Sync + 'static,
129    P: AuthProvider<Sess> + 'static,
130{
131    inner: Inner,
132    provider: Arc<P>,
133    config: BearerAuthConfig,
134    _session: PhantomData<fn() -> Sess>,
135}
136
137impl<Inner, Sess, P: ?Sized> Clone for BearerAuthService<Inner, Sess, P>
138where
139    Inner: Clone,
140    Sess: Clone + Send + Sync + 'static,
141    P: AuthProvider<Sess> + 'static,
142{
143    fn clone(&self) -> Self {
144        Self {
145            inner: self.inner.clone(),
146            provider: self.provider.clone(),
147            config: self.config.clone(),
148            _session: PhantomData,
149        }
150    }
151}
152
153impl<Inner, Sess, P: ?Sized> Service<Request<Body>> for BearerAuthService<Inner, Sess, P>
154where
155    Inner: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
156    Inner::Future: Send + 'static,
157    Sess: Clone + Send + Sync + 'static,
158    P: AuthProvider<Sess> + 'static,
159{
160    type Response = Response<Body>;
161    type Error = Inner::Error;
162    type Future = std::pin::Pin<
163        Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
164    >;
165
166    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
167        self.inner.poll_ready(cx)
168    }
169
170    fn call(&mut self, mut req: Request<Body>) -> Self::Future {
171        // Standard tower middleware idiom: the future captures a clone
172        // of `inner` so it doesn't borrow `self`. axum tolerates the
173        // re-clone; the alternative (`mem::replace`) introduces more
174        // ceremony than value at this layer.
175        let mut inner = self.inner.clone();
176
177        // Pre-auth path exemption — checked BEFORE any Bearer
178        // extraction. A request whose exact URI path is on the
179        // consumer's `path_exempt` allowlist skips the perimeter
180        // entirely: no token read, no `verify_token`, no session
181        // injected. This is a *skip*, not a second auth path — the
182        // single-verify-site invariant holds because the exempt
183        // request returns here, before the verify branch below ever
184        // runs. The sole use is anonymous-by-contract routes (native
185        // gRPC health probes); see [`BearerAuthConfig::path_exempt`]
186        // and [`is_path_exempt`] for the exact-match contract.
187        if is_path_exempt(&self.config.path_exempt, req.uri().path()) {
188            return Box::pin(async move { inner.call(req).await });
189        }
190
191        let provider = self.provider.clone();
192        let cookie_name = self.config.access_cookie_name;
193        let on_clear = self.config.on_clear.clone();
194        Box::pin(async move {
195            let token = match extract_bearer(req.headers(), cookie_name) {
196                Some(t) => t,
197                None => return Ok(unauthenticated_response(false, &on_clear)),
198            };
199
200            match provider.verify_token(&token).await {
201                Ok(session) => {
202                    req.extensions_mut().insert(session);
203                    inner.call(req).await
204                }
205                Err(VerifyError::SubstrateTransient(reason)) => {
206                    tracing::warn!(reason = %reason, "auth substrate transient — 503");
207                    Ok(transient_response())
208                }
209                Err(VerifyError::Rejected(reason)) => {
210                    tracing::debug!(reason = %reason, "token rejected at perimeter — 401 + clear");
211                    Ok(unauthenticated_response(true, &on_clear))
212                }
213            }
214        })
215    }
216}
217
218/// Exact full-path match — the sole `path_exempt` matching site.
219///
220/// `==` only, NEVER a prefix/substring: a prefix would be a fail-open
221/// over-exemption, silently admitting any sibling RPC that shares the
222/// service prefix. An empty `exempt` slice matches nothing, so every
223/// route authenticates (the fail-closed default). Centralised here so
224/// the exact-match invariant has one greppable home and one regression
225/// target (`bearer_boundary.rs` prefix-sibling test). See
226/// [`BearerAuthConfig::path_exempt`].
227// `exempt.contains(&path)` doesn't typecheck: the slice elements are
228// `&'static str` while `path` is a shorter-lived `&str`, so clippy's
229// `manual_contains` suggestion is a false positive here.
230#[allow(clippy::manual_contains)]
231fn is_path_exempt(exempt: &[&'static str], path: &str) -> bool {
232    exempt.iter().any(|entry| *entry == path)
233}
234
235/// Extract the Bearer token: Authorization header > consumer's cookie.
236///
237/// Authorization is preferred and case-insensitive on the scheme
238/// (RFC 7235 §2.1). Cookie value is passed verbatim — no %-decoding,
239/// no trimming, no transformation. Empty token strings are treated as
240/// absent so an attacker can't replay an empty cookie to coerce
241/// "clear cookies" behaviour.
242fn extract_bearer(headers: &HeaderMap, cookie_name: &'static str) -> Option<String> {
243    if let Some(value) = headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok())
244        && let Some(token) = value
245            .strip_prefix("Bearer ")
246            .or_else(|| value.strip_prefix("bearer "))
247        && !token.is_empty()
248    {
249        return Some(token.to_owned());
250    }
251    let cookie_header = headers.get(COOKIE).and_then(|v| v.to_str().ok())?;
252    cookie_header
253        .split(';')
254        .filter_map(|s| s.trim().split_once('='))
255        .find(|(name, _)| *name == cookie_name)
256        .map(|(_, value)| value.to_owned())
257        .filter(|v| !v.is_empty())
258}
259
260/// 401 response. With `clear_cookies` set, the consumer's `on_clear`
261/// closure is invoked on a fresh [`CookieJar`] and its add-list
262/// becomes the response's Set-Cookie headers — independent of the
263/// original request's cookie state.
264fn unauthenticated_response(
265    clear_cookies: bool,
266    on_clear: &Arc<dyn Fn(CookieJar) -> CookieJar + Send + Sync>,
267) -> Response<Body> {
268    if clear_cookies {
269        let jar = on_clear(CookieJar::new());
270        (StatusCode::UNAUTHORIZED, jar, "unauthenticated").into_response()
271    } else {
272        (StatusCode::UNAUTHORIZED, "unauthenticated").into_response()
273    }
274}
275
276/// 503 response — auth substrate transient. Cookies preserved; browser
277/// may retry.
278fn transient_response() -> Response<Body> {
279    (
280        StatusCode::SERVICE_UNAVAILABLE,
281        "auth substrate temporarily unavailable",
282    )
283        .into_response()
284}