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}