Skip to main content

dynamic_config_server/
auth.rs

1//! Who is calling, and what they may read.
2//!
3//! Authorisation here is **per application**, not per server: a token that
4//! reads `billing` reads `billing` and nothing else. That is the decision the
5//! threat model turns on — a config server holds every service's
6//! configuration, so a credential scoped to the server is every secret at
7//! once, and the blast radius of a leaked pod token has to be the pod's own
8//! section.
9//!
10//! There is exactly one credential shape: a bearer token, presented in
11//! `Authorization`. A client certificate is **not** a second one — with
12//! `[server.tls]` and a `client_ca` it is a gate the connection passes
13//! before a request exists, and nothing in this module knows or cares that
14//! it happened. JWT validation is deliberately absent rather than
15//! half-present; see the crate documentation, and [`crate::tls`] for why a
16//! certificate names no principal here.
17
18use std::fmt;
19use std::sync::Arc;
20
21use serde::Deserialize;
22
23/// The shortest token this server will accept in its configuration.
24///
25/// Long enough that guessing is not a strategy, and stated as a number
26/// rather than as advice because a config server with a four-character token
27/// is a config server with no authentication at all.
28pub const MIN_TOKEN_LEN: usize = 32;
29
30/// A bearer token, as configured.
31///
32/// Deserialises from a plain string. It has no accessor: the only thing
33/// anything may do with a configured token is ask whether a presented one
34/// equals it, and that comparison lives here so it cannot be written a
35/// second, sloppier time somewhere else.
36#[derive(Clone, Deserialize)]
37#[serde(transparent)]
38pub struct Token(String);
39
40impl Token {
41    /// A token from a string, for constructing a server in code.
42    #[must_use]
43    pub fn new(token: impl Into<String>) -> Self {
44        Self(token.into())
45    }
46
47    /// The configured length, for the minimum-length refusal.
48    #[must_use]
49    pub fn len(&self) -> usize {
50        self.0.len()
51    }
52
53    /// Whether the token is empty — `len() == 0`, spelled for clippy.
54    #[must_use]
55    pub fn is_empty(&self) -> bool {
56        self.0.is_empty()
57    }
58
59    /// Whether `presented` is this token.
60    ///
61    /// The byte comparison does not stop at the first difference, so the
62    /// time it takes does not reveal how much of a guess was right. What it
63    /// does still reveal is the *length* — a presented token of a different
64    /// length is rejected after fewer XORs — and that is accepted
65    /// deliberately: a token's length is fixed by whoever issued it, is not
66    /// the secret, and is bounded below by [`MIN_TOKEN_LEN`] anyway.
67    #[must_use]
68    pub fn matches(&self, presented: &str) -> bool {
69        let (configured, presented) = (self.0.as_bytes(), presented.as_bytes());
70        let mut difference = u8::from(configured.len() != presented.len());
71
72        for (left, right) in configured.iter().zip(presented) {
73            difference |= left ^ right;
74        }
75
76        difference == 0
77    }
78
79    /// Whether two *configured* tokens are the same, for the duplicate-token
80    /// refusal. Neither side is attacker-supplied, so this is the one
81    /// comparison here that has nothing to hide.
82    pub(crate) fn same_as(&self, other: &Self) -> bool {
83        self.0 == other.0
84    }
85}
86
87/// Redacted, and the mistake AGENTS.md names: a derived `Debug` over a
88/// credential is how three store crates shipped printing their tokens. Not
89/// even the length, which would narrow a guess for nothing in return.
90impl fmt::Debug for Token {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str("Token(***)")
93    }
94}
95
96/// An authenticated caller and the applications it may read.
97///
98/// Cheap to clone — a request handler carries one — because the grants are
99/// behind an `Arc` rather than copied per request.
100#[derive(Clone, Debug)]
101pub struct Principal(Arc<Inner>);
102
103#[derive(Debug)]
104struct Inner {
105    name: String,
106    applications: Vec<String>,
107}
108
109impl Principal {
110    /// A principal named `name`, granted `applications`.
111    #[must_use]
112    pub fn new(
113        name: impl Into<String>,
114        applications: impl IntoIterator<Item = impl Into<String>>,
115    ) -> Self {
116        Self(Arc::new(Inner {
117            name: name.into(),
118            applications: applications.into_iter().map(Into::into).collect(),
119        }))
120    }
121
122    /// The configured client name. Safe to log: it comes from the server's
123    /// own configuration, never from the request.
124    #[must_use]
125    pub fn name(&self) -> &str {
126        &self.0.name
127    }
128
129    /// Whether this caller may read `application`.
130    ///
131    /// Exact match, no wildcards and no prefixes. A grant language is a
132    /// place to make a mistake that reads as a working deployment, and
133    /// nothing here needs one.
134    #[must_use]
135    pub fn may_read(&self, application: &str) -> bool {
136        self.0
137            .applications
138            .iter()
139            .any(|granted| granted == application)
140    }
141
142    /// The applications this caller may read.
143    #[must_use]
144    pub fn applications(&self) -> &[String] {
145        &self.0.applications
146    }
147}
148
149/// Turns an `Authorization` header into a [`Principal`], or into nothing.
150///
151/// Nothing is the default: a caller with no credential is a principal only
152/// when the deployment configured an anonymous client *and* opted in with
153/// `allow_anonymous`. Two switches, because one of them is the kind that
154/// gets flipped in a hurry.
155#[derive(Debug)]
156pub struct Authenticator {
157    clients: Vec<(Token, Principal)>,
158    anonymous: Option<Principal>,
159}
160
161impl Authenticator {
162    /// An authenticator over `clients`, with an optional anonymous
163    /// principal for callers that present no credential.
164    #[must_use]
165    pub fn new(
166        clients: impl IntoIterator<Item = (Token, Principal)>,
167        anonymous: Option<Principal>,
168    ) -> Self {
169        Self {
170            clients: clients.into_iter().collect(),
171            anonymous,
172        }
173    }
174
175    /// Whether an unauthenticated caller is somebody here.
176    #[must_use]
177    pub fn allows_anonymous(&self) -> bool {
178        self.anonymous.is_some()
179    }
180
181    /// Who is calling, given the raw `Authorization` header.
182    ///
183    /// Three answers, and the middle one is the one worth stating: a header
184    /// that is *present* but unusable — a wrong scheme, an unknown token —
185    /// is **not** downgraded to anonymous. A caller that presented a
186    /// credential meant to present that credential, and silently serving it
187    /// the anonymous grants instead is how an expired token becomes a
188    /// deployment that appears to work.
189    #[must_use]
190    pub fn authenticate(&self, authorization: Option<&str>) -> Option<Principal> {
191        let Some(header) = authorization else {
192            return self.anonymous.clone();
193        };
194
195        let presented = bearer(header)?;
196
197        // Every configured token is compared, whether or not one has already
198        // matched: stopping early would make the time taken depend on which
199        // client is calling, which is a smaller oracle than the byte
200        // comparison's but the same kind.
201        let mut found = None;
202
203        for (token, principal) in &self.clients {
204            let hit = token.matches(presented);
205
206            if hit && found.is_none() {
207                found = Some(principal.clone());
208            }
209        }
210
211        found
212    }
213}
214
215/// The token out of `Bearer <token>`, case-insensitively on the scheme.
216fn bearer(header: &str) -> Option<&str> {
217    let (scheme, token) = header.split_once(' ')?;
218
219    if !scheme.eq_ignore_ascii_case("bearer") {
220        return None;
221    }
222
223    let token = token.trim_start();
224
225    (!token.is_empty()).then_some(token)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn authenticator() -> Authenticator {
233        Authenticator::new(
234            [(
235                Token::new("0123456789abcdef0123456789abcdef"),
236                Principal::new("billing-pod", ["billing"]),
237            )],
238            None,
239        )
240    }
241
242    #[test]
243    fn a_configured_token_authenticates_its_client() {
244        let principal = authenticator()
245            .authenticate(Some("Bearer 0123456789abcdef0123456789abcdef"))
246            .expect("the configured token");
247
248        assert_eq!(principal.name(), "billing-pod");
249        assert!(principal.may_read("billing"));
250    }
251
252    #[test]
253    fn the_scheme_is_case_insensitive_and_the_token_is_not() {
254        let authenticator = authenticator();
255
256        assert!(authenticator
257            .authenticate(Some("bearer 0123456789abcdef0123456789abcdef"))
258            .is_some());
259        assert!(authenticator
260            .authenticate(Some("Bearer 0123456789ABCDEF0123456789ABCDEF"))
261            .is_none());
262    }
263
264    #[test]
265    fn an_unusable_header_is_nobody_even_when_anonymous_is_configured() {
266        let authenticator = Authenticator::new(
267            [(
268                Token::new("0123456789abcdef0123456789abcdef"),
269                Principal::new("billing-pod", ["billing"]),
270            )],
271            Some(Principal::new("anonymous", ["demo"])),
272        );
273
274        // No credential at all is the anonymous principal...
275        assert_eq!(
276            authenticator
277                .authenticate(None)
278                .map(|who| who.name().to_owned()),
279            Some("anonymous".to_owned())
280        );
281        // ...but a credential that does not work is not silently downgraded.
282        assert!(authenticator.authenticate(Some("Bearer wrong")).is_none());
283        assert!(authenticator.authenticate(Some("Basic abc")).is_none());
284        assert!(authenticator.authenticate(Some("Bearer ")).is_none());
285        assert!(authenticator.authenticate(Some("garbage")).is_none());
286    }
287
288    #[test]
289    fn a_grant_is_exact() {
290        let principal = Principal::new("who", ["billing"]);
291
292        assert!(principal.may_read("billing"));
293        assert!(!principal.may_read("bill"));
294        assert!(!principal.may_read("billing-staging"));
295        assert!(!principal.may_read("*"));
296    }
297
298    #[test]
299    fn token_comparison_is_by_bytes_and_length() {
300        let token = Token::new("0123456789abcdef0123456789abcdef");
301
302        assert!(token.matches("0123456789abcdef0123456789abcdef"));
303        assert!(!token.matches("0123456789abcdef0123456789abcdeg"));
304        assert!(!token.matches("0123456789abcdef0123456789abcde"));
305        assert!(!token.matches("0123456789abcdef0123456789abcdef0"));
306        assert!(!token.matches(""));
307    }
308
309    /// The mistake AGENTS.md records, asserted rather than reviewed: a
310    /// planted token must not survive a `{:?}` of anything holding it.
311    #[test]
312    fn debug_never_prints_a_token() {
313        let token = Token::new("planted-token-value-0123456789ab");
314        let authenticator =
315            Authenticator::new([(token.clone(), Principal::new("who", ["billing"]))], None);
316
317        for rendered in [format!("{token:?}"), format!("{authenticator:?}")] {
318            assert!(
319                !rendered.contains("planted-token-value"),
320                "a credential escaped through Debug: {rendered}"
321            );
322        }
323    }
324}