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//!   - [`jwt::jwt_authenticate`] (feature `jwt`)
15//!   - [`pkce`] (feature `oauth-pkce`)
16
17pub mod bearer;
18pub mod mtls;
19pub mod oauth;
20
21#[cfg(feature = "jwt")]
22pub mod jwt;
23
24#[cfg(feature = "oauth-pkce")]
25pub mod pkce;
26
27#[cfg(feature = "oauth-pkce-server")]
28pub mod pkce_server;
29
30use std::collections::BTreeMap;
31use std::sync::Arc;
32
33use crate::errors::RpcError;
34
35/// Authentication state attached to every RPC call.
36///
37/// Mirrors the canonical Python frozen dataclass
38/// (`vgi_rpc.rpc._common.AuthContext`). Anonymous callers get the value
39/// returned by [`AuthContext::anonymous`].
40#[derive(Clone, Debug, Default)]
41pub struct AuthContext {
42    /// Logical auth domain (e.g. "bearer", "mtls", "oauth:issuer.example").
43    /// Empty for anonymous callers.
44    pub domain: String,
45    /// `true` when the call was authenticated (even if `principal` is empty).
46    pub authenticated: bool,
47    /// Principal name (e.g. subject DN, OAuth `sub` claim, bearer token alias).
48    pub principal: String,
49    /// Opaque string-keyed claims (e.g. JWT claims, cert extensions).
50    pub claims: BTreeMap<String, String>,
51}
52
53impl AuthContext {
54    /// Anonymous / unauthenticated context.
55    pub fn anonymous() -> Self {
56        Self::default()
57    }
58
59    /// Build an authenticated context with a principal and optional domain.
60    pub fn for_principal(domain: impl Into<String>, principal: impl Into<String>) -> Self {
61        Self {
62            domain: domain.into(),
63            authenticated: true,
64            principal: principal.into(),
65            claims: BTreeMap::new(),
66        }
67    }
68
69    /// Require authentication; returns a `PermissionError` when anonymous.
70    pub fn require_authenticated(&self) -> crate::errors::Result<()> {
71        if self.authenticated {
72            Ok(())
73        } else {
74            Err(RpcError::permission_error("authentication required"))
75        }
76    }
77
78    /// Attach a claim (fluent builder used mainly by helpers).
79    pub fn with_claim(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
80        self.claims.insert(key.into(), value.into());
81        self
82    }
83}
84
85/// Minimal view of an incoming request exposed to authenticate callbacks.
86///
87/// Abstracts over transports: HTTP provides headers + peer addr + method;
88/// pipe/unix dispatchers use [`AuthRequest::anonymous_pipe`].
89#[derive(Debug)]
90pub struct AuthRequest<'a> {
91    pub method: &'a str,
92    pub headers: &'a [(String, String)],
93    pub peer_addr: Option<&'a str>,
94}
95
96impl<'a> AuthRequest<'a> {
97    /// Build a trivial request describing an anonymous pipe/unix call.
98    pub fn anonymous_pipe(method: &'a str) -> Self {
99        Self {
100            method,
101            headers: &[],
102            peer_addr: None,
103        }
104    }
105
106    /// Return the value of a header by case-insensitive name.
107    pub fn header(&self, name: &str) -> Option<&str> {
108        self.headers
109            .iter()
110            .find(|(k, _)| k.eq_ignore_ascii_case(name))
111            .map(|(_, v)| v.as_str())
112    }
113}
114
115/// Outcome of an authenticate callback.
116///
117/// `Ok(ctx)` — caller is accepted with the given context (may be anonymous).
118/// `Err(_)` — caller is rejected; HTTP maps the error to a 401/403 status.
119pub type AuthResult = std::result::Result<AuthContext, RpcError>;
120
121/// Trait object holding an authenticate callback.
122///
123/// Every enterprise auth helper produces one of these. Use
124/// [`chain_authenticate`] to try several in sequence.
125pub type Authenticate = Arc<dyn Fn(&AuthRequest<'_>) -> AuthResult + Send + Sync>;
126
127/// Compose two authenticate callbacks: if the first returns anonymous,
128/// try the second.
129///
130/// Matches the Python `chain_authenticate` semantics: first-non-anonymous
131/// wins, errors short-circuit.
132pub fn chain_authenticate(a: Authenticate, b: Authenticate) -> Authenticate {
133    Arc::new(move |req| {
134        let first = (a)(req)?;
135        if first.authenticated {
136            return Ok(first);
137        }
138        (b)(req)
139    })
140}
141
142/// Extract the opaque token from a `Authorization: Bearer <token>` header.
143///
144/// Case-insensitive prefix match. Returns `None` when the header is absent,
145/// does not start with the `Bearer ` scheme, or carries an empty token.
146pub(crate) fn extract_bearer<'a>(req: &'a AuthRequest<'a>) -> Option<&'a str> {
147    let h = req.header("authorization")?;
148    let prefix = "Bearer ";
149    if h.len() > prefix.len() && h[..prefix.len()].eq_ignore_ascii_case(prefix) {
150        let tok = h[prefix.len()..].trim();
151        (!tok.is_empty()).then_some(tok)
152    } else {
153        None
154    }
155}
156
157/// Utility: fold an iterator of callbacks into a single chain.
158pub fn chain_all<I: IntoIterator<Item = Authenticate>>(cbs: I) -> Option<Authenticate> {
159    let mut it = cbs.into_iter();
160    let mut acc = it.next()?;
161    for next in it {
162        acc = chain_authenticate(acc, next);
163    }
164    Some(acc)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn require_authenticated_rejects_anonymous() {
173        let anon = AuthContext::anonymous();
174        assert!(anon.require_authenticated().is_err());
175        let authd = AuthContext::for_principal("bearer", "alice");
176        assert!(authd.require_authenticated().is_ok());
177    }
178
179    #[test]
180    fn chain_tries_second_when_first_anonymous() {
181        let a: Authenticate = Arc::new(|_| Ok(AuthContext::anonymous()));
182        let b: Authenticate = Arc::new(|_| Ok(AuthContext::for_principal("bearer", "alice")));
183        let chain = chain_authenticate(a, b);
184        let req = AuthRequest::anonymous_pipe("echo");
185        let ctx = chain(&req).unwrap();
186        assert_eq!(ctx.principal, "alice");
187    }
188
189    #[test]
190    fn chain_uses_first_when_authenticated() {
191        let a: Authenticate = Arc::new(|_| Ok(AuthContext::for_principal("mtls", "bob")));
192        let b: Authenticate = Arc::new(|_| Ok(AuthContext::for_principal("bearer", "alice")));
193        let chain = chain_authenticate(a, b);
194        let req = AuthRequest::anonymous_pipe("echo");
195        assert_eq!(chain(&req).unwrap().principal, "bob");
196    }
197}