Skip to main content

max / makenotwork

8.1 KB · 207 lines History Blame Raw
1 //! OAuth 2.0 scopes for "Log in with Makenotwork".
2 //!
3 //! Scopes bound what an access token can do. The security-critical use is that
4 //! a token minted for an RP's perk-refresh flow carries only `profile:read` /
5 //! `perks:read` and a userinfo audience, so it can read identity/entitlements
6 //! but cannot act as the user on the sync API. `offline_access` is the opt-in
7 //! that makes the authorization-code grant also issue a refresh token.
8
9 use std::collections::BTreeSet;
10 use std::fmt;
11 use std::str::FromStr;
12
13 /// A single recognized OAuth scope.
14 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15 pub enum OAuthScope {
16 /// Read identity fields (username, display name, avatar) from userinfo.
17 ProfileRead,
18 /// Read the `perks` block (Fan+, creator tier) from userinfo.
19 PerksRead,
20 /// Issue a refresh token on the authorization-code grant (OIDC name).
21 Offline,
22 /// Full SyncKit-API access, the desktop-app pairing flow. Distinct from the
23 /// read-only userinfo scopes: a token carrying `sync` authenticates the
24 /// entire sync API, so it is the explicit opt-in for the 7-day sync token.
25 Sync,
26 }
27
28 impl OAuthScope {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 OAuthScope::ProfileRead => "profile:read",
32 OAuthScope::PerksRead => "perks:read",
33 OAuthScope::Offline => "offline_access",
34 OAuthScope::Sync => "sync",
35 }
36 }
37 }
38
39 impl FromStr for OAuthScope {
40 type Err = ();
41 fn from_str(s: &str) -> Result<Self, Self::Err> {
42 match s {
43 "profile:read" => Ok(OAuthScope::ProfileRead),
44 "perks:read" => Ok(OAuthScope::PerksRead),
45 "offline_access" => Ok(OAuthScope::Offline),
46 "sync" => Ok(OAuthScope::Sync),
47 _ => Err(()),
48 }
49 }
50 }
51
52 /// The scopes granted to a token, as a canonical set.
53 #[derive(Debug, Clone, PartialEq, Eq, Default)]
54 pub struct GrantedScopes(BTreeSet<OAuthScope>);
55
56 impl GrantedScopes {
57 /// Parse a space-delimited scope string (RFC 6749 §3.3). Unknown scopes are
58 /// **silently dropped** rather than erroring, forward-compatibility, so a
59 /// client requesting a scope we don't recognize yet still authenticates
60 /// with the scopes we do recognize.
61 pub fn parse(raw: &str) -> Self {
62 GrantedScopes(
63 raw.split_whitespace()
64 .filter_map(|s| OAuthScope::from_str(s).ok())
65 .collect(),
66 )
67 }
68
69 /// The default scopes when an authorize request omits `scope`: read-only
70 /// userinfo access, but **not** `offline_access`, so a client that never
71 /// opts in never receives a refresh token. Preserves today's behavior.
72 pub fn default_userinfo() -> Self {
73 let mut set = BTreeSet::new();
74 set.insert(OAuthScope::ProfileRead);
75 set.insert(OAuthScope::PerksRead);
76 GrantedScopes(set)
77 }
78
79 pub fn contains(&self, scope: OAuthScope) -> bool {
80 self.0.contains(&scope)
81 }
82
83 pub fn is_empty(&self) -> bool {
84 self.0.is_empty()
85 }
86
87 /// True when this is a full-sync request rather than the read-only userinfo
88 /// RP flow. A request is "sync" **only** if it explicitly carries the `sync`
89 /// scope.
90 ///
91 /// An empty / unrecognized scope set is NOT sync: it takes the least-
92 /// privilege userinfo path. This closes the escalation where a relying party
93 /// that merely omitted `scope` was silently granted the broadest 7-day
94 /// sync-API token (audit Run 17 Security). First-party clients now send
95 /// `scope=sync` via `synckit-client`'s authorize URL; a client that predates
96 /// that build and sends no scope gets a userinfo token, not sync.
97 pub fn is_sync_request(&self) -> bool {
98 self.contains(OAuthScope::Sync)
99 }
100
101 /// True if every scope in `self` is also in `other`. The downgrade-only
102 /// invariant for refresh: a refresh request may narrow but never widen the
103 /// scope it was originally granted. Also the prompt=none consent gate: a
104 /// silent re-auth's requested scope must be a subset of what was consented.
105 pub fn subset_of(&self, other: &GrantedScopes) -> bool {
106 self.0.is_subset(&other.0)
107 }
108
109 /// Merge every scope from `other` into this set (set union). Used to fold a
110 /// fresh interactive-consent grant into the user's standing grant for an app.
111 pub fn union_with(&mut self, other: &GrantedScopes) {
112 self.0.extend(other.0.iter().copied());
113 }
114 }
115
116 impl fmt::Display for GrantedScopes {
117 /// Canonical space-joined form, used to store on the code/refresh row and
118 /// echo in the token response. Deterministic ordering via the BTreeSet.
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 let mut first = true;
121 for scope in &self.0 {
122 if !first {
123 f.write_str(" ")?;
124 }
125 f.write_str(scope.as_str())?;
126 first = false;
127 }
128 Ok(())
129 }
130 }
131
132 #[cfg(test)]
133 mod tests {
134 use super::*;
135
136 #[test]
137 fn parse_round_trips_canonical() {
138 let s = GrantedScopes::parse("perks:read profile:read");
139 // Canonical order is enum order: profile:read before perks:read.
140 assert_eq!(s.to_string(), "profile:read perks:read");
141 }
142
143 #[test]
144 fn parse_drops_unknown_scopes() {
145 let s = GrantedScopes::parse("profile:read admin:everything perks:read");
146 assert!(s.contains(OAuthScope::ProfileRead));
147 assert!(s.contains(OAuthScope::PerksRead));
148 assert_eq!(s.to_string(), "profile:read perks:read");
149 }
150
151 #[test]
152 fn default_has_no_offline() {
153 let s = GrantedScopes::default_userinfo();
154 assert!(s.contains(OAuthScope::ProfileRead));
155 assert!(s.contains(OAuthScope::PerksRead));
156 assert!(!s.contains(OAuthScope::Offline));
157 }
158
159 #[test]
160 fn subset_of_enforces_downgrade_only() {
161 let granted = GrantedScopes::parse("profile:read perks:read offline_access");
162 let narrower = GrantedScopes::parse("perks:read");
163 let same = GrantedScopes::parse("profile:read perks:read offline_access");
164 let wider = GrantedScopes::parse("profile:read perks:read offline_access");
165 assert!(narrower.subset_of(&granted));
166 assert!(same.subset_of(&granted));
167 // A scope not in the grant cannot be requested on refresh.
168 let escalated = GrantedScopes::parse("perks:read");
169 assert!(!granted.subset_of(&escalated)); // granted is wider than escalated
170 assert!(wider.subset_of(&granted));
171 }
172
173 #[test]
174 fn empty_string_parses_empty() {
175 assert!(GrantedScopes::parse("").is_empty());
176 assert!(GrantedScopes::parse(" ").is_empty());
177 }
178
179 #[test]
180 fn union_with_merges_and_dedups() {
181 let mut a = GrantedScopes::parse("profile:read");
182 a.union_with(&GrantedScopes::parse("perks:read profile:read"));
183 assert_eq!(a.to_string(), "profile:read perks:read");
184 // The consent-gate use: a narrower silent request is permitted by the union.
185 assert!(GrantedScopes::parse("perks:read").subset_of(&a));
186 assert!(!GrantedScopes::parse("offline_access").subset_of(&a));
187 }
188
189 #[test]
190 fn sync_request_detection() {
191 // Only the explicit `sync` scope mints a sync token.
192 assert!(GrantedScopes::parse("sync").is_sync_request());
193 // Empty / whitespace-only scope is NO LONGER treated as sync, it takes
194 // the least-privilege userinfo path. This is the Run 17 escalation fix:
195 // a client that omits `scope` can no longer be upgraded to a sync token.
196 assert!(!GrantedScopes::parse("").is_sync_request());
197 assert!(!GrantedScopes::parse(" ").is_sync_request());
198 // Unrecognized-only scopes parse to empty → also not sync.
199 assert!(!GrantedScopes::parse("admin:everything").is_sync_request());
200 // A userinfo request must NOT be treated as a sync request.
201 assert!(!GrantedScopes::parse("profile:read").is_sync_request());
202 assert!(!GrantedScopes::parse("profile:read perks:read").is_sync_request());
203 // `sync` round-trips through the canonical string form.
204 assert_eq!(GrantedScopes::parse("sync").to_string(), "sync");
205 }
206 }
207