1use std::fmt;
10
11pub const AUTH_REASON_HEADER: &str = "vgi-auth-reason";
13
14pub const AUTH_PROXY_REQUIRED_HEADER: &str = "vgi-auth-proxy-required";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum AuthReason {
24 MissingCredential,
26 InvalidCredential,
28 ExpiredCredential,
30 InsufficientScope,
37 ProxyRequired,
40 Unauthorized,
42}
43
44impl AuthReason {
45 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#[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#[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#[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 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}