Skip to main content

pas_external/
scope_grant.rs

1//! Granted-vs-requested scope validation (RFC 6749 §5.1).
2//!
3//! A phantom scope marker types what the client **asks for**. What it
4//! **gets** is a runtime fact the server decides, and the two can differ:
5//! an authorization server may narrow a grant at the token endpoint, and
6//! OAuth 2.1 §1.3.2 explicitly permits a *refresh* response to carry a
7//! narrower scope than the token it renews. Without a check, a client whose
8//! type says `Notify` can be running on a credential that lost `chat.ack`
9//! — and the discrepancy surfaces as a puzzling 403 from the resource
10//! server, arbitrarily far from the cause.
11//!
12//! [`ensure_covers`] is that check, in one place, used by every leg of the
13//! SDK that receives a token response.
14
15use ppoppo_sdk_core::scopes::ConsentScopes;
16
17/// The server granted a scope that does not cover what was requested.
18///
19/// Terminal, never transient: retrying re-runs the same grant policy and
20/// gets the same answer. The consumer's correct response is to treat the
21/// credential as unusable at this tier — surface a signed-out state and
22/// start a fresh authorization, or re-consent at a tier the user will grant.
23#[derive(Debug, Clone, thiserror::Error)]
24#[error(
25    "granted scope does not cover the requested tier — missing [{}] (requested [{}], granted [{}])",
26    missing.join(" "),
27    requested.join(" "),
28    granted.join(" ")
29)]
30pub struct ScopeNotCovered {
31    /// The atoms the marker type requested.
32    pub requested: Vec<String>,
33    /// The atoms the server actually granted, as echoed.
34    pub granted: Vec<String>,
35    /// Requested atoms absent from the grant — the reason this failed.
36    pub missing: Vec<String>,
37}
38
39/// Check that a token response's scope echo covers `S`'s atoms.
40///
41/// # Absence means success, per RFC 6749 §5.1
42///
43/// The `scope` parameter of a token response is **optional when the granted
44/// scope is identical to the requested scope**. So `None` is the server
45/// saying "you got exactly what you asked for", and must pass. Failing
46/// closed on absence would reject spec-conformant servers outright — a real
47/// risk for a published SDK, even though today's PAS always echoes.
48///
49/// # Containment, never equality
50///
51/// RFC 6749 §3.3 defines the scope parameter as a space-delimited,
52/// **order-insignificant** list. Comparing strings would reject a grant that
53/// merely echoes the atoms in a different order, or with different internal
54/// whitespace. The check is therefore set containment — `granted ⊇
55/// requested` — over whitespace-split atoms.
56///
57/// A *superset* passes: a server electing to grant more than was asked is
58/// not an error, and the client simply never exercises the extra atoms (its
59/// phantom type gates it to the tier it requested). Only a **narrowed**
60/// grant — one missing a requested atom — fails.
61///
62/// # Errors
63///
64/// [`ScopeNotCovered`] when the echo is present and omits at least one atom
65/// of `S::SCOPES`.
66pub fn ensure_covers<S: ConsentScopes>(granted: Option<&str>) -> Result<(), ScopeNotCovered> {
67    // RFC 6749 §5.1 — omitted echo means "granted as requested".
68    let Some(granted) = granted else {
69        return Ok(());
70    };
71
72    // §3.3 — space-delimited; treat any ASCII whitespace run as one
73    // separator so a server padding its echo is not a false positive.
74    let granted_atoms: Vec<&str> = granted.split_whitespace().collect();
75
76    let missing: Vec<String> = S::SCOPES
77        .iter()
78        .filter(|requested| !granted_atoms.contains(*requested))
79        .map(|s| (*s).to_owned())
80        .collect();
81
82    if missing.is_empty() {
83        return Ok(());
84    }
85
86    Err(ScopeNotCovered {
87        requested: S::SCOPES.iter().map(|s| (*s).to_owned()).collect(),
88        granted: granted_atoms.into_iter().map(str::to_owned).collect(),
89        missing,
90    })
91}
92
93#[cfg(test)]
94#[allow(clippy::unwrap_used)]
95mod tests {
96    use super::*;
97
98    struct Notify;
99    impl ConsentScopes for Notify {
100        const SCOPES: &'static [&'static str] = &["chat.read", "contact.read", "chat.ack"];
101    }
102
103    #[test]
104    fn absent_echo_passes_as_granted_identically() {
105        // RFC 6749 §5.1: the field is OPTIONAL when granted == requested.
106        // Failing closed here would reject a spec-conformant server.
107        assert!(ensure_covers::<Notify>(None).is_ok());
108    }
109
110    #[test]
111    fn exact_echo_passes() {
112        assert!(ensure_covers::<Notify>(Some("chat.read contact.read chat.ack")).is_ok());
113    }
114
115    #[test]
116    fn reordered_echo_passes() {
117        // §3.3 makes order insignificant — string equality would reject this.
118        assert!(ensure_covers::<Notify>(Some("chat.ack chat.read contact.read")).is_ok());
119    }
120
121    #[test]
122    fn irregular_whitespace_passes() {
123        // Leading/trailing/repeated separators are still just separators.
124        assert!(ensure_covers::<Notify>(Some("  chat.read   contact.read\tchat.ack ")).is_ok());
125    }
126
127    #[test]
128    fn superset_grant_passes() {
129        // A server granting more than asked is not an error; the phantom
130        // type still gates the client to the tier it requested.
131        assert!(
132            ensure_covers::<Notify>(Some("chat.read contact.read chat.ack chat.send")).is_ok()
133        );
134    }
135
136    #[test]
137    fn narrowed_grant_is_a_typed_error_naming_what_is_missing() {
138        let err = ensure_covers::<Notify>(Some("chat.read contact.read"))
139            .expect_err("a grant missing chat.ack must not pass");
140        assert_eq!(err.missing, ["chat.ack"]);
141        assert_eq!(err.granted, ["chat.read", "contact.read"]);
142        assert_eq!(err.requested, ["chat.read", "contact.read", "chat.ack"]);
143        // The message must name the missing atom — this error lands in a
144        // consumer's logs at the moment sign-in fails, and "scope mismatch"
145        // alone would send them to the wrong place.
146        assert!(err.to_string().contains("chat.ack"), "{err}");
147    }
148
149    #[test]
150    fn empty_echo_fails_for_a_non_empty_request() {
151        // An explicitly empty string is NOT the same as an absent field: the
152        // server affirmatively said "nothing granted".
153        let err = ensure_covers::<Notify>(Some("")).expect_err("empty grant covers nothing");
154        assert_eq!(err.missing.len(), 3);
155    }
156
157    #[test]
158    fn atoms_match_whole_not_by_prefix() {
159        // `chat.read` must not be satisfied by `chat.readonly`. Substring
160        // matching here would silently accept a strictly weaker grant.
161        struct ReadOnly;
162        impl ConsentScopes for ReadOnly {
163            const SCOPES: &'static [&'static str] = &["chat.read"];
164        }
165        assert!(ensure_covers::<ReadOnly>(Some("chat.readonly")).is_err());
166        assert!(ensure_covers::<ReadOnly>(Some("chat.read")).is_ok());
167    }
168}