acdp_client/auth.rs
1//! Read authentication (RFC-ACDP-0008 §6.2, 0.5.0 `bearer_jwt`).
2//!
3//! A registry that serves non-public contexts, or that sets
4//! `anonymous_public_reads: false`, answers an unauthenticated read with
5//! `not_authorized`. Revocation discovery against such a vantage is then
6//! `unknown` (`unanswerable`), never not-revoked. An [`AuthProvider`]
7//! attached to a [`RegistryClient`](crate::RegistryClient) or a
8//! [`CrossRegistryResolver`](crate::CrossRegistryResolver) makes those reads
9//! answerable by presenting `Authorization: Bearer <token>` (RFC 6750).
10//!
11//! The provider is asked for a token **per request, by authority** — the
12//! host (plus non-default port) of the registry being read. A
13//! `bearer_jwt` token's `aud` is the registry that minted it, and that
14//! registry MUST reject any other audience; asking by authority keeps one
15//! registry's token from ever being presented to another, including on a
16//! cross-registry walk. Minting the token (the signed-challenge exchange)
17//! is deployment-defined and outside the protocol, so it is the provider's
18//! job; this crate only presents what the provider returns.
19//!
20//! Tokens are attached only to **read** requests (retrieve, lineage,
21//! current, search), only over `https`, and never on a redirect to another
22//! authority (the client follows same-authority redirects only). A bearer
23//! token never substitutes for a producer signature on a write
24//! (RFC-ACDP-0008 §6.2), so `publish*` never sends one, and neither does
25//! the public capabilities fetch.
26
27use std::collections::HashMap;
28use std::fmt;
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::{Arc, RwLock};
32
33use acdp_primitives::AcdpError;
34
35/// The boxed future an [`AuthProvider`] returns. Boxed because the trait
36/// is used as `dyn AuthProvider` and the MSRV (1.86) has no `async fn` in
37/// object-safe traits.
38pub type AuthFuture<'a> =
39 Pin<Box<dyn Future<Output = Result<Option<BearerToken>, AcdpError>> + Send + 'a>>;
40
41/// A bearer token. `Debug` and `Display` never print it.
42#[derive(Clone, PartialEq, Eq)]
43pub struct BearerToken(Arc<str>);
44
45impl BearerToken {
46 /// Wrap a token string (the compact JWT, without the `Bearer ` prefix).
47 ///
48 /// # Errors
49 /// [`AcdpError::SchemaViolation`] when the token is empty or contains a
50 /// character that is not valid in an RFC 6750 `b64token` (so it can
51 /// never inject a header line).
52 pub fn new(token: impl Into<String>) -> Result<Self, AcdpError> {
53 let token = token.into();
54 // b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
55 let core = token.trim_end_matches('=');
56 let valid = !core.is_empty()
57 && core.bytes().all(|b| {
58 b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
59 });
60 if !valid {
61 return Err(AcdpError::SchemaViolation(
62 "bearer token must be a non-empty RFC 6750 b64token".into(),
63 ));
64 }
65 Ok(Self(token.into()))
66 }
67
68 /// The token string. Handle with care: whoever holds it can replay it
69 /// until it expires.
70 #[must_use]
71 pub fn expose(&self) -> &str {
72 &self.0
73 }
74}
75
76impl fmt::Debug for BearerToken {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.write_str("BearerToken(<redacted>)")
79 }
80}
81
82/// Supplies read credentials per registry authority (RFC-ACDP-0008 §6.2).
83///
84/// Implement this to plug in a token source that refreshes before `exp`
85/// (registries SHOULD keep `bearer_jwt` lifetimes short). Return
86/// `Ok(None)` for an authority you hold no credential for: the read goes
87/// out anonymously. An `Err` aborts the read and is returned to the caller
88/// unchanged; during revocation discovery it becomes `unknown` with the
89/// reason its error class maps to.
90pub trait AuthProvider: Send + Sync {
91 /// The token to present to `authority` (host, plus port when
92 /// non-default — the same value as
93 /// [`RegistryClient::authority`](crate::RegistryClient::authority)).
94 fn bearer_token<'a>(&'a self, authority: &'a str) -> AuthFuture<'a>;
95
96 /// Called when `authority` answered a read that carried this
97 /// provider's token with HTTP 401 or 403 — the token may have expired
98 /// or been revoked. Drop any cached token for `authority` here so the
99 /// next read fetches a fresh one. The rejected read itself is not
100 /// retried. Default: no-op.
101 fn token_rejected(&self, authority: &str) {
102 let _ = authority;
103 }
104}
105
106impl fmt::Debug for dyn AuthProvider {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 f.write_str("dyn AuthProvider")
109 }
110}
111
112/// One token for exactly one authority. Use on a [`RegistryClient`] that
113/// talks to a single registry.
114///
115/// [`RegistryClient`]: crate::RegistryClient
116#[derive(Debug, Clone)]
117pub struct StaticBearerToken {
118 authority: String,
119 token: BearerToken,
120}
121
122impl StaticBearerToken {
123 /// Present `token` to `authority` and to no other.
124 #[must_use]
125 pub fn new(authority: impl Into<String>, token: BearerToken) -> Self {
126 Self {
127 authority: authority.into().to_ascii_lowercase(),
128 token,
129 }
130 }
131}
132
133impl AuthProvider for StaticBearerToken {
134 fn bearer_token<'a>(&'a self, authority: &'a str) -> AuthFuture<'a> {
135 let token = authority
136 .eq_ignore_ascii_case(&self.authority)
137 .then(|| self.token.clone());
138 Box::pin(async move { Ok(token) })
139 }
140}
141
142/// A token per authority, replaceable at runtime — the shape a
143/// [`CrossRegistryResolver`](crate::CrossRegistryResolver) walk across
144/// several registries needs. Cheap to clone (an `Arc` handle); a clone
145/// shares the table, so a background task can refresh tokens with
146/// [`Self::set`] while readers use them.
147#[derive(Debug, Clone, Default)]
148pub struct PerAuthorityTokens {
149 tokens: Arc<RwLock<HashMap<String, BearerToken>>>,
150}
151
152impl PerAuthorityTokens {
153 /// An empty table: every read goes out anonymously until [`Self::set`].
154 #[must_use]
155 pub fn new() -> Self {
156 Self::default()
157 }
158
159 /// Present `token` to `authority`, replacing any previous token.
160 #[must_use]
161 pub fn with(self, authority: impl Into<String>, token: BearerToken) -> Self {
162 self.set(authority, token);
163 self
164 }
165
166 /// Present `token` to `authority`, replacing any previous token.
167 pub fn set(&self, authority: impl Into<String>, token: BearerToken) {
168 self.tokens
169 .write()
170 .unwrap_or_else(std::sync::PoisonError::into_inner)
171 .insert(authority.into().to_ascii_lowercase(), token);
172 }
173
174 /// Stop presenting a token to `authority`.
175 pub fn remove(&self, authority: &str) {
176 self.tokens
177 .write()
178 .unwrap_or_else(std::sync::PoisonError::into_inner)
179 .remove(&authority.to_ascii_lowercase());
180 }
181}
182
183impl AuthProvider for PerAuthorityTokens {
184 fn bearer_token<'a>(&'a self, authority: &'a str) -> AuthFuture<'a> {
185 let token = self
186 .tokens
187 .read()
188 .unwrap_or_else(std::sync::PoisonError::into_inner)
189 .get(&authority.to_ascii_lowercase())
190 .cloned();
191 Box::pin(async move { Ok(token) })
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn tok(s: &str) -> BearerToken {
200 BearerToken::new(s).unwrap()
201 }
202
203 #[test]
204 fn token_debug_is_redacted() {
205 let t = tok("eyJhbGciOiJFZERTQSJ9.e30.c2ln");
206 assert_eq!(format!("{t:?}"), "BearerToken(<redacted>)");
207 let p = StaticBearerToken::new("r.example", t);
208 assert!(!format!("{p:?}").contains("eyJ"));
209 }
210
211 #[test]
212 fn token_rejects_header_injection_and_empty() {
213 assert!(BearerToken::new("").is_err());
214 assert!(BearerToken::new("abc\r\nX-Evil: 1").is_err());
215 assert!(BearerToken::new("a b").is_err());
216 assert!(
217 BearerToken::new("a=b").is_err(),
218 "'=' only as trailing padding"
219 );
220 assert!(BearerToken::new("abc==").is_ok());
221 assert!(BearerToken::new("a-b.c_d~e+f/g").is_ok());
222 }
223
224 #[tokio::test]
225 async fn static_token_is_scoped_to_its_authority() {
226 let p = StaticBearerToken::new("Registry.Example", tok("t1"));
227 assert_eq!(
228 p.bearer_token("registry.example").await.unwrap(),
229 Some(tok("t1"))
230 );
231 assert_eq!(p.bearer_token("other.example").await.unwrap(), None);
232 assert_eq!(
233 p.bearer_token("registry.example:8443").await.unwrap(),
234 None,
235 "a different port is a different authority"
236 );
237 }
238
239 #[tokio::test]
240 async fn per_authority_tokens_are_shared_and_replaceable() {
241 let p = PerAuthorityTokens::new().with("a.example", tok("ta"));
242 let reader = p.clone();
243 assert_eq!(
244 reader.bearer_token("a.example").await.unwrap(),
245 Some(tok("ta"))
246 );
247 assert_eq!(reader.bearer_token("b.example").await.unwrap(), None);
248 p.set("A.example", tok("ta2"));
249 assert_eq!(
250 reader.bearer_token("a.example").await.unwrap(),
251 Some(tok("ta2"))
252 );
253 p.remove("a.example");
254 assert_eq!(reader.bearer_token("a.example").await.unwrap(), None);
255 }
256}