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}