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}