Skip to main content

oauth2_types/
scope.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8//! Types to define an [access token's scope].
9//!
10//! [access token's scope]: https://www.rfc-editor.org/rfc/rfc6749#section-3.3
11
12#![allow(clippy::module_name_repetitions)]
13
14use std::{
15    borrow::Cow,
16    collections::BTreeSet,
17    iter::FromIterator,
18    ops::{Deref, DerefMut},
19    str::FromStr,
20};
21
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24
25/// The error type returned when a scope is invalid.
26#[derive(Debug, Error, PartialEq, Eq, PartialOrd, Ord, Hash)]
27#[error("Invalid scope format")]
28pub struct InvalidScope;
29
30/// A scope token or scope value.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub struct ScopeToken(Cow<'static, str>);
33
34impl ScopeToken {
35    /// Create a `ScopeToken` from a static string. The validity of it is not
36    /// checked since it has to be valid in const contexts
37    #[must_use]
38    pub const fn from_static(token: &'static str) -> Self {
39        Self(Cow::Borrowed(token))
40    }
41
42    /// Get the scope token as a string slice.
43    #[must_use]
44    pub fn as_str(&self) -> &str {
45        self.0.as_ref()
46    }
47}
48
49/// `openid`.
50///
51/// Must be included in OpenID Connect requests.
52pub const OPENID: ScopeToken = ScopeToken::from_static("openid");
53
54/// `profile`.
55///
56/// Requests access to the End-User's default profile Claims.
57pub const PROFILE: ScopeToken = ScopeToken::from_static("profile");
58
59/// `email`.
60///
61/// Requests access to the `email` and `email_verified` Claims.
62pub const EMAIL: ScopeToken = ScopeToken::from_static("email");
63
64/// `address`.
65///
66/// Requests access to the `address` Claim.
67pub const ADDRESS: ScopeToken = ScopeToken::from_static("address");
68
69/// `phone`.
70///
71/// Requests access to the `phone_number` and `phone_number_verified` Claims.
72pub const PHONE: ScopeToken = ScopeToken::from_static("phone");
73
74/// `offline_access`.
75///
76/// Requests that an OAuth 2.0 Refresh Token be issued that can be used to
77/// obtain an Access Token that grants access to the End-User's Userinfo
78/// Endpoint even when the End-User is not present (not logged in).
79pub const OFFLINE_ACCESS: ScopeToken = ScopeToken::from_static("offline_access");
80
81// As per RFC6749 appendix A:
82// https://datatracker.ietf.org/doc/html/rfc6749#appendix-A
83//
84//    NQCHAR     = %x21 / %x23-5B / %x5D-7E
85//
86// Both ranges are inclusive on both ends.
87fn nqchar(c: char) -> bool {
88    '\x21' == c || ('\x23'..='\x5B').contains(&c) || ('\x5D'..='\x7E').contains(&c)
89}
90
91impl FromStr for ScopeToken {
92    type Err = InvalidScope;
93
94    fn from_str(s: &str) -> Result<Self, Self::Err> {
95        // As per RFC6749 appendix A.4:
96        // https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.4
97        //
98        //    scope-token = 1*NQCHAR
99        if !s.is_empty() && s.chars().all(nqchar) {
100            Ok(ScopeToken(Cow::Owned(s.into())))
101        } else {
102            Err(InvalidScope)
103        }
104    }
105}
106
107impl Deref for ScopeToken {
108    type Target = str;
109
110    fn deref(&self) -> &Self::Target {
111        &self.0
112    }
113}
114
115impl std::fmt::Display for ScopeToken {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        self.0.fmt(f)
118    }
119}
120
121/// A scope.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Scope(BTreeSet<ScopeToken>);
124
125impl Deref for Scope {
126    type Target = BTreeSet<ScopeToken>;
127
128    fn deref(&self) -> &Self::Target {
129        &self.0
130    }
131}
132
133impl DerefMut for Scope {
134    fn deref_mut(&mut self) -> &mut Self::Target {
135        &mut self.0
136    }
137}
138
139impl FromStr for Scope {
140    type Err = InvalidScope;
141
142    fn from_str(s: &str) -> Result<Self, Self::Err> {
143        // As per RFC6749 appendix A.4:
144        // https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.4
145        //
146        //    scope       = scope-token *( SP scope-token )
147        let scopes: Result<BTreeSet<ScopeToken>, InvalidScope> =
148            s.split(' ').map(ScopeToken::from_str).collect();
149
150        Ok(Self(scopes?))
151    }
152}
153
154impl Scope {
155    /// Whether this `Scope` is empty.
156    #[must_use]
157    pub fn is_empty(&self) -> bool {
158        // This should never be the case?
159        self.0.is_empty()
160    }
161
162    /// The number of tokens in the `Scope`.
163    #[must_use]
164    pub fn len(&self) -> usize {
165        self.0.len()
166    }
167
168    /// Whether this `Scope` contains the given value.
169    #[must_use]
170    pub fn contains(&self, token: &str) -> bool {
171        ScopeToken::from_str(token).is_ok_and(|token| self.0.contains(&token))
172    }
173
174    /// Inserts the given token in this `Scope`.
175    ///
176    /// Returns whether the token was newly inserted.
177    pub fn insert(&mut self, value: ScopeToken) -> bool {
178        self.0.insert(value)
179    }
180}
181
182impl std::fmt::Display for Scope {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        for (index, token) in self.0.iter().enumerate() {
185            if index == 0 {
186                write!(f, "{token}")?;
187            } else {
188                write!(f, " {token}")?;
189            }
190        }
191
192        Ok(())
193    }
194}
195
196impl Serialize for Scope {
197    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
198    where
199        S: serde::Serializer,
200    {
201        self.to_string().serialize(serializer)
202    }
203}
204
205impl<'de> Deserialize<'de> for Scope {
206    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
207    where
208        D: serde::Deserializer<'de>,
209    {
210        // FIXME: seems like there is an unnecessary clone here?
211        let scope: String = Deserialize::deserialize(deserializer)?;
212        Scope::from_str(&scope).map_err(serde::de::Error::custom)
213    }
214}
215
216impl FromIterator<ScopeToken> for Scope {
217    fn from_iter<T: IntoIterator<Item = ScopeToken>>(iter: T) -> Self {
218        Self(BTreeSet::from_iter(iter))
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn parse_scope_token() {
228        assert_eq!(ScopeToken::from_str("openid"), Ok(OPENID));
229
230        assert_eq!(ScopeToken::from_str("invalid\\scope"), Err(InvalidScope));
231
232        // Regression test for #5878: Matrix device IDs may contain any
233        // RFC 3986 unreserved character, including `~`.
234        assert!(ScopeToken::from_str("urn:matrix:client:device:AA~bb1234_-.").is_ok());
235    }
236
237    #[test]
238    fn parse_scope_token_characters() {
239        // NQCHAR = %x21 / %x23-5B / %x5D-7E, boundaries inclusive.
240        for c in '\x21'..='\x7E' {
241            if c == '\x22' || c == '\x5C' {
242                continue;
243            }
244            assert!(
245                ScopeToken::from_str(&c.to_string()).is_ok(),
246                "expected {c:?} (0x{:02X}) to be a valid NQCHAR",
247                c as u32
248            );
249        }
250
251        // Outside NQCHAR:
252        //  - space is 0x20
253        //  - " (double quote) is 0x22
254        //  - \ (backslash) is 0x5C
255        //  - DEL is 0x7F
256        for s in [" ", "\"", "\\", "\x7F"] {
257            assert_eq!(ScopeToken::from_str(s), Err(InvalidScope));
258        }
259    }
260
261    #[test]
262    fn parse_scope() {
263        let scope = Scope::from_str("openid profile address").unwrap();
264        assert_eq!(scope.len(), 3);
265        assert!(scope.contains("openid"));
266        assert!(scope.contains("profile"));
267        assert!(scope.contains("address"));
268        assert!(!scope.contains("unknown"));
269
270        assert!(
271            Scope::from_str("").is_err(),
272            "there should always be at least one token in the scope"
273        );
274
275        assert!(Scope::from_str("invalid\\scope").is_err());
276        assert!(Scope::from_str("no  double space").is_err());
277        assert!(Scope::from_str(" no leading space").is_err());
278        assert!(Scope::from_str("no trailing space ").is_err());
279
280        let scope = Scope::from_str("openid").unwrap();
281        assert_eq!(scope.len(), 1);
282        assert!(scope.contains("openid"));
283        assert!(!scope.contains("profile"));
284        assert!(!scope.contains("address"));
285
286        assert_eq!(
287            Scope::from_str("order does not matter"),
288            Scope::from_str("matter not order does"),
289        );
290
291        assert!(Scope::from_str("http://example.com").is_ok());
292        assert!(Scope::from_str("urn:matrix:client:api:*").is_ok());
293        assert!(Scope::from_str("urn:matrix:org.matrix.msc2967.client:api:*").is_ok());
294    }
295}