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}