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/// A static JSON document served at a fixed path (an OIDC metadata document or a
78/// JWKS document).
79#[derive(Debug, Clone)]
80pub struct MetadataDocument {
81 /// Path to serve at (e.g. `/.well-known/openid-configuration`).
82 pub path: String,
83 /// JSON body.
84 pub json: serde_json::Value,
85}
86
87impl MetadataDocument {
88 /// Construct a metadata document.
89 pub fn new(path: impl Into<String>, json: serde_json::Value) -> Self {
90 Self {
91 path: path.into(),
92 json,
93 }
94 }
95}
96
97/// Backing for the *stateless* OIDC surface the proxy hosts.
98///
99/// The proxy owns the HTTP routes (discovery, JWKS, userinfo); the embedder
100/// supplies their content from its own key/client metadata. No `authorize` /
101/// `token` here: those are stateful and out of scope for the data plane.
102#[async_trait]
103pub trait OidcBackend: Send + Sync {
104 /// Static metadata documents to serve as `GET` routes, e.g. the
105 /// `openid-configuration` and any provider-specific discovery document.
106 fn metadata_documents(&self) -> Vec<MetadataDocument>;
107
108 /// The JWKS document and the path it is advertised at.
109 fn jwks(&self) -> MetadataDocument;
110
111 /// The path of the UserInfo endpoint. Defaults to `/userinfo`.
112 fn userinfo_path(&self) -> String {
113 "/userinfo".to_string()
114 }
115
116 /// Resolve UserInfo claims for a bearer token. `None` yields `401`.
117 ///
118 /// `bearer` is always a present, non-empty token (the `Bearer ` prefix
119 /// already stripped): a request with no credentials is rejected with a
120 /// `401` Bearer challenge before this method is called, so implementations
121 /// never receive an empty string.
122 async fn userinfo(&self, bearer: &str) -> Option<serde_json::Value>;
123}
124
125/// Owned view of a request handed to an [`ExtraRouteHandler`].
126///
127/// Unlike [`RequestParts`], this owns its data (including the full body), since
128/// an extra route may consume the body to produce a response.
129#[derive(Debug)]
130pub struct RouteRequest {
131 /// Request method.
132 pub method: Method,
133 /// Full request URI (path + query).
134 pub uri: http::Uri,
135 /// Request headers.
136 pub headers: HeaderMap,
137 /// Request body bytes.
138 pub body: Bytes,
139 /// Direct peer socket address.
140 pub peer: SocketAddr,
141}
142
143/// Response produced by an [`ExtraRouteHandler`].
144pub struct RouteResponse {
145 /// HTTP status.
146 pub status: StatusCode,
147 /// Response headers.
148 pub headers: HeaderMap,
149 /// Response body bytes.
150 pub body: Bytes,
151}
152
153impl RouteResponse {
154 /// A response with the given status and body and no extra headers.
155 pub fn new(status: StatusCode, body: impl Into<Bytes>) -> Self {
156 Self {
157 status,
158 headers: HeaderMap::new(),
159 body: body.into(),
160 }
161 }
162}
163
164/// A stateless handler for an extra route registered via
165/// [`ProxyServer::with_extra_routes`](crate::ProxyServer::with_extra_routes).
166///
167/// The framework-agnostic seam (request parts in, response parts out) the
168/// embedder uses for service-specific endpoints without naming `axum`.
169#[async_trait]
170pub trait ExtraRouteHandler: Send + Sync {
171 /// Handle a request and produce a response.
172 async fn handle(&self, req: RouteRequest) -> RouteResponse;
173}
174
175/// A single extra route: a method, a path, and the handler to run.
176#[derive(Clone)]
177pub struct ExtraRoute {
178 pub(crate) method: Method,
179 pub(crate) path: String,
180 pub(crate) handler: Arc<dyn ExtraRouteHandler>,
181}
182
183impl ExtraRoute {
184 /// Register `handler` for `method` requests to `path`.
185 pub fn new(
186 method: Method,
187 path: impl Into<String>,
188 handler: Arc<dyn ExtraRouteHandler>,
189 ) -> Self {
190 Self {
191 method,
192 path: path.into(),
193 handler,
194 }
195 }
196}