Skip to main content

max / makenotwork

5.2 KB · 152 lines History Blame Raw
1 //! OAuth 2.0 scopes for "Log in with Makenot.work".
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 }
23
24 impl OAuthScope {
25 pub fn as_str(self) -> &'static str {
26 match self {
27 OAuthScope::ProfileRead => "profile:read",
28 OAuthScope::PerksRead => "perks:read",
29 OAuthScope::Offline => "offline_access",
30 }
31 }
32 }
33
34 impl FromStr for OAuthScope {
35 type Err = ();
36 fn from_str(s: &str) -> Result<Self, Self::Err> {
37 match s {
38 "profile:read" => Ok(OAuthScope::ProfileRead),
39 "perks:read" => Ok(OAuthScope::PerksRead),
40 "offline_access" => Ok(OAuthScope::Offline),
41 _ => Err(()),
42 }
43 }
44 }
45
46 /// The scopes granted to a token, as a canonical set.
47 #[derive(Debug, Clone, PartialEq, Eq, Default)]
48 pub struct GrantedScopes(BTreeSet<OAuthScope>);
49
50 impl GrantedScopes {
51 /// Parse a space-delimited scope string (RFC 6749 ยง3.3). Unknown scopes are
52 /// **silently dropped** rather than erroring โ€” forward-compatibility, so a
53 /// client requesting a scope we don't recognize yet still authenticates
54 /// with the scopes we do recognize.
55 pub fn parse(raw: &str) -> Self {
56 GrantedScopes(
57 raw.split_whitespace()
58 .filter_map(|s| OAuthScope::from_str(s).ok())
59 .collect(),
60 )
61 }
62
63 /// The default scopes when an authorize request omits `scope`: read-only
64 /// userinfo access, but **not** `offline_access` โ€” so a client that never
65 /// opts in never receives a refresh token. Preserves today's behavior.
66 pub fn default_userinfo() -> Self {
67 let mut set = BTreeSet::new();
68 set.insert(OAuthScope::ProfileRead);
69 set.insert(OAuthScope::PerksRead);
70 GrantedScopes(set)
71 }
72
73 pub fn contains(&self, scope: OAuthScope) -> bool {
74 self.0.contains(&scope)
75 }
76
77 pub fn is_empty(&self) -> bool {
78 self.0.is_empty()
79 }
80
81 /// True if every scope in `self` is also in `other`. The downgrade-only
82 /// invariant for refresh: a refresh request may narrow but never widen the
83 /// scope it was originally granted.
84 pub fn subset_of(&self, other: &GrantedScopes) -> bool {
85 self.0.is_subset(&other.0)
86 }
87 }
88
89 impl fmt::Display for GrantedScopes {
90 /// Canonical space-joined form, used to store on the code/refresh row and
91 /// echo in the token response. Deterministic ordering via the BTreeSet.
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 let mut first = true;
94 for scope in &self.0 {
95 if !first {
96 f.write_str(" ")?;
97 }
98 f.write_str(scope.as_str())?;
99 first = false;
100 }
101 Ok(())
102 }
103 }
104
105 #[cfg(test)]
106 mod tests {
107 use super::*;
108
109 #[test]
110 fn parse_round_trips_canonical() {
111 let s = GrantedScopes::parse("perks:read profile:read");
112 // Canonical order is enum order: profile:read before perks:read.
113 assert_eq!(s.to_string(), "profile:read perks:read");
114 }
115
116 #[test]
117 fn parse_drops_unknown_scopes() {
118 let s = GrantedScopes::parse("profile:read admin:everything perks:read");
119 assert!(s.contains(OAuthScope::ProfileRead));
120 assert!(s.contains(OAuthScope::PerksRead));
121 assert_eq!(s.to_string(), "profile:read perks:read");
122 }
123
124 #[test]
125 fn default_has_no_offline() {
126 let s = GrantedScopes::default_userinfo();
127 assert!(s.contains(OAuthScope::ProfileRead));
128 assert!(s.contains(OAuthScope::PerksRead));
129 assert!(!s.contains(OAuthScope::Offline));
130 }
131
132 #[test]
133 fn subset_of_enforces_downgrade_only() {
134 let granted = GrantedScopes::parse("profile:read perks:read offline_access");
135 let narrower = GrantedScopes::parse("perks:read");
136 let same = GrantedScopes::parse("profile:read perks:read offline_access");
137 let wider = GrantedScopes::parse("profile:read perks:read offline_access");
138 assert!(narrower.subset_of(&granted));
139 assert!(same.subset_of(&granted));
140 // A scope not in the grant cannot be requested on refresh.
141 let escalated = GrantedScopes::parse("perks:read");
142 assert!(!granted.subset_of(&escalated)); // granted is wider than escalated
143 assert!(wider.subset_of(&granted));
144 }
145
146 #[test]
147 fn empty_string_parses_empty() {
148 assert!(GrantedScopes::parse("").is_empty());
149 assert!(GrantedScopes::parse(" ").is_empty());
150 }
151 }
152