gglib_core/access/bearer.rs
1//! Matching an `Authorization` header against the configured bearer token.
2//!
3//! Split from `mod.rs` rather than added to it because that file is near its
4//! complexity budget, and because this is one self-contained decision: given
5//! what a client sent and what the endpoint expects, does the request get in.
6
7use std::sync::Arc;
8
9use super::constant_time_eq;
10use crate::services::SettingsCache;
11
12/// The auth scheme this endpoint speaks, compared case-insensitively.
13const BEARER: &str = "bearer";
14
15/// Whether `presented` — the raw `Authorization` header value, or `None` when
16/// the client sent none — carries `expected_key`.
17///
18/// # Why the scheme is matched case-insensitively
19///
20/// RFC 9110 §11.1 defines the auth scheme as a `token`, and tokens are
21/// case-insensitive. `bearer sk-…` is therefore a correct request, and the
22/// previous comparison — the whole `"Bearer <key>"` string, byte for byte —
23/// answered it with a 401 that said the key was wrong. It was not; only its
24/// capitalisation was, and nothing in the response said so. That is the least
25/// actionable rejection available, and it matters here beyond pedantry: the
26/// tunnel in front of this endpoint accepts the same header the RFC does, so
27/// two doors checking one credential disagreed about whether it was valid.
28///
29/// The scheme comparison is deliberately **not** constant-time. It is a public
30/// protocol keyword, not a secret, and there is nothing to leak by returning
31/// early on it. Only the credential goes to [`constant_time_eq`].
32///
33/// # What is still refused
34///
35/// Everything a lenient comparison would wave through. A different scheme
36/// (`Basic <key>`), the bare key with no scheme at all, a prefix of the key,
37/// and an empty credential are all rejected. The last of those is load-bearing:
38/// settings validation refuses a blank `proxy_api_key` precisely so that
39/// `Bearer ` cannot become a credential everyone holds, and splitting the
40/// header on its space must not reintroduce that from the other side.
41///
42/// Whitespace follows the grammar rather than being trimmed indiscriminately:
43/// the scheme and the credential are separated by one or more spaces
44/// (`1*SP`), and trailing optional whitespace is not part of the credential.
45#[must_use]
46pub fn bearer_matches(presented: Option<&str>, expected_key: &str) -> bool {
47 // A blank expectation can never be satisfied. Unreachable through the
48 // settings path, which rejects one, but this function is the last place
49 // that assumption could go wrong quietly rather than loudly.
50 if expected_key.is_empty() {
51 return false;
52 }
53
54 let Some(header) = presented else {
55 return false;
56 };
57
58 // No space means no credential — `"Bearer"` alone, or a bare key sent with
59 // the scheme omitted, both land here.
60 let Some((scheme, rest)) = header.split_once(' ') else {
61 return false;
62 };
63
64 if !scheme.eq_ignore_ascii_case(BEARER) {
65 return false;
66 }
67
68 let credential = rest.trim();
69 if credential.is_empty() {
70 return false;
71 }
72
73 constant_time_eq(credential.as_bytes(), expected_key.as_bytes())
74}
75
76/// Which token a running endpoint currently requires.
77///
78/// # Why this is not just a string
79///
80/// The expected token used to be resolved once, at bind, and baked into the
81/// middleware — so a key rotated afterwards was never honoured and a key set
82/// afterwards was never enforced. Worse, the guard was only *installed* when a
83/// token existed at bind, so an endpoint that started open could not be closed
84/// without a restart.
85///
86/// The fix cannot be an in-process notification. `gglib config settings set`
87/// writes the database from a **separate process**, so nothing the daemon
88/// subscribes to would ever see it — the same reasoning
89/// [`SettingsCache`] already records for every
90/// other setting. Reading through that cache is what makes a rotation take
91/// effect here at all.
92///
93/// **The staleness is bounded, not zero.** A revoked key keeps working for up
94/// to [`SETTINGS_CACHE_TTL`](crate::services::SETTINGS_CACHE_TTL). That is the
95/// accepted trade, and it is strictly better than what it replaces, where a
96/// rotation performed through the CLI never took effect at all.
97#[derive(Clone)]
98pub struct BearerPolicy {
99 /// A token supplied by flag or environment. It does not live in settings,
100 /// so nothing in settings may override it.
101 pinned: Option<Arc<str>>,
102 /// The token in force at bind, kept as a floor.
103 floor: Option<Arc<str>>,
104 /// The live view of the stored token.
105 settings: Option<Arc<SettingsCache>>,
106}
107
108impl BearerPolicy {
109 /// A token the operator supplied directly, which never tracks settings.
110 ///
111 /// `--api-key` and `GGLIB_API_KEY` outrank the stored value by design, so
112 /// letting a settings write replace one would both invert that precedence
113 /// and lock out the operator who passed it.
114 #[must_use]
115 pub fn pinned(key: &str) -> Self {
116 Self {
117 pinned: Some(Arc::from(key)),
118 floor: None,
119 settings: None,
120 }
121 }
122
123 /// A token read from settings — or absent — which tracks later writes.
124 ///
125 /// `bind_key` is whatever was in force when the endpoint bound, and is
126 /// kept as a floor: if the stored value later disappears, this endpoint
127 /// keeps demanding the token it started with rather than falling open.
128 /// **Authentication can be switched on at runtime and never off**, which
129 /// is the asymmetry a listener bound off loopback needs — clearing the
130 /// setting must not silently expose it.
131 #[must_use]
132 pub fn tracking(bind_key: Option<&str>, settings: Arc<SettingsCache>) -> Self {
133 Self {
134 pinned: None,
135 floor: bind_key.map(Arc::from),
136 settings: Some(settings),
137 }
138 }
139
140 /// A token that can never change and is never required. For hosts with no
141 /// settings to read, such as tests and embedded servers.
142 #[must_use]
143 pub fn fixed(key: Option<&str>) -> Self {
144 Self {
145 pinned: key.map(Arc::from),
146 floor: None,
147 settings: None,
148 }
149 }
150
151 /// The token a request must present right now, or `None` while this
152 /// endpoint is unauthenticated.
153 pub async fn current(&self) -> Option<Arc<str>> {
154 if let Some(pinned) = &self.pinned {
155 return Some(Arc::clone(pinned));
156 }
157 let stored = match &self.settings {
158 Some(cache) => cache
159 .get()
160 .await
161 .proxy_api_key
162 .as_deref()
163 .filter(|key| !key.trim().is_empty())
164 .map(Arc::from),
165 None => None,
166 };
167 stored.or_else(|| self.floor.clone())
168 }
169
170 /// Whether `presented` gets in.
171 ///
172 /// An endpoint with no token configured admits everyone, which is the
173 /// loopback default and the behaviour this had before authentication
174 /// existed.
175 pub async fn admits(&self, presented: Option<&str>) -> bool {
176 self.current()
177 .await
178 .is_none_or(|expected| bearer_matches(presented, &expected))
179 }
180}
181
182#[cfg(test)]
183#[path = "bearer_tests.rs"]
184mod bearer_tests;