//! OAuth 2.0 scopes for "Log in with Makenot.work". //! //! Scopes bound what an access token can do. The security-critical use is that //! a token minted for an RP's perk-refresh flow carries only `profile:read` / //! `perks:read` and a userinfo audience, so it can read identity/entitlements //! but cannot act as the user on the sync API. `offline_access` is the opt-in //! that makes the authorization-code grant also issue a refresh token. use std::collections::BTreeSet; use std::fmt; use std::str::FromStr; /// A single recognized OAuth scope. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum OAuthScope { /// Read identity fields (username, display name, avatar) from userinfo. ProfileRead, /// Read the `perks` block (Fan+, creator tier) from userinfo. PerksRead, /// Issue a refresh token on the authorization-code grant (OIDC name). Offline, } impl OAuthScope { pub fn as_str(self) -> &'static str { match self { OAuthScope::ProfileRead => "profile:read", OAuthScope::PerksRead => "perks:read", OAuthScope::Offline => "offline_access", } } } impl FromStr for OAuthScope { type Err = (); fn from_str(s: &str) -> Result { match s { "profile:read" => Ok(OAuthScope::ProfileRead), "perks:read" => Ok(OAuthScope::PerksRead), "offline_access" => Ok(OAuthScope::Offline), _ => Err(()), } } } /// The scopes granted to a token, as a canonical set. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct GrantedScopes(BTreeSet); impl GrantedScopes { /// Parse a space-delimited scope string (RFC 6749 §3.3). Unknown scopes are /// **silently dropped** rather than erroring — forward-compatibility, so a /// client requesting a scope we don't recognize yet still authenticates /// with the scopes we do recognize. pub fn parse(raw: &str) -> Self { GrantedScopes( raw.split_whitespace() .filter_map(|s| OAuthScope::from_str(s).ok()) .collect(), ) } /// The default scopes when an authorize request omits `scope`: read-only /// userinfo access, but **not** `offline_access` — so a client that never /// opts in never receives a refresh token. Preserves today's behavior. pub fn default_userinfo() -> Self { let mut set = BTreeSet::new(); set.insert(OAuthScope::ProfileRead); set.insert(OAuthScope::PerksRead); GrantedScopes(set) } pub fn contains(&self, scope: OAuthScope) -> bool { self.0.contains(&scope) } pub fn is_empty(&self) -> bool { self.0.is_empty() } /// True if every scope in `self` is also in `other`. The downgrade-only /// invariant for refresh: a refresh request may narrow but never widen the /// scope it was originally granted. pub fn subset_of(&self, other: &GrantedScopes) -> bool { self.0.is_subset(&other.0) } } impl fmt::Display for GrantedScopes { /// Canonical space-joined form, used to store on the code/refresh row and /// echo in the token response. Deterministic ordering via the BTreeSet. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut first = true; for scope in &self.0 { if !first { f.write_str(" ")?; } f.write_str(scope.as_str())?; first = false; } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_round_trips_canonical() { let s = GrantedScopes::parse("perks:read profile:read"); // Canonical order is enum order: profile:read before perks:read. assert_eq!(s.to_string(), "profile:read perks:read"); } #[test] fn parse_drops_unknown_scopes() { let s = GrantedScopes::parse("profile:read admin:everything perks:read"); assert!(s.contains(OAuthScope::ProfileRead)); assert!(s.contains(OAuthScope::PerksRead)); assert_eq!(s.to_string(), "profile:read perks:read"); } #[test] fn default_has_no_offline() { let s = GrantedScopes::default_userinfo(); assert!(s.contains(OAuthScope::ProfileRead)); assert!(s.contains(OAuthScope::PerksRead)); assert!(!s.contains(OAuthScope::Offline)); } #[test] fn subset_of_enforces_downgrade_only() { let granted = GrantedScopes::parse("profile:read perks:read offline_access"); let narrower = GrantedScopes::parse("perks:read"); let same = GrantedScopes::parse("profile:read perks:read offline_access"); let wider = GrantedScopes::parse("profile:read perks:read offline_access"); assert!(narrower.subset_of(&granted)); assert!(same.subset_of(&granted)); // A scope not in the grant cannot be requested on refresh. let escalated = GrantedScopes::parse("perks:read"); assert!(!granted.subset_of(&escalated)); // granted is wider than escalated assert!(wider.subset_of(&granted)); } #[test] fn empty_string_parses_empty() { assert!(GrantedScopes::parse("").is_empty()); assert!(GrantedScopes::parse(" ").is_empty()); } }