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    /// The TokenReview fallback: consulted only when a bearer matched
160    /// no configured token — a projected service-account token is not
161    /// in anyone's config, and that is its whole point.
162    #[cfg(feature = "kubernetes-auth")]
163    kubernetes: Option<crate::kubernetes::KubernetesVerifier>,
164}
165
166impl Authenticator {
167    /// An authenticator over `clients`, with an optional anonymous
168    /// principal for callers that present no credential.
169    #[must_use]
170    pub fn new(
171        clients: impl IntoIterator<Item = (Token, Principal)>,
172        anonymous: Option<Principal>,
173    ) -> Self {
174        Self {
175            clients: clients.into_iter().collect(),
176            anonymous,
177            #[cfg(feature = "kubernetes-auth")]
178            kubernetes: None,
179        }
180    }
181
182    /// Adds the TokenReview fallback for bearers no configured token
183    /// matched.
184    #[cfg(feature = "kubernetes-auth")]
185    #[must_use]
186    pub fn with_kubernetes(mut self, verifier: crate::kubernetes::KubernetesVerifier) -> Self {
187        self.kubernetes = Some(verifier);
188        self
189    }
190
191    /// Whether an unauthenticated caller is somebody here.
192    #[must_use]
193    pub fn allows_anonymous(&self) -> bool {
194        self.anonymous.is_some()
195    }
196
197    /// Who is calling, given the raw `Authorization` header.
198    ///
199    /// Three answers, and the middle one is the one worth stating: a header
200    /// that is *present* but unusable — a wrong scheme, an unknown token —
201    /// is **not** downgraded to anonymous. A caller that presented a
202    /// credential meant to present that credential, and silently serving it
203    /// the anonymous grants instead is how an expired token becomes a
204    /// deployment that appears to work.
205    #[must_use]
206    pub fn authenticate(&self, authorization: Option<&str>) -> Option<Principal> {
207        let Some(header) = authorization else {
208            return self.anonymous.clone();
209        };
210
211        let presented = bearer(header)?;
212
213        // Every configured token is compared, whether or not one has already
214        // matched: stopping early would make the time taken depend on which
215        // client is calling, which is a smaller oracle than the byte
216        // comparison's but the same kind.
217        let mut found = None;
218
219        for (token, principal) in &self.clients {
220            let hit = token.matches(presented);
221
222            if hit && found.is_none() {
223                found = Some(principal.clone());
224            }
225        }
226
227        #[cfg(feature = "kubernetes-auth")]
228        if found.is_none() {
229            if let Some(kubernetes) = &self.kubernetes {
230                found = kubernetes.verify(presented);
231            }
232        }
233
234        found
235    }
236}
237
238/// The token out of `Bearer <token>`, case-insensitively on the scheme.
239fn bearer(header: &str) -> Option<&str> {
240    let (scheme, token) = header.split_once(' ')?;
241
242    if !scheme.eq_ignore_ascii_case("bearer") {
243        return None;
244    }
245
246    let token = token.trim_start();
247
248    (!token.is_empty()).then_some(token)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn authenticator() -> Authenticator {
256        Authenticator::new(
257            [(
258                Token::new("0123456789abcdef0123456789abcdef"),
259                Principal::new("billing-pod", ["billing"]),
260            )],
261            None,
262        )
263    }
264
265    #[test]
266    fn a_configured_token_authenticates_its_client() {
267        let principal = authenticator()
268            .authenticate(Some("Bearer 0123456789abcdef0123456789abcdef"))
269            .expect("the configured token");
270
271        assert_eq!(principal.name(), "billing-pod");
272        assert!(principal.may_read("billing"));
273    }
274
275    #[test]
276    fn the_scheme_is_case_insensitive_and_the_token_is_not() {
277        let authenticator = authenticator();
278
279        assert!(authenticator
280            .authenticate(Some("bearer 0123456789abcdef0123456789abcdef"))
281            .is_some());
282        assert!(authenticator
283            .authenticate(Some("Bearer 0123456789ABCDEF0123456789ABCDEF"))
284            .is_none());
285    }
286
287    #[test]
288    fn an_unusable_header_is_nobody_even_when_anonymous_is_configured() {
289        let authenticator = Authenticator::new(
290            [(
291                Token::new("0123456789abcdef0123456789abcdef"),
292                Principal::new("billing-pod", ["billing"]),
293            )],
294            Some(Principal::new("anonymous", ["demo"])),
295        );
296
297        // No credential at all is the anonymous principal...
298        assert_eq!(
299            authenticator
300                .authenticate(None)
301                .map(|who| who.name().to_owned()),
302            Some("anonymous".to_owned())
303        );
304        // ...but a credential that does not work is not silently downgraded.
305        assert!(authenticator.authenticate(Some("Bearer wrong")).is_none());
306        assert!(authenticator.authenticate(Some("Basic abc")).is_none());
307        assert!(authenticator.authenticate(Some("Bearer ")).is_none());
308        assert!(authenticator.authenticate(Some("garbage")).is_none());
309    }
310
311    #[test]
312    fn a_grant_is_exact() {
313        let principal = Principal::new("who", ["billing"]);
314
315        assert!(principal.may_read("billing"));
316        assert!(!principal.may_read("bill"));
317        assert!(!principal.may_read("billing-staging"));
318        assert!(!principal.may_read("*"));
319    }
320
321    #[test]
322    fn token_comparison_is_by_bytes_and_length() {
323        let token = Token::new("0123456789abcdef0123456789abcdef");
324
325        assert!(token.matches("0123456789abcdef0123456789abcdef"));
326        assert!(!token.matches("0123456789abcdef0123456789abcdeg"));
327        assert!(!token.matches("0123456789abcdef0123456789abcde"));
328        assert!(!token.matches("0123456789abcdef0123456789abcdef0"));
329        assert!(!token.matches(""));
330    }
331
332    /// The mistake AGENTS.md records, asserted rather than reviewed: a
333    /// planted token must not survive a `{:?}` of anything holding it.
334    #[test]
335    fn debug_never_prints_a_token() {
336        let token = Token::new("planted-token-value-0123456789ab");
337        let authenticator =
338            Authenticator::new([(token.clone(), Principal::new("who", ["billing"]))], None);
339
340        for rendered in [format!("{token:?}"), format!("{authenticator:?}")] {
341            assert!(
342                !rendered.contains("planted-token-value"),
343                "a credential escaped through Debug: {rendered}"
344            );
345        }
346    }
347}