Skip to main content

vgi_rpc/auth/
mod.rs

1//! Authentication framework.
2//!
3//! Servers configure an [`Authenticate`] callback (or chain of callbacks)
4//! that inspects each incoming request and returns an [`AuthContext`]
5//! describing the caller. The context is propagated onto
6//! [`crate::CallContext`] so handlers can gate access on `principal`
7//! / `auth_domain` / `claims`.
8//!
9//! Built-in helpers:
10//!   - [`bearer::bearer_authenticate`] / [`bearer::bearer_authenticate_static`]
11//!   - [`mtls::mtls_authenticate_fingerprint`] / [`mtls::mtls_authenticate_subject`]
12//!     / [`mtls::mtls_authenticate_xfcc`]
13//!   - [`oauth::OAuthResourceMetadata`] (RFC 9728)
14//!   - [`proof::proof_authenticate`] (feature `http`)
15//!   - [`jwt::jwt_authenticate`] (feature `jwt`)
16//!   - [`pkce`] (feature `oauth-pkce`)
17//!
18//! [`introspect`] is the inverse direction: resolving an opaque credential to a
19//! principal *on behalf of a fronting proxy*, which is a distinct capability
20//! from authenticating a request and is off unless explicitly enabled.
21
22pub mod bearer;
23pub mod introspect;
24pub mod mtls;
25pub mod oauth;
26
27#[cfg(feature = "http")]
28pub mod proof;
29
30#[cfg(feature = "jwt")]
31pub mod jwt;
32
33#[cfg(feature = "oauth-pkce")]
34pub mod pkce;
35
36#[cfg(feature = "oauth-pkce-server")]
37pub mod pkce_server;
38
39use std::collections::BTreeMap;
40use std::sync::Arc;
41
42use crate::errors::RpcError;
43
44/// Authentication state attached to every RPC call.
45///
46/// Mirrors the canonical Python frozen dataclass
47/// (`vgi_rpc.rpc._common.AuthContext`). Anonymous callers get the value
48/// returned by [`AuthContext::anonymous`].
49#[derive(Clone, Debug, Default)]
50pub struct AuthContext {
51    /// Logical auth domain (e.g. "bearer", "mtls", "oauth:issuer.example").
52    /// Empty for anonymous callers.
53    pub domain: String,
54    /// `true` when the call was authenticated (even if `principal` is empty).
55    pub authenticated: bool,
56    /// Principal name (e.g. subject DN, OAuth `sub` claim, bearer token alias).
57    pub principal: String,
58    /// Opaque string-keyed claims (e.g. JWT claims, cert extensions).
59    pub claims: BTreeMap<String, String>,
60}
61
62impl AuthContext {
63    /// Anonymous / unauthenticated context.
64    pub fn anonymous() -> Self {
65        Self::default()
66    }
67
68    /// Build an authenticated context with a principal and optional domain.
69    pub fn for_principal(domain: impl Into<String>, principal: impl Into<String>) -> Self {
70        Self {
71            domain: domain.into(),
72            authenticated: true,
73            principal: principal.into(),
74            claims: BTreeMap::new(),
75        }
76    }
77
78    /// Require authentication; returns a `PermissionError` when anonymous.
79    pub fn require_authenticated(&self) -> crate::errors::Result<()> {
80        if self.authenticated {
81            Ok(())
82        } else {
83            Err(RpcError::permission_error("authentication required"))
84        }
85    }
86
87    /// Attach a claim (fluent builder used mainly by helpers).
88    pub fn with_claim(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
89        self.claims.insert(key.into(), value.into());
90        self
91    }
92}
93
94/// Minimal view of an incoming request exposed to authenticate callbacks.
95///
96/// Abstracts over transports: HTTP provides headers + peer addr + method;
97/// pipe/unix dispatchers use [`AuthRequest::anonymous_pipe`].
98#[derive(Debug)]
99pub struct AuthRequest<'a> {
100    pub method: &'a str,
101    pub headers: &'a [(String, String)],
102    pub peer_addr: Option<&'a str>,
103}
104
105impl<'a> AuthRequest<'a> {
106    /// Build a trivial request describing an anonymous pipe/unix call.
107    pub fn anonymous_pipe(method: &'a str) -> Self {
108        Self {
109            method,
110            headers: &[],
111            peer_addr: None,
112        }
113    }
114
115    /// Return the value of a header by case-insensitive name.
116    pub fn header(&self, name: &str) -> Option<&str> {
117        self.headers
118            .iter()
119            .find(|(k, _)| k.eq_ignore_ascii_case(name))
120            .map(|(_, v)| v.as_str())
121    }
122}
123
124/// Outcome of an authenticate callback.
125///
126/// `Ok(ctx)` — caller is accepted with the given context (may be anonymous).
127/// `Err(_)` — caller is rejected; HTTP maps the error to a 401/403 status.
128pub type AuthResult = std::result::Result<AuthContext, RpcError>;
129
130/// Trait object holding an authenticate callback.
131///
132/// Every enterprise auth helper produces one of these. Use
133/// [`chain_authenticate`] to try several in sequence.
134pub type Authenticate = Arc<dyn Fn(&AuthRequest<'_>) -> AuthResult + Send + Sync>;
135
136/// Compose two authenticate callbacks: if the first returns anonymous,
137/// try the second.
138///
139/// Matches the Python `chain_authenticate` semantics: first-non-anonymous
140/// wins, errors short-circuit.
141///
142/// "Not my credential, try the next" is `Ok(anonymous)` here — never an `Err` —
143/// which is what keeps [`RpcError::auth_unavailable`] intact across the chain.
144/// An outage must not be read as a miss and re-emerge as a 401 from the end of
145/// the chain: that turns a sidecar restart into a fleet-wide re-login storm and
146/// invites callers to negative-cache the outage. Every `Err` propagates
147/// unchanged, and the HTTP layer decides between `401` and `503` from the
148/// error's type.
149pub fn chain_authenticate(a: Authenticate, b: Authenticate) -> Authenticate {
150    Arc::new(move |req| {
151        let first = (a)(req)?;
152        if first.authenticated {
153            return Ok(first);
154        }
155        (b)(req)
156    })
157}
158
159/// Extract the opaque token from a `Authorization: Bearer <token>` header.
160///
161/// Case-insensitive prefix match. Returns `None` when the header is absent,
162/// does not start with the `Bearer ` scheme, or carries an empty token.
163pub(crate) fn extract_bearer<'a>(req: &'a AuthRequest<'a>) -> Option<&'a str> {
164    let h = req.header("authorization")?;
165    let prefix = "Bearer ";
166    if h.len() > prefix.len() && h[..prefix.len()].eq_ignore_ascii_case(prefix) {
167        let tok = h[prefix.len()..].trim();
168        (!tok.is_empty()).then_some(tok)
169    } else {
170        None
171    }
172}
173
174/// Utility: fold an iterator of callbacks into a single chain.
175pub fn chain_all<I: IntoIterator<Item = Authenticate>>(cbs: I) -> Option<Authenticate> {
176    let mut it = cbs.into_iter();
177    let mut acc = it.next()?;
178    for next in it {
179        acc = chain_authenticate(acc, next);
180    }
181    Some(acc)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn require_authenticated_rejects_anonymous() {
190        let anon = AuthContext::anonymous();
191        assert!(anon.require_authenticated().is_err());
192        let authd = AuthContext::for_principal("bearer", "alice");
193        assert!(authd.require_authenticated().is_ok());
194    }
195
196    #[test]
197    fn chain_tries_second_when_first_anonymous() {
198        let a: Authenticate = Arc::new(|_| Ok(AuthContext::anonymous()));
199        let b: Authenticate = Arc::new(|_| Ok(AuthContext::for_principal("bearer", "alice")));
200        let chain = chain_authenticate(a, b);
201        let req = AuthRequest::anonymous_pipe("echo");
202        let ctx = chain(&req).unwrap();
203        assert_eq!(ctx.principal, "alice");
204    }
205
206    #[test]
207    fn chain_propagates_a_transient_failure_rather_than_advancing() {
208        // The distinction the whole definitive/transient split rests on: an
209        // authority that could not answer must not look like a miss, or the
210        // chain ends in a 401 and every caller re-authenticates against a
211        // service that is merely down.
212        let down: Authenticate = Arc::new(|_| Err(RpcError::auth_unavailable("jwks fetch failed")));
213        let reached = Arc::new(std::sync::atomic::AtomicBool::new(false));
214        let flag = reached.clone();
215        let next: Authenticate = Arc::new(move |_| {
216            flag.store(true, std::sync::atomic::Ordering::SeqCst);
217            Ok(AuthContext::for_principal("bearer", "alice"))
218        });
219        let req = AuthRequest::anonymous_pipe("echo");
220
221        for chain in [
222            chain_authenticate(down.clone(), next.clone()),
223            chain_all([down.clone(), next.clone()]).expect("non-empty"),
224        ] {
225            let err = chain(&req).expect_err("an outage must not resolve to an identity");
226            assert!(err.is_auth_unavailable(), "reclassified as {err}");
227            assert_eq!(err.retry_after_seconds, Some(5));
228        }
229        assert!(
230            !reached.load(std::sync::atomic::Ordering::SeqCst),
231            "the chain advanced past an outage"
232        );
233    }
234
235    #[test]
236    fn chain_uses_first_when_authenticated() {
237        let a: Authenticate = Arc::new(|_| Ok(AuthContext::for_principal("mtls", "bob")));
238        let b: Authenticate = Arc::new(|_| Ok(AuthContext::for_principal("bearer", "alice")));
239        let chain = chain_authenticate(a, b);
240        let req = AuthRequest::anonymous_pipe("echo");
241        assert_eq!(chain(&req).unwrap().principal, "bob");
242    }
243}