Skip to main content

mas_data_model/
tokens.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
8use base64ct::{Base64UrlUnpadded, Encoding};
9use chrono::{DateTime, Utc};
10use crc::{CRC_32_ISO_HDLC, Crc};
11use mas_iana::oauth::OAuthTokenTypeHint;
12use rand::{Rng, RngExt, distr::Alphanumeric};
13use thiserror::Error;
14use ulid::Ulid;
15
16use crate::InvalidTransitionError;
17
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub enum AccessTokenState {
20    #[default]
21    Valid,
22    Revoked {
23        revoked_at: DateTime<Utc>,
24    },
25}
26
27impl AccessTokenState {
28    fn revoke(self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
29        match self {
30            Self::Valid => Ok(Self::Revoked { revoked_at }),
31            Self::Revoked { .. } => Err(InvalidTransitionError),
32        }
33    }
34
35    /// Returns `true` if the refresh token state is [`Valid`].
36    ///
37    /// [`Valid`]: AccessTokenState::Valid
38    #[must_use]
39    pub fn is_valid(&self) -> bool {
40        matches!(self, Self::Valid)
41    }
42
43    /// Returns `true` if the refresh token state is [`Revoked`].
44    ///
45    /// [`Revoked`]: AccessTokenState::Revoked
46    #[must_use]
47    pub fn is_revoked(&self) -> bool {
48        matches!(self, Self::Revoked { .. })
49    }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct AccessToken {
54    pub id: Ulid,
55    pub state: AccessTokenState,
56    pub session_id: Ulid,
57    pub access_token: String,
58    pub created_at: DateTime<Utc>,
59    pub expires_at: Option<DateTime<Utc>>,
60    pub first_used_at: Option<DateTime<Utc>>,
61}
62
63impl AccessToken {
64    #[must_use]
65    pub fn jti(&self) -> String {
66        self.id.to_string()
67    }
68
69    /// Whether the access token is valid, i.e. not revoked and not expired
70    ///
71    /// # Parameters
72    ///
73    /// * `now` - The current time
74    #[must_use]
75    pub fn is_valid(&self, now: DateTime<Utc>) -> bool {
76        self.state.is_valid() && !self.is_expired(now)
77    }
78
79    /// Whether the access token is expired
80    ///
81    /// Always returns `false` if the access token does not have an expiry time.
82    ///
83    /// # Parameters
84    ///
85    /// * `now` - The current time
86    #[must_use]
87    pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
88        match self.expires_at {
89            Some(expires_at) => expires_at < now,
90            None => false,
91        }
92    }
93
94    /// Whether the access token was used at least once
95    #[must_use]
96    pub fn is_used(&self) -> bool {
97        self.first_used_at.is_some()
98    }
99
100    /// Mark the access token as revoked
101    ///
102    /// # Parameters
103    ///
104    /// * `revoked_at` - The time at which the access token was revoked
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the access token is already revoked
109    pub fn revoke(mut self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
110        self.state = self.state.revoke(revoked_at)?;
111        Ok(self)
112    }
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
116pub enum RefreshTokenState {
117    #[default]
118    Valid,
119    Consumed {
120        consumed_at: DateTime<Utc>,
121        next_refresh_token_id: Option<Ulid>,
122    },
123    Revoked {
124        revoked_at: DateTime<Utc>,
125    },
126}
127
128impl RefreshTokenState {
129    /// Consume the refresh token, returning a new state.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the refresh token is revoked.
134    fn consume(
135        self,
136        consumed_at: DateTime<Utc>,
137        replaced_by: &RefreshToken,
138    ) -> Result<Self, InvalidTransitionError> {
139        match self {
140            Self::Valid | Self::Consumed { .. } => Ok(Self::Consumed {
141                consumed_at,
142                next_refresh_token_id: Some(replaced_by.id),
143            }),
144            Self::Revoked { .. } => Err(InvalidTransitionError),
145        }
146    }
147
148    /// Revoke the refresh token, returning a new state.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the refresh token is already consumed or revoked.
153    pub fn revoke(self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
154        match self {
155            Self::Valid => Ok(Self::Revoked { revoked_at }),
156            Self::Consumed { .. } | Self::Revoked { .. } => Err(InvalidTransitionError),
157        }
158    }
159
160    /// Returns `true` if the refresh token state is [`Valid`].
161    ///
162    /// [`Valid`]: RefreshTokenState::Valid
163    #[must_use]
164    pub fn is_valid(&self) -> bool {
165        matches!(self, Self::Valid)
166    }
167
168    /// Returns the next refresh token ID, if any.
169    #[must_use]
170    pub fn next_refresh_token_id(&self) -> Option<Ulid> {
171        match self {
172            Self::Valid | Self::Revoked { .. } => None,
173            Self::Consumed {
174                next_refresh_token_id,
175                ..
176            } => *next_refresh_token_id,
177        }
178    }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct RefreshToken {
183    pub id: Ulid,
184    pub state: RefreshTokenState,
185    pub refresh_token: String,
186    pub session_id: Ulid,
187    pub created_at: DateTime<Utc>,
188    pub access_token_id: Option<Ulid>,
189}
190
191impl std::ops::Deref for RefreshToken {
192    type Target = RefreshTokenState;
193
194    fn deref(&self) -> &Self::Target {
195        &self.state
196    }
197}
198
199impl RefreshToken {
200    #[must_use]
201    pub fn jti(&self) -> String {
202        self.id.to_string()
203    }
204
205    /// Consumes the refresh token and returns the consumed token.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the refresh token is revoked.
210    pub fn consume(
211        mut self,
212        consumed_at: DateTime<Utc>,
213        replaced_by: &Self,
214    ) -> Result<Self, InvalidTransitionError> {
215        self.state = self.state.consume(consumed_at, replaced_by)?;
216        Ok(self)
217    }
218
219    /// Revokes the refresh token and returns a new revoked token
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the refresh token is already revoked.
224    pub fn revoke(mut self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
225        self.state = self.state.revoke(revoked_at)?;
226        Ok(self)
227    }
228}
229
230/// Type of token to generate or validate
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum TokenType {
233    /// An access token, used by Relying Parties to authenticate requests
234    AccessToken,
235
236    /// A refresh token, used by the refresh token grant
237    RefreshToken,
238
239    /// A legacy access token
240    CompatAccessToken,
241
242    /// A legacy refresh token
243    CompatRefreshToken,
244
245    /// A personal access token.
246    PersonalAccessToken,
247}
248
249impl std::fmt::Display for TokenType {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            TokenType::AccessToken => write!(f, "access token"),
253            TokenType::RefreshToken => write!(f, "refresh token"),
254            TokenType::CompatAccessToken => write!(f, "compat access token"),
255            TokenType::CompatRefreshToken => write!(f, "compat refresh token"),
256            TokenType::PersonalAccessToken => write!(f, "personal access token"),
257        }
258    }
259}
260
261impl TokenType {
262    fn prefix(self) -> &'static str {
263        match self {
264            TokenType::AccessToken => "mat",
265            TokenType::RefreshToken => "mar",
266            TokenType::CompatAccessToken => "mct",
267            TokenType::CompatRefreshToken => "mcr",
268            TokenType::PersonalAccessToken => "mpt",
269        }
270    }
271
272    fn match_prefix(prefix: &str) -> Option<Self> {
273        match prefix {
274            "mat" => Some(TokenType::AccessToken),
275            "mar" => Some(TokenType::RefreshToken),
276            "mct" | "syt" => Some(TokenType::CompatAccessToken),
277            "mcr" | "syr" => Some(TokenType::CompatRefreshToken),
278            "mpt" => Some(TokenType::PersonalAccessToken),
279            _ => None,
280        }
281    }
282
283    /// Generate a token for the given type
284    pub fn generate(self, rng: &mut (impl Rng + ?Sized)) -> String {
285        let random_part: String = rng
286            .sample_iter(&Alphanumeric)
287            .take(30)
288            .map(char::from)
289            .collect();
290
291        let base = format!("{prefix}_{random_part}", prefix = self.prefix());
292        let crc = CRC.checksum(base.as_bytes());
293        let crc = base62_encode(crc);
294        format!("{base}_{crc}")
295    }
296
297    /// Check the format of a token and determine its type
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the token is not valid
302    pub fn check(token: &str) -> Result<TokenType, TokenFormatError> {
303        // these are legacy tokens imported from Synapse
304        // we don't do any validation on them and continue as is
305        if token.starts_with("syt_") || is_likely_synapse_macaroon(token) {
306            return Ok(TokenType::CompatAccessToken);
307        }
308        if token.starts_with("syr_") {
309            return Ok(TokenType::CompatRefreshToken);
310        }
311
312        let split: Vec<&str> = token.split('_').collect();
313        let [prefix, random_part, crc]: [&str; 3] = split
314            .try_into()
315            .map_err(|_| TokenFormatError::InvalidFormat)?;
316
317        if prefix.len() != 3 || random_part.len() != 30 || crc.len() != 6 {
318            return Err(TokenFormatError::InvalidFormat);
319        }
320
321        let token_type =
322            TokenType::match_prefix(prefix).ok_or_else(|| TokenFormatError::UnknownPrefix {
323                prefix: prefix.to_owned(),
324            })?;
325
326        let base = format!("{prefix}_{random_part}", prefix = token_type.prefix());
327        let expected_crc = CRC.checksum(base.as_bytes());
328        let expected_crc = base62_encode(expected_crc);
329        if crc != expected_crc {
330            return Err(TokenFormatError::InvalidCrc {
331                expected: expected_crc,
332                got: crc.to_owned(),
333            });
334        }
335
336        Ok(token_type)
337    }
338}
339
340impl PartialEq<OAuthTokenTypeHint> for TokenType {
341    fn eq(&self, other: &OAuthTokenTypeHint) -> bool {
342        matches!(
343            (self, other),
344            (
345                TokenType::AccessToken
346                    | TokenType::CompatAccessToken
347                    | TokenType::PersonalAccessToken,
348                OAuthTokenTypeHint::AccessToken
349            ) | (
350                TokenType::RefreshToken | TokenType::CompatRefreshToken,
351                OAuthTokenTypeHint::RefreshToken
352            )
353        )
354    }
355}
356
357/// Returns true if and only if a token looks like it may be a macaroon.
358///
359/// Macaroons are a standard for tokens that support attenuation.
360/// Synapse used them for old sessions and for guest sessions.
361///
362/// We won't bother to decode them fully, but we can check to see if the first
363/// constraint is the `location` constraint.
364fn is_likely_synapse_macaroon(token: &str) -> bool {
365    let Ok(decoded) = Base64UrlUnpadded::decode_vec(token) else {
366        return false;
367    };
368    decoded.get(4..13) == Some(b"location ")
369}
370
371const NUM: [u8; 62] = *b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
372
373fn base62_encode(mut num: u32) -> String {
374    let mut res = String::with_capacity(6);
375    while num > 0 {
376        res.push(NUM[(num % 62) as usize] as char);
377        num /= 62;
378    }
379
380    format!("{res:0>6}")
381}
382
383const CRC: Crc<u32> = Crc::<u32>::new(&CRC_32_ISO_HDLC);
384
385/// Invalid token
386#[derive(Debug, Error, PartialEq, Eq)]
387pub enum TokenFormatError {
388    /// Overall token format is invalid
389    #[error("invalid token format")]
390    InvalidFormat,
391
392    /// Token used an unknown prefix
393    #[error("unknown token prefix {prefix:?}")]
394    UnknownPrefix {
395        /// The prefix found in the token
396        prefix: String,
397    },
398
399    /// The CRC checksum in the token is invalid
400    #[error("invalid crc {got:?}, expected {expected:?}")]
401    InvalidCrc {
402        /// The CRC hash expected to be found in the token
403        expected: String,
404        /// The CRC found in the token
405        got: String,
406    },
407}
408
409#[cfg(test)]
410mod tests {
411    use std::collections::HashSet;
412
413    use rand::rng;
414
415    use super::*;
416
417    #[test]
418    fn test_prefix_match() {
419        use TokenType::{AccessToken, CompatAccessToken, CompatRefreshToken, RefreshToken};
420        assert_eq!(TokenType::match_prefix("syt"), Some(CompatAccessToken));
421        assert_eq!(TokenType::match_prefix("syr"), Some(CompatRefreshToken));
422        assert_eq!(TokenType::match_prefix("mct"), Some(CompatAccessToken));
423        assert_eq!(TokenType::match_prefix("mcr"), Some(CompatRefreshToken));
424        assert_eq!(TokenType::match_prefix("mat"), Some(AccessToken));
425        assert_eq!(TokenType::match_prefix("mar"), Some(RefreshToken));
426        assert_eq!(TokenType::match_prefix("matt"), None);
427        assert_eq!(TokenType::match_prefix("marr"), None);
428        assert_eq!(TokenType::match_prefix("ma"), None);
429        assert_eq!(
430            TokenType::match_prefix(TokenType::CompatAccessToken.prefix()),
431            Some(TokenType::CompatAccessToken)
432        );
433        assert_eq!(
434            TokenType::match_prefix(TokenType::CompatRefreshToken.prefix()),
435            Some(TokenType::CompatRefreshToken)
436        );
437        assert_eq!(
438            TokenType::match_prefix(TokenType::AccessToken.prefix()),
439            Some(TokenType::AccessToken)
440        );
441        assert_eq!(
442            TokenType::match_prefix(TokenType::RefreshToken.prefix()),
443            Some(TokenType::RefreshToken)
444        );
445    }
446
447    #[test]
448    fn test_is_likely_synapse_macaroon() {
449        // This is just the prefix of a Synapse macaroon, but it's enough to make the
450        // sniffing work
451        assert!(is_likely_synapse_macaroon(
452            "MDAxYmxvY2F0aW9uIGxpYnJlcHVzaC5uZXQKMDAx"
453        ));
454
455        // This is a valid macaroon (even though Synapse did not generate this one)
456        assert!(is_likely_synapse_macaroon(
457            "MDAxY2xvY2F0aW9uIGh0dHA6Ly9teWJhbmsvCjAwMjZpZGVudGlmaWVyIHdlIHVzZWQgb3VyIHNlY3JldCBrZXkKMDAyZnNpZ25hdHVyZSDj2eApCFJsTAA5rhURQRXZf91ovyujebNCqvD2F9BVLwo"
458        ));
459
460        // None of these are macaroons
461        assert!(!is_likely_synapse_macaroon(
462            "eyJARTOhearotnaeisahtoarsnhiasra.arsohenaor.oarnsteao"
463        ));
464        assert!(!is_likely_synapse_macaroon("...."));
465        assert!(!is_likely_synapse_macaroon("aaa"));
466    }
467
468    #[test]
469    fn test_generate_and_check() {
470        const COUNT: usize = 500; // Generate 500 of each token type
471
472        #[expect(clippy::disallowed_methods)]
473        let mut rng = rng();
474
475        for t in [
476            TokenType::CompatAccessToken,
477            TokenType::CompatRefreshToken,
478            TokenType::AccessToken,
479            TokenType::RefreshToken,
480        ] {
481            // Generate many tokens
482            let tokens: HashSet<String> = (0..COUNT).map(|_| t.generate(&mut rng)).collect();
483
484            // Check that they are all different
485            assert_eq!(tokens.len(), COUNT, "All tokens are unique");
486
487            // Check that they are all valid and detected as the right token type
488            for token in tokens {
489                assert_eq!(TokenType::check(&token).unwrap(), t);
490            }
491        }
492    }
493}