Skip to main content

structured_proxy/
hooks.rs

1//! Framework-agnostic extension points for embedding the proxy.
2//!
3//! These traits let an embedding crate inject *stateless* service-specific logic
4//! (a forward-auth/PDP decision, an OIDC discovery/JWKS/userinfo backing, extra
5//! routes) without naming an HTTP framework in its own code or `Cargo.toml`.
6//! All signatures use the foundational [`http`] crate (already in the tree via
7//! both `axum` and `tonic`), [`bytes::Bytes`], and `serde_json::Value` (never an
8//! `axum` type), so `cargo tree -i axum` in an embedder shows axum only under
9//! `structured-proxy`.
10//!
11//! [`AuthDecider`] and [`TokenVerifier`] are for traffic whose authentication
12//! and authorization are left to the proxy. An application with its own
13//! authn/authz needs neither for requests that reach its service; its checks
14//! never see the proxy's own routes, which take an [`AuthDecider`] scoped to
15//! them or stay off.
16//!
17//! Stateful concerns (BFF sessions, OIDC `authorize`/`token`) are deliberately
18//! absent: the default build is a stateless data plane (see the crate README
19//! Non-goals). They are planned behind an opt-in `bff` feature.
20
21use std::sync::Arc;
22
23use async_trait::async_trait;
24use bytes::Bytes;
25use http::{HeaderMap, Method, StatusCode};
26
27use crate::client_address::ClientAddress;
28
29/// Borrowed view of an incoming request, passed to an [`AuthDecider`].
30///
31/// All fields borrow from the live request: building this is allocation-free, so
32/// the per-request gate stays cheap. The body is intentionally absent: an auth
33/// decision is taken from method, path, query, headers, and client alone.
34///
35/// # Examples
36///
37/// ```
38/// use structured_proxy::hooks::RequestParts;
39/// use structured_proxy::ClientAddress;
40///
41/// // The view a decider's own tests build.
42/// let client = ClientAddress::from_peer(Some("203.0.113.7:51234".parse().unwrap()));
43/// let headers = http::HeaderMap::new();
44/// let parts = RequestParts::new(&http::Method::GET, "/v1/things", None, &headers, &client);
45/// assert_eq!(parts.client.ip(), Some("203.0.113.7".parse().unwrap()));
46/// ```
47#[derive(Debug)]
48#[non_exhaustive]
49pub struct RequestParts<'a> {
50    /// Request method (the *original* method on the `/verify` path, recovered
51    /// from the fronting proxy's forwarding headers).
52    pub method: &'a Method,
53    /// Request path, query stripped.
54    pub path: &'a str,
55    /// Raw query string, if any (without the leading `?`).
56    pub query: Option<&'a str>,
57    /// Request headers, with the forwarding headers as the
58    /// `client_address.forward` policy wrote them for the upstream. With the
59    /// default policy they hold only verified data; with `append` or
60    /// `preserve`, `X-Forwarded-For` (and with `preserve`, `X-Real-IP`) still
61    /// carries what the client sent. Decide on [`client`](Self::client), never
62    /// on these headers.
63    pub headers: &'a HeaderMap,
64    /// The client address the proxy resolved, with the connection's peer
65    /// (the client, or the fronting proxy): the one to authorize on, whatever
66    /// the forwarding policy.
67    pub client: &'a ClientAddress,
68}
69
70impl<'a> RequestParts<'a> {
71    /// The view of a request: what the proxy hands a decider, and what a
72    /// decider's own tests build.
73    pub fn new(
74        method: &'a Method,
75        path: &'a str,
76        query: Option<&'a str>,
77        headers: &'a HeaderMap,
78        client: &'a ClientAddress,
79    ) -> Self {
80        Self {
81            method,
82            path,
83            query,
84            headers,
85            client,
86        }
87    }
88}
89
90/// The outcome of an [`AuthDecider`] evaluation.
91#[non_exhaustive]
92pub enum Decision {
93    /// Allow the request; merge these (decider-controlled) headers onto it before
94    /// it continues upstream. The proxy strips any client-supplied copies of
95    /// these header names first, so a client cannot forge them.
96    Allow {
97        /// Headers to inject for the upstream (e.g. a verified `x-user-id`).
98        inject_headers: HeaderMap,
99    },
100    /// Reject the request with this status and body (served as `application/json`).
101    Deny {
102        /// HTTP status to return (e.g. 401 / 403).
103        status: StatusCode,
104        /// Response body bytes.
105        body: Bytes,
106    },
107    /// Redirect the client (e.g. to a login URL); returned as `302 Found`.
108    Redirect {
109        /// Absolute or relative `Location` URL.
110        location: String,
111    },
112}
113
114/// The per-request authorization gate.
115///
116/// Implemented by the embedder for its forward-auth / policy-decision logic
117/// (e.g. JWT verification + a policy engine + header translation). Called inline
118/// on every proxied request *and* by the `/verify` forward-auth endpoint: same
119/// trait, two call sites.
120#[async_trait]
121pub trait AuthDecider: Send + Sync {
122    /// Decide whether to allow, deny, or redirect the request.
123    async fn decide(&self, req: &RequestParts<'_>) -> Decision;
124}
125
126/// Verifies a bearer token and yields its claims.
127///
128/// This is the seam the JWT middleware validates through. The built-in
129/// implementation (`jsonwebtoken`, keys from `auth.jwt`) is what a plain
130/// config-driven deployment gets; an embedder injects its own through
131/// [`ProxyServer::with_token_verifier`](crate::ProxyServer::with_token_verifier)
132/// when it needs a different signature backend — a validated / FIPS module, an
133/// HSM, a shared verifier it already owns.
134///
135/// Injecting one is what makes the crypto backend a property of the *binary*
136/// rather than of the dependency graph: Cargo unifies features across the whole
137/// resolution, so two consumers of this crate that want different built-in
138/// backends cannot coexist, while two consumers that inject their own verifiers
139/// can. A build that injects one needs no crypto backend feature at all
140/// (`default-features = false`), and then links no JWT crypto.
141///
142/// Everything around verification stays with the proxy: route policies
143/// (`require_auth` / `required_roles`), the roles claim, and the claim→header
144/// forwarding all operate on the returned claims.
145#[async_trait]
146pub trait TokenVerifier: Send + Sync {
147    /// Verify `token` and return its claims, or `None` to reject the request
148    /// with `401`.
149    ///
150    /// `token` is the raw JWT from the `Authorization: Bearer` header, already
151    /// stripped of the prefix and guaranteed non-empty. The implementation owns
152    /// the whole check — signature, `exp`/`nbf`, issuer, audience — since only
153    /// it knows which of those its keys and policy imply. Returning claims for
154    /// a token whose signature was not verified would hand a forged identity to
155    /// the upstream.
156    async fn verify(&self, token: &str) -> Option<serde_json::Value>;
157}
158
159/// A static JSON document served at a fixed path (an OIDC metadata document or a
160/// JWKS document).
161#[derive(Debug, Clone)]
162#[non_exhaustive]
163pub struct MetadataDocument {
164    /// Path to serve at (e.g. `/.well-known/openid-configuration`).
165    pub path: String,
166    /// JSON body.
167    pub json: serde_json::Value,
168}
169
170impl MetadataDocument {
171    /// Construct a metadata document.
172    pub fn new(path: impl Into<String>, json: serde_json::Value) -> Self {
173        Self {
174            path: path.into(),
175            json,
176        }
177    }
178}
179
180/// Backing for the *stateless* OIDC surface the proxy hosts.
181///
182/// The proxy owns the HTTP routes (discovery, JWKS, userinfo); the embedder
183/// supplies their content from its own key/client metadata. No `authorize` /
184/// `token` here: those are stateful and out of scope for the data plane.
185#[async_trait]
186pub trait OidcBackend: Send + Sync {
187    /// Static metadata documents to serve as `GET` routes, e.g. the
188    /// `openid-configuration` and any provider-specific discovery document.
189    fn metadata_documents(&self) -> Vec<MetadataDocument>;
190
191    /// The JWKS document and the path it is advertised at.
192    fn jwks(&self) -> MetadataDocument;
193
194    /// The path of the UserInfo endpoint. Defaults to `/userinfo`.
195    fn userinfo_path(&self) -> String {
196        "/userinfo".to_string()
197    }
198
199    /// Resolve UserInfo claims for a bearer token. `None` yields `401`.
200    ///
201    /// `bearer` is always a present, non-empty token (the `Bearer ` prefix
202    /// already stripped): a request with no credentials is rejected with a
203    /// `401` Bearer challenge before this method is called, so implementations
204    /// never receive an empty string.
205    async fn userinfo(&self, bearer: &str) -> Option<serde_json::Value>;
206}
207
208/// Owned view of a request handed to an [`ExtraRouteHandler`].
209///
210/// Unlike [`RequestParts`], this owns its data (including the full body), since
211/// an extra route may consume the body to produce a response.
212#[derive(Debug)]
213#[non_exhaustive]
214pub struct RouteRequest {
215    /// Request method.
216    pub method: Method,
217    /// Full request URI (path + query).
218    pub uri: http::Uri,
219    /// Request headers, with the forwarding headers as the
220    /// `client_address.forward` policy wrote them (see
221    /// [`RequestParts::headers`]). Decide on [`client`](Self::client).
222    pub headers: HeaderMap,
223    /// Request body bytes.
224    pub body: Bytes,
225    /// The client address the proxy resolved, with the connection's peer.
226    pub client: ClientAddress,
227}
228
229impl RouteRequest {
230    /// The request a handler gets: what the proxy builds, and what a
231    /// handler's own tests build.
232    pub fn new(
233        method: Method,
234        uri: http::Uri,
235        headers: HeaderMap,
236        body: Bytes,
237        client: ClientAddress,
238    ) -> Self {
239        Self {
240            method,
241            uri,
242            headers,
243            body,
244            client,
245        }
246    }
247}
248
249/// Response produced by an [`ExtraRouteHandler`].
250#[non_exhaustive]
251pub struct RouteResponse {
252    /// HTTP status.
253    pub status: StatusCode,
254    /// Response headers.
255    pub headers: HeaderMap,
256    /// Response body bytes.
257    pub body: Bytes,
258}
259
260impl RouteResponse {
261    /// A response with the given status and body and no extra headers.
262    pub fn new(status: StatusCode, body: impl Into<Bytes>) -> Self {
263        Self {
264            status,
265            headers: HeaderMap::new(),
266            body: body.into(),
267        }
268    }
269}
270
271/// A stateless handler for an extra route registered via
272/// [`ProxyServer::with_extra_routes`](crate::ProxyServer::with_extra_routes).
273///
274/// The framework-agnostic seam (request parts in, response parts out) the
275/// embedder uses for service-specific endpoints without naming `axum`.
276#[async_trait]
277pub trait ExtraRouteHandler: Send + Sync {
278    /// Handle a request and produce a response.
279    async fn handle(&self, req: RouteRequest) -> RouteResponse;
280}
281
282/// A single extra route: a method, a path, and the handler to run.
283#[derive(Clone)]
284pub struct ExtraRoute {
285    pub(crate) method: Method,
286    pub(crate) path: String,
287    pub(crate) handler: Arc<dyn ExtraRouteHandler>,
288}
289
290impl ExtraRoute {
291    /// Register `handler` for `method` requests to `path`.
292    pub fn new(
293        method: Method,
294        path: impl Into<String>,
295        handler: Arc<dyn ExtraRouteHandler>,
296    ) -> Self {
297        Self {
298            method,
299            path: path.into(),
300            handler,
301        }
302    }
303}