Skip to main content

vgi_rpc/
unauthorized.rs

1//! The standardized 401, per `docs/unauthorized-spec.md` in the reference
2//! repo.
3//!
4//! A rejection carries a machine-readable reason code so a client can switch
5//! on it — refresh on [`AuthReason::ExpiredCredential`], give up on
6//! [`AuthReason::InsufficientScope`] — instead of matching on message text,
7//! which misclassifies the moment someone rewords a string.
8
9use std::fmt;
10
11/// Header carrying one [`AuthReason`] on every 401.
12pub const AUTH_REASON_HEADER: &str = "vgi-auth-reason";
13
14/// Header set to `"true"` on 401s from a service whose authentication depends
15/// on a reverse proxy. Omitted otherwise — never `"false"`.
16pub const AUTH_PROXY_REQUIRED_HEADER: &str = "vgi-auth-proxy-required";
17
18/// The closed set of reason codes.
19///
20/// Readers must treat an unrecognised code as [`AuthReason::Unauthorized`] —
21/// that means the server is newer, not broken.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum AuthReason {
24    /// No credential was presented at all.
25    MissingCredential,
26    /// A credential was presented and rejected.
27    InvalidCredential,
28    /// Well-formed, but outside its validity window.
29    ExpiredCredential,
30    /// Identified, but not permitted.
31    ///
32    /// Deliberately a 401 rather than a 403: the authenticate callback runs
33    /// before any method is resolved, so there is no route yet whose
34    /// permissions could be evaluated. A service wanting a true 403 raises it
35    /// from the method body.
36    InsufficientScope,
37    /// The request carried no evidence of arriving through the trusted proxy.
38    /// Derived from server configuration, never from the request.
39    ProxyRequired,
40    /// Refused, unclassified. The fallback.
41    Unauthorized,
42}
43
44impl AuthReason {
45    /// The wire spelling.
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            Self::MissingCredential => "missing_credential",
49            Self::InvalidCredential => "invalid_credential",
50            Self::ExpiredCredential => "expired_credential",
51            Self::InsufficientScope => "insufficient_scope",
52            Self::ProxyRequired => "proxy_required",
53            Self::Unauthorized => "unauthorized",
54        }
55    }
56}
57
58impl fmt::Display for AuthReason {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(self.as_str())
61    }
62}
63
64/// Build the operator-facing proxy note.
65///
66/// The wording is not normative — it is prose for a human. It must convey
67/// that the service is only reachable through its proxy, which header names
68/// the proxy must set, and that a rejection here is at least as likely to be
69/// a proxy misconfiguration as a bad credential.
70#[cfg(feature = "http")]
71pub(crate) fn proxy_hint(headers: &[String]) -> String {
72    format!(
73        "This service only accepts requests that arrive through its configured \
74         reverse proxy, which must set the {} header(s). A rejection here is at \
75         least as likely to be a proxy misconfiguration as a bad credential — \
76         check that the proxy is forwarding them before re-issuing credentials.",
77        headers.join(", ")
78    )
79}
80
81/// Render the JSON envelope of spec §4.3.
82///
83/// `proxy_hint` is absent, not empty, when it does not apply, so its presence
84/// alone is a usable signal.
85#[cfg(feature = "http")]
86pub(crate) fn envelope(reason: AuthReason, detail: &str, hint: Option<&str>) -> String {
87    let mut out = String::from("{\"error\":\"unauthorized\",\"reason\":\"");
88    out.push_str(reason.as_str());
89    out.push_str("\",\"detail\":");
90    out.push_str(&json_string(detail));
91    if let Some(hint) = hint {
92        out.push_str(",\"proxy_hint\":");
93        out.push_str(&json_string(hint));
94    }
95    out.push('}');
96    out
97}
98
99/// Minimal JSON string escaping — the envelope has no other dynamic shape,
100/// so pulling in a serializer for it would not earn its keep.
101#[cfg(feature = "http")]
102fn json_string(s: &str) -> String {
103    let mut out = String::with_capacity(s.len() + 2);
104    out.push('"');
105    for c in s.chars() {
106        match c {
107            '"' => out.push_str("\\\""),
108            '\\' => out.push_str("\\\\"),
109            '\n' => out.push_str("\\n"),
110            '\r' => out.push_str("\\r"),
111            '\t' => out.push_str("\\t"),
112            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
113            c => out.push(c),
114        }
115    }
116    out.push('"');
117    out
118}
119
120#[cfg(all(test, feature = "http"))]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn envelope_omits_the_hint_when_it_does_not_apply() {
126        // Absent, not empty — presence alone has to be a usable signal.
127        let body = envelope(AuthReason::InvalidCredential, "nope", None);
128        assert!(!body.contains("proxy_hint"), "{body}");
129        assert!(body.contains("\"reason\":\"invalid_credential\""), "{body}");
130        assert!(body.contains("\"error\":\"unauthorized\""), "{body}");
131    }
132
133    #[test]
134    fn envelope_carries_the_hint_when_it_applies() {
135        let hint = proxy_hint(&["vgi-proxy-proof".to_string()]);
136        let body = envelope(AuthReason::ProxyRequired, "", Some(&hint));
137        assert!(body.contains("proxy_hint"), "{body}");
138        assert!(body.contains("vgi-proxy-proof"), "{body}");
139    }
140
141    #[test]
142    fn detail_is_escaped() {
143        let body = envelope(AuthReason::Unauthorized, "a \"quoted\"\nline", None);
144        assert!(body.contains("\\\"quoted\\\""), "{body}");
145        assert!(body.contains("\\n"), "{body}");
146    }
147}