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