1use 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 #[must_use]
39 pub fn is_valid(&self) -> bool {
40 matches!(self, Self::Valid)
41 }
42
43 #[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 #[must_use]
75 pub fn is_valid(&self, now: DateTime<Utc>) -> bool {
76 self.state.is_valid() && !self.is_expired(now)
77 }
78
79 #[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 #[must_use]
96 pub fn is_used(&self) -> bool {
97 self.first_used_at.is_some()
98 }
99
100 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 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 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 #[must_use]
164 pub fn is_valid(&self) -> bool {
165 matches!(self, Self::Valid)
166 }
167
168 #[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum TokenType {
233 AccessToken,
235
236 RefreshToken,
238
239 CompatAccessToken,
241
242 CompatRefreshToken,
244
245 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 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 pub fn check(token: &str) -> Result<TokenType, TokenFormatError> {
303 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
357fn 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#[derive(Debug, Error, PartialEq, Eq)]
387pub enum TokenFormatError {
388 #[error("invalid token format")]
390 InvalidFormat,
391
392 #[error("unknown token prefix {prefix:?}")]
394 UnknownPrefix {
395 prefix: String,
397 },
398
399 #[error("invalid crc {got:?}, expected {expected:?}")]
401 InvalidCrc {
402 expected: String,
404 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 assert!(is_likely_synapse_macaroon(
452 "MDAxYmxvY2F0aW9uIGxpYnJlcHVzaC5uZXQKMDAx"
453 ));
454
455 assert!(is_likely_synapse_macaroon(
457 "MDAxY2xvY2F0aW9uIGh0dHA6Ly9teWJhbmsvCjAwMjZpZGVudGlmaWVyIHdlIHVzZWQgb3VyIHNlY3JldCBrZXkKMDAyZnNpZ25hdHVyZSDj2eApCFJsTAA5rhURQRXZf91ovyujebNCqvD2F9BVLwo"
458 ));
459
460 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; #[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 let tokens: HashSet<String> = (0..COUNT).map(|_| t.generate(&mut rng)).collect();
483
484 assert_eq!(tokens.len(), COUNT, "All tokens are unique");
486
487 for token in tokens {
489 assert_eq!(TokenType::check(&token).unwrap(), t);
490 }
491 }
492 }
493}