Skip to main content

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}