Skip to main content

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}