Skip to main content

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;