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