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}