codex_app_server_client/rest/auth.rs
1//! Optional bearer-token auth layer for the REST adapter.
2//!
3//! **This is transport auth only.** It answers exactly one question: did the
4//! caller present the configured secret? It says nothing about
5//! authorization (which sessions/methods a caller may touch), multi-tenant
6//! isolation, or sandboxing of the underlying `codex app-server` process -
7//! those remain entirely the host application's responsibility, same as
8//! documented for the REST adapter as a whole in README.md. A caller that
9//! presents the one shared token gets everything the mounted router exposes.
10//!
11//! ```rust,no_run
12//! use codex_app_server_client::rest;
13//!
14//! # fn build() -> axum::Router {
15//! rest::trusted_bridge_router().layer(rest::bearer_auth("super-secret-token"))
16//! # }
17//! ```
18
19use std::{
20 convert::Infallible,
21 future::Future,
22 pin::Pin,
23 sync::Arc,
24 task::{Context, Poll},
25};
26
27use axum::{
28 extract::Request,
29 http::{header, HeaderMap, HeaderValue, StatusCode},
30 response::{IntoResponse, Json, Response},
31};
32use tower_layer::Layer;
33use tower_service::Service;
34
35use super::types::RestErrorResponse;
36
37/// Builds a [`BearerAuthLayer`] that requires `Authorization: Bearer <token>`
38/// on every request except (by default) the health routes.
39///
40/// # Panics
41///
42/// Panics if `token` is empty or contains only whitespace. A blank
43/// configured secret is never intentional - it is either a wiring bug (the
44/// real token wasn't threaded through, e.g. an empty env var) or it would
45/// accept a blank `Authorization: Bearer` header, which is functionally no
46/// auth at all while looking like auth is configured. Failing fast at
47/// construction is safer than accepting a broken configuration and starting
48/// to accept blank tokens.
49pub fn bearer_auth(token: impl Into<String>) -> BearerAuthLayer {
50 BearerAuthLayer::new(token)
51}
52
53/// [`tower_layer::Layer`] that wraps a router (or any inner `tower` service)
54/// with bearer-token auth.
55///
56/// Construct with [`bearer_auth`]. Use [`Self::allow_unauthenticated_health`]
57/// to change whether `GET /health` and `GET /v1/health` require the token
58/// too (they don't, by default - see that method's docs for why).
59#[derive(Clone)]
60pub struct BearerAuthLayer {
61 token: Arc<[u8]>,
62 allow_unauthenticated_health: bool,
63}
64
65impl BearerAuthLayer {
66 fn new(token: impl Into<String>) -> Self {
67 let token = token.into();
68 assert!(
69 !token.trim().is_empty(),
70 "bearer_auth: configured token must not be empty or whitespace-only"
71 );
72 Self {
73 token: token.into_bytes().into(),
74 allow_unauthenticated_health: true,
75 }
76 }
77
78 /// Controls whether `GET /health` and `GET /v1/health` (and *only* those
79 /// two paths - not `GET /v1/compatibility`, which reveals the installed
80 /// `codex` version) are reachable without a token.
81 ///
82 /// Defaults to `true`: liveness probes rarely carry credentials, and a
83 /// bare "is the process up" response leaks nothing sensitive. Set this
84 /// to `false` for deployments where even that is unwanted, e.g. an
85 /// operator who wants every request authenticated uniformly regardless
86 /// of what it reveals.
87 pub fn allow_unauthenticated_health(mut self, allow: bool) -> Self {
88 self.allow_unauthenticated_health = allow;
89 self
90 }
91}
92
93impl<S> Layer<S> for BearerAuthLayer {
94 type Service = BearerAuthService<S>;
95
96 fn layer(&self, inner: S) -> Self::Service {
97 BearerAuthService {
98 inner,
99 token: self.token.clone(),
100 allow_unauthenticated_health: self.allow_unauthenticated_health,
101 }
102 }
103}
104
105/// The [`tower_service::Service`] produced by [`BearerAuthLayer`].
106///
107/// Public because it is the associated `Layer::Service` type of the public
108/// `BearerAuthLayer` - not meant to be constructed directly, use
109/// [`bearer_auth`].
110#[derive(Clone)]
111pub struct BearerAuthService<S> {
112 inner: S,
113 token: Arc<[u8]>,
114 allow_unauthenticated_health: bool,
115}
116
117impl<S> Service<Request> for BearerAuthService<S>
118where
119 S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + Sync + 'static,
120 S::Future: Send + 'static,
121{
122 type Response = Response;
123 type Error = Infallible;
124 type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
125
126 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
127 self.inner.poll_ready(cx)
128 }
129
130 fn call(&mut self, req: Request) -> Self::Future {
131 // `Service::call` takes `&mut self`, but the returned future is boxed
132 // and awaited independently of it, so the inner service has to be
133 // moved into the future rather than borrowed.
134 //
135 // Swap rather than plain-clone: `poll_ready` above reserved capacity
136 // on *`self.inner` specifically*, and tower's contract is that the
137 // very instance which returned `Poll::Ready` is the one that must
138 // receive the corresponding `call`. Handing the request to a fresh
139 // clone would abandon that reservation and let the request skip the
140 // inner service's backpressure. `mem::replace` moves the ready
141 // instance out to be called and leaves the not-yet-ready clone behind
142 // for the next `poll_ready`/`call` round-trip.
143 //
144 // This is invisible with an `axum::Router` underneath (its
145 // `poll_ready` is always `Ready`), but `S` is generic and a caller may
146 // well stack something load-shedding or rate-limiting below us.
147 let clone = self.inner.clone();
148 let mut inner = std::mem::replace(&mut self.inner, clone);
149
150 let exempt = self.allow_unauthenticated_health && is_exempt_health_path(req.uri().path());
151 if exempt || is_authorized(req.headers(), &self.token) {
152 Box::pin(async move { inner.call(req).await })
153 } else {
154 Box::pin(async move { Ok(unauthorized_response()) })
155 }
156 }
157}
158
159fn is_exempt_health_path(path: &str) -> bool {
160 matches!(path, "/health" | "/v1/health")
161}
162
163/// Checks the request's `Authorization` header against `expected`.
164///
165/// Always performs the same shape of work regardless of *why* the request
166/// would be rejected (missing header, non-Bearer scheme, wrong token): it
167/// extracts whatever token bytes are present (or an empty slice if none
168/// are) and always runs [`constant_time_eq`] against `expected`. That keeps
169/// "no header", "wrong scheme", and "right scheme, wrong token" from being
170/// distinguishable by branch structure - only [`constant_time_eq`] itself
171/// determines the answer.
172fn is_authorized(headers: &HeaderMap, expected: &[u8]) -> bool {
173 let provided = extract_bearer_token(headers).unwrap_or_default();
174 constant_time_eq(provided.as_bytes(), expected)
175}
176
177/// Extracts the token from a case-insensitive `Authorization: Bearer
178/// <token>` header. Returns `None` for a missing header, an unparsable
179/// value, or any scheme other than `Bearer`.
180fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
181 let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
182 let (scheme, token) = value.split_once(' ')?;
183 scheme.eq_ignore_ascii_case("bearer").then(|| token.trim())
184}
185
186/// Constant-time byte comparison for the configured secret.
187///
188/// Deliberately not `==`: slice equality short-circuits on the first
189/// differing byte (and on a length mismatch), which leaks timing
190/// information an attacker can use to recover the secret byte-by-byte. This
191/// compares every position up to `max(a.len(), b.len())` unconditionally -
192/// there is no early return on the first difference and no early return on
193/// a length mismatch (a length mismatch instead forces at least one
194/// differing "byte" up front, folded into the same `|=` accumulator as
195/// everything else) - so the amount of work done depends only on the
196/// lengths involved, never on *where* (or whether) the inputs diverge.
197///
198/// No crypto crate is pulled in for this; it's a handful of lines and this
199/// crate is deliberately dependency-minimal (see README.md).
200fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
201 let mut diff: u8 = u8::from(a.len() != b.len());
202 let max_len = a.len().max(b.len());
203 for index in 0..max_len {
204 let left = a.get(index).copied().unwrap_or(0);
205 let right = b.get(index).copied().unwrap_or(0);
206 diff |= left ^ right;
207 }
208 diff == 0
209}
210
211fn unauthorized_response() -> Response {
212 let mut response = (
213 StatusCode::UNAUTHORIZED,
214 Json(RestErrorResponse {
215 error: "unauthorized".to_owned(),
216 message: "a valid `Authorization: Bearer <token>` header is required".to_owned(),
217 code: None,
218 data: None,
219 }),
220 )
221 .into_response();
222 response
223 .headers_mut()
224 .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
225 response
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn constant_time_eq_matches_equal_slices() {
234 assert!(constant_time_eq(b"same-token", b"same-token"));
235 }
236
237 #[test]
238 fn constant_time_eq_rejects_different_content_same_length() {
239 assert!(!constant_time_eq(b"token-aaaa", b"token-bbbb"));
240 }
241
242 #[test]
243 fn constant_time_eq_rejects_different_lengths() {
244 assert!(!constant_time_eq(b"short", b"much-longer-token"));
245 assert!(!constant_time_eq(b"much-longer-token", b"short"));
246 }
247
248 #[test]
249 fn constant_time_eq_treats_two_empty_slices_as_equal() {
250 assert!(constant_time_eq(b"", b""));
251 }
252
253 #[test]
254 fn extract_bearer_token_is_case_insensitive_on_scheme() {
255 let mut headers = HeaderMap::new();
256 headers.insert(
257 header::AUTHORIZATION,
258 HeaderValue::from_static("BeArEr abc123"),
259 );
260 assert_eq!(extract_bearer_token(&headers), Some("abc123"));
261 }
262
263 #[test]
264 fn extract_bearer_token_rejects_other_schemes() {
265 let mut headers = HeaderMap::new();
266 headers.insert(
267 header::AUTHORIZATION,
268 HeaderValue::from_static("Basic abc123"),
269 );
270 assert_eq!(extract_bearer_token(&headers), None);
271 }
272
273 #[test]
274 fn extract_bearer_token_rejects_missing_header() {
275 assert_eq!(extract_bearer_token(&HeaderMap::new()), None);
276 }
277
278 #[test]
279 #[should_panic(expected = "must not be empty or whitespace-only")]
280 fn bearer_auth_panics_on_blank_token() {
281 let _ = bearer_auth(" ");
282 }
283
284 #[test]
285 #[should_panic(expected = "must not be empty or whitespace-only")]
286 fn bearer_auth_panics_on_empty_token() {
287 let _ = bearer_auth("");
288 }
289}