//! 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, /// Full SyncKit-API access, the desktop-app pairing flow. Distinct from the /// read-only userinfo scopes: a token carrying `sync` authenticates the /// entire sync API, so it is the explicit opt-in for the 7-day sync token. Sync, } impl OAuthScope { pub fn as_str(self) -> &'static str { match self { OAuthScope::ProfileRead => "profile:read", OAuthScope::PerksRead => "perks:read", OAuthScope::Offline => "offline_access", OAuthScope::Sync => "sync", } } } 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), "sync" => Ok(OAuthScope::Sync), _ => 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 when this is a full-sync request rather than the read-only userinfo /// RP flow. A request is "sync" **only** if it explicitly carries the `sync` /// scope. /// /// An empty / unrecognized scope set is NOT sync: it takes the least- /// privilege userinfo path. This closes the escalation where a relying party /// that merely omitted `scope` was silently granted the broadest 7-day /// sync-API token (audit Run 17 Security). First-party clients now send /// `scope=sync` via `synckit-client`'s authorize URL; a client that predates /// that build and sends no scope gets a userinfo token, not sync. pub fn is_sync_request(&self) -> bool { self.contains(OAuthScope::Sync) } /// 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. Also the prompt=none consent gate: a /// silent re-auth's requested scope must be a subset of what was consented. pub fn subset_of(&self, other: &GrantedScopes) -> bool { self.0.is_subset(&other.0) } /// Merge every scope from `other` into this set (set union). Used to fold a /// fresh interactive-consent grant into the user's standing grant for an app. pub fn union_with(&mut self, other: &GrantedScopes) { self.0.extend(other.0.iter().copied()); } } 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()); } #[test] fn union_with_merges_and_dedups() { let mut a = GrantedScopes::parse("profile:read"); a.union_with(&GrantedScopes::parse("perks:read profile:read")); assert_eq!(a.to_string(), "profile:read perks:read"); // The consent-gate use: a narrower silent request is permitted by the union. assert!(GrantedScopes::parse("perks:read").subset_of(&a)); assert!(!GrantedScopes::parse("offline_access").subset_of(&a)); } #[test] fn sync_request_detection() { // Only the explicit `sync` scope mints a sync token. assert!(GrantedScopes::parse("sync").is_sync_request()); // Empty / whitespace-only scope is NO LONGER treated as sync, it takes // the least-privilege userinfo path. This is the Run 17 escalation fix: // a client that omits `scope` can no longer be upgraded to a sync token. assert!(!GrantedScopes::parse("").is_sync_request()); assert!(!GrantedScopes::parse(" ").is_sync_request()); // Unrecognized-only scopes parse to empty → also not sync. assert!(!GrantedScopes::parse("admin:everything").is_sync_request()); // A userinfo request must NOT be treated as a sync request. assert!(!GrantedScopes::parse("profile:read").is_sync_request()); assert!(!GrantedScopes::parse("profile:read perks:read").is_sync_request()); // `sync` round-trips through the canonical string form. assert_eq!(GrantedScopes::parse("sync").to_string(), "sync"); } }