Skip to main content

assay_auth/oidc_provider/
auth_params.rs

1//! `auth_params` whitelist — per-IdP authorize-URL parameter validation
2//! and forwarding.
3//!
4//! Operators store a JSON object on `auth.upstream_providers.auth_params`
5//! whose keys must be either in [`ALLOWED_KEYS`] or carry the `idp_`
6//! prefix. Framework-owned keys (`client_id`, `redirect_uri`, `scope`,
7//! `state`, `nonce`, `response_type`, `code_challenge*`, `request*`)
8//! are always rejected — those are owned by the federation flow itself
9//! and must never be admin-overridable.
10//!
11//! Values are strings ≤256 chars. Anything else (nested objects,
12//! arrays, oversize strings) is rejected at write time so the
13//! authorize-URL emitter never has to defend against bad shapes.
14
15use std::collections::BTreeMap;
16
17/// Keys explicitly forwarded to the upstream's authorize URL.
18pub const ALLOWED_KEYS: &[&str] = &[
19    "prompt",
20    "login_hint",
21    "domain_hint",
22    "hd",
23    "acr_values",
24    "max_age",
25    "ui_locales",
26];
27
28/// Prefix that opens up arbitrary IdP-specific params without a code
29/// change for each new key.
30pub const ALLOWED_PREFIX: &str = "idp_";
31
32/// Keys the framework owns; must never appear in stored auth_params.
33pub const REJECTED_KEYS: &[&str] = &[
34    "client_id",
35    "redirect_uri",
36    "scope",
37    "state",
38    "nonce",
39    "response_type",
40    "code_challenge",
41    "code_challenge_method",
42    "request",
43    "request_uri",
44];
45
46/// Maximum length of a single auth-param value, in bytes. URL-encoded
47/// at use site, not at storage site.
48pub const MAX_VALUE_LEN: usize = 256;
49
50/// Reasons a single key/value pair fails validation. The admin handler
51/// stringifies these into the per-key error body.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub enum AuthParamError {
54    /// Key is in [`REJECTED_KEYS`] — owned by the framework.
55    RejectedKey(String),
56    /// Key is neither in [`ALLOWED_KEYS`] nor carries [`ALLOWED_PREFIX`].
57    UnknownKey(String),
58    /// Value exceeds [`MAX_VALUE_LEN`] bytes.
59    ValueTooLong(String),
60    /// Key contains characters that would break URL encoding (control
61    /// chars, `=`, `&`, …). Belt-and-braces — admin should send clean
62    /// keys to begin with, but a stored row that bypassed validation
63    /// (e.g. via a manual SQL insert) shouldn't be allowed to break the
64    /// authorize URL.
65    InvalidKey(String),
66}
67
68impl std::fmt::Display for AuthParamError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::RejectedKey(k) => write!(f, "auth_params key {k:?} is owned by the framework"),
72            Self::UnknownKey(k) => write!(f, "auth_params key {k:?} is not in the whitelist"),
73            Self::ValueTooLong(k) => write!(
74                f,
75                "auth_params value for {k:?} exceeds {MAX_VALUE_LEN} chars"
76            ),
77            Self::InvalidKey(k) => write!(f, "auth_params key {k:?} contains invalid characters"),
78        }
79    }
80}
81
82impl std::error::Error for AuthParamError {}
83
84/// Validate every key/value pair in `params`. Returns the first error
85/// encountered (with key context) so callers can surface a per-key
86/// HTTP 400 body. Caller iterates separately if it wants to collect
87/// every error in one pass.
88pub fn validate(params: &BTreeMap<String, String>) -> Result<(), AuthParamError> {
89    for (k, v) in params {
90        validate_pair(k, v)?;
91    }
92    Ok(())
93}
94
95/// Validate a single key/value pair. Exposed so the admin handler can
96/// build a `Vec<(key, error_string)>` per the spec by iterating itself.
97pub fn validate_pair(key: &str, value: &str) -> Result<(), AuthParamError> {
98    if !is_clean_key(key) {
99        return Err(AuthParamError::InvalidKey(key.to_string()));
100    }
101    if REJECTED_KEYS.contains(&key) {
102        return Err(AuthParamError::RejectedKey(key.to_string()));
103    }
104    let allowed = ALLOWED_KEYS.contains(&key) || key.starts_with(ALLOWED_PREFIX);
105    if !allowed {
106        return Err(AuthParamError::UnknownKey(key.to_string()));
107    }
108    if value.len() > MAX_VALUE_LEN {
109        return Err(AuthParamError::ValueTooLong(key.to_string()));
110    }
111    Ok(())
112}
113
114fn is_clean_key(key: &str) -> bool {
115    !key.is_empty()
116        && key
117            .chars()
118            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
119}
120
121/// Append every validated `auth_param` to a query-string buffer with
122/// `&key=urlencoded(value)` form. Caller appends to an existing URL
123/// that already carries the framework-owned params.
124pub fn append_to_query(buf: &mut String, params: &BTreeMap<String, String>) {
125    for (k, v) in params {
126        if validate_pair(k, v).is_err() {
127            // Defence in depth — a row that smuggled past validation
128            // should not break the authorize URL silently. Skip it.
129            continue;
130        }
131        buf.push('&');
132        buf.push_str(k);
133        buf.push('=');
134        buf.push_str(&url_encode(v));
135    }
136}
137
138fn url_encode(s: &str) -> String {
139    let mut out = String::with_capacity(s.len());
140    for byte in s.bytes() {
141        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
142            out.push(byte as char);
143        } else {
144            out.push_str(&format!("%{byte:02X}"));
145        }
146    }
147    out
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn whitelisted_keys_pass() {
156        for k in ALLOWED_KEYS {
157            assert!(validate_pair(k, "x").is_ok(), "{k} should pass");
158        }
159    }
160
161    #[test]
162    fn idp_prefix_passes() {
163        assert!(validate_pair("idp_login_hint_dom", "x").is_ok());
164        assert!(validate_pair("idp_anything", "x").is_ok());
165    }
166
167    #[test]
168    fn rejected_keys_are_rejected() {
169        for k in REJECTED_KEYS {
170            assert!(
171                matches!(validate_pair(k, "x"), Err(AuthParamError::RejectedKey(_))),
172                "{k} should be rejected as framework-owned"
173            );
174        }
175    }
176
177    #[test]
178    fn unknown_keys_are_rejected() {
179        let err = validate_pair("totally_random", "v").unwrap_err();
180        assert!(matches!(err, AuthParamError::UnknownKey(_)));
181    }
182
183    #[test]
184    fn oversize_value_is_rejected() {
185        let big = "x".repeat(MAX_VALUE_LEN + 1);
186        let err = validate_pair("prompt", &big).unwrap_err();
187        assert!(matches!(err, AuthParamError::ValueTooLong(_)));
188    }
189
190    #[test]
191    fn boundary_value_passes() {
192        let ok = "x".repeat(MAX_VALUE_LEN);
193        assert!(validate_pair("prompt", &ok).is_ok());
194    }
195
196    #[test]
197    fn invalid_key_chars_rejected() {
198        assert!(matches!(
199            validate_pair("with space", "v"),
200            Err(AuthParamError::InvalidKey(_))
201        ));
202        assert!(matches!(
203            validate_pair("", "v"),
204            Err(AuthParamError::InvalidKey(_))
205        ));
206    }
207
208    #[test]
209    fn append_to_query_url_encodes_values() {
210        let mut params = BTreeMap::new();
211        params.insert("prompt".to_string(), "consent".to_string());
212        params.insert("hd".to_string(), "example.com".to_string());
213        let mut buf = String::new();
214        append_to_query(&mut buf, &params);
215        assert!(buf.contains("&prompt=consent"));
216        assert!(buf.contains("&hd=example.com"));
217    }
218
219    #[test]
220    fn append_to_query_skips_smuggled_invalid_pairs() {
221        let mut params = BTreeMap::new();
222        params.insert("client_id".to_string(), "evil".to_string());
223        params.insert("prompt".to_string(), "consent".to_string());
224        let mut buf = String::new();
225        append_to_query(&mut buf, &params);
226        assert!(!buf.contains("client_id"));
227        assert!(buf.contains("prompt=consent"));
228    }
229}