mas_data_model/oauth2/device_code_grant.rs
1// Copyright 2024, 2025 New Vector Ltd.
2// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use std::net::IpAddr;
8
9use chrono::{DateTime, Utc};
10use oauth2_types::scope::Scope;
11use rand::{Rng, RngCore};
12use serde::Serialize;
13use ulid::Ulid;
14
15use crate::{BrowserSession, InvalidTransitionError, Session};
16
17/// The [Crockford Base32] symbol set, used for device grant user codes.
18///
19/// `I`, `L` and `O` are excluded because they are easily confused with `1` and
20/// `0`; `U` is excluded to reduce the chance of a code spelling something
21/// obscene.
22///
23/// [Crockford Base32]: https://www.crockford.com/base32.html
24const USER_CODE_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
25
26/// The number of symbols in a generated user code.
27///
28/// The alphabet has 32 symbols, so each one carries exactly 5 bits: a
29/// six-symbol code is 30 bits of entropy.
30const USER_CODE_LENGTH: usize = 6;
31
32/// Generate a random user code for a device code grant.
33pub fn generate_user_code<R: RngCore + ?Sized>(rng: &mut R) -> String {
34 (0..USER_CODE_LENGTH)
35 .map(|_| char::from(USER_CODE_ALPHABET[rng.gen_range(0..USER_CODE_ALPHABET.len())]))
36 .collect()
37}
38
39/// Apply the [Crockford Base32] decode mapping to a user code as typed by a
40/// user.
41///
42/// On top of uppercasing, this folds `O` onto `0` and `I` and `L` onto `1`,
43/// repairing a user who read a `0` as an `O` or a `1` as an `I`.
44///
45/// `U` is not in the alphabet but has no mapping defined for it either, so it
46/// is passed through unchanged.
47///
48/// Crockford ignores hyphens when decoding, but we issue none, so nothing is
49/// stripped here: hyphens, whitespace and punctuation all just fail to match.
50///
51/// The mapping is lossy, so a caller which looks up both the code as the user
52/// typed and its normalized form should try the code as typed first: a legacy
53/// code containing `I`, `L` or `O` is otherwise shadowed by any live grant at
54/// the code it folds onto. See the lookup in the device link handler.
55///
56/// [Crockford Base32]: https://www.crockford.com/base32.html
57#[must_use]
58pub fn normalize_user_code(code: &str) -> String {
59 code.to_uppercase()
60 .chars()
61 .map(|c| match c {
62 'O' => '0',
63 'I' | 'L' => '1',
64 c => c,
65 })
66 .collect()
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70#[serde(rename_all = "snake_case", tag = "state")]
71pub enum DeviceCodeGrantState {
72 /// The device code grant is pending.
73 Pending,
74
75 /// The device code grant has been fulfilled by a user.
76 Fulfilled {
77 /// The browser session which was used to complete this device code
78 /// grant.
79 browser_session_id: Ulid,
80
81 /// The time at which this device code grant was fulfilled.
82 fulfilled_at: DateTime<Utc>,
83 },
84
85 /// The device code grant has been rejected by a user.
86 Rejected {
87 /// The browser session which was used to reject this device code grant.
88 browser_session_id: Ulid,
89
90 /// The time at which this device code grant was rejected.
91 rejected_at: DateTime<Utc>,
92 },
93
94 /// The device code grant was exchanged for an access token.
95 Exchanged {
96 /// The browser session which was used to exchange this device code
97 /// grant.
98 browser_session_id: Ulid,
99
100 /// The time at which the device code grant was fulfilled.
101 fulfilled_at: DateTime<Utc>,
102
103 /// The time at which this device code grant was exchanged.
104 exchanged_at: DateTime<Utc>,
105
106 /// The OAuth 2.0 session ID which was created by this device code
107 /// grant.
108 session_id: Ulid,
109 },
110}
111
112impl DeviceCodeGrantState {
113 /// Mark this device code grant as fulfilled, returning a new state.
114 ///
115 /// # Errors
116 ///
117 /// Returns an error if the device code grant is not in the [`Pending`]
118 /// state.
119 ///
120 /// [`Pending`]: DeviceCodeGrantState::Pending
121 pub fn fulfill(
122 self,
123 browser_session: &BrowserSession,
124 fulfilled_at: DateTime<Utc>,
125 ) -> Result<Self, InvalidTransitionError> {
126 match self {
127 DeviceCodeGrantState::Pending => Ok(DeviceCodeGrantState::Fulfilled {
128 browser_session_id: browser_session.id,
129 fulfilled_at,
130 }),
131 _ => Err(InvalidTransitionError),
132 }
133 }
134
135 /// Mark this device code grant as rejected, returning a new state.
136 ///
137 /// # Errors
138 ///
139 /// Returns an error if the device code grant is not in the [`Pending`]
140 /// state.
141 ///
142 /// [`Pending`]: DeviceCodeGrantState::Pending
143 pub fn reject(
144 self,
145 browser_session: &BrowserSession,
146 rejected_at: DateTime<Utc>,
147 ) -> Result<Self, InvalidTransitionError> {
148 match self {
149 DeviceCodeGrantState::Pending => Ok(DeviceCodeGrantState::Rejected {
150 browser_session_id: browser_session.id,
151 rejected_at,
152 }),
153 _ => Err(InvalidTransitionError),
154 }
155 }
156
157 /// Mark this device code grant as exchanged, returning a new state.
158 ///
159 /// # Errors
160 ///
161 /// Returns an error if the device code grant is not in the [`Fulfilled`]
162 /// state.
163 ///
164 /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled
165 pub fn exchange(
166 self,
167 session: &Session,
168 exchanged_at: DateTime<Utc>,
169 ) -> Result<Self, InvalidTransitionError> {
170 match self {
171 DeviceCodeGrantState::Fulfilled {
172 fulfilled_at,
173 browser_session_id,
174 ..
175 } => Ok(DeviceCodeGrantState::Exchanged {
176 browser_session_id,
177 fulfilled_at,
178 exchanged_at,
179 session_id: session.id,
180 }),
181 _ => Err(InvalidTransitionError),
182 }
183 }
184
185 /// Returns `true` if the device code grant state is [`Pending`].
186 ///
187 /// [`Pending`]: DeviceCodeGrantState::Pending
188 #[must_use]
189 pub fn is_pending(&self) -> bool {
190 matches!(self, Self::Pending)
191 }
192
193 /// Returns `true` if the device code grant state is [`Fulfilled`].
194 ///
195 /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled
196 #[must_use]
197 pub fn is_fulfilled(&self) -> bool {
198 matches!(self, Self::Fulfilled { .. })
199 }
200
201 /// Returns `true` if the device code grant state is [`Rejected`].
202 ///
203 /// [`Rejected`]: DeviceCodeGrantState::Rejected
204 #[must_use]
205 pub fn is_rejected(&self) -> bool {
206 matches!(self, Self::Rejected { .. })
207 }
208
209 /// Returns `true` if the device code grant state is [`Exchanged`].
210 ///
211 /// [`Exchanged`]: DeviceCodeGrantState::Exchanged
212 #[must_use]
213 pub fn is_exchanged(&self) -> bool {
214 matches!(self, Self::Exchanged { .. })
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
219pub struct DeviceCodeGrant {
220 pub id: Ulid,
221 #[serde(flatten)]
222 pub state: DeviceCodeGrantState,
223
224 /// The client ID which requested this device code grant.
225 pub client_id: Ulid,
226
227 /// The scope which was requested by this device code grant.
228 pub scope: Scope,
229
230 /// The user code which was generated for this device code grant.
231 /// This is the one that the user will enter into their client.
232 pub user_code: String,
233
234 /// The device code which was generated for this device code grant.
235 /// This is the one that the client will use to poll for an access token.
236 pub device_code: String,
237
238 /// The time at which this device code grant was created.
239 pub created_at: DateTime<Utc>,
240
241 /// The time at which this device code grant will expire.
242 pub expires_at: DateTime<Utc>,
243
244 /// The IP address of the client which requested this device code grant.
245 pub ip_address: Option<IpAddr>,
246
247 /// The user agent used to request this device code grant.
248 pub user_agent: Option<String>,
249
250 /// The locale detected from the browser which fulfilled this device code
251 /// grant. Used to render a human-readable device name. [`None`] until the
252 /// grant is fulfilled.
253 pub locale: Option<String>,
254}
255
256impl std::ops::Deref for DeviceCodeGrant {
257 type Target = DeviceCodeGrantState;
258
259 fn deref(&self) -> &Self::Target {
260 &self.state
261 }
262}
263
264impl DeviceCodeGrant {
265 /// Mark this device code grant as fulfilled, returning the updated grant.
266 ///
267 /// # Errors
268 ///
269 /// Returns an error if the device code grant is not in the [`Pending`]
270 /// state.
271 ///
272 /// [`Pending`]: DeviceCodeGrantState::Pending
273 pub fn fulfill(
274 self,
275 browser_session: &BrowserSession,
276 locale: Option<String>,
277 fulfilled_at: DateTime<Utc>,
278 ) -> Result<Self, InvalidTransitionError> {
279 Ok(Self {
280 state: self.state.fulfill(browser_session, fulfilled_at)?,
281 locale,
282 ..self
283 })
284 }
285
286 /// Mark this device code grant as rejected, returning the updated grant.
287 ///
288 /// # Errors
289 ///
290 /// Returns an error if the device code grant is not in the [`Pending`]
291 ///
292 /// [`Pending`]: DeviceCodeGrantState::Pending
293 pub fn reject(
294 self,
295 browser_session: &BrowserSession,
296 rejected_at: DateTime<Utc>,
297 ) -> Result<Self, InvalidTransitionError> {
298 Ok(Self {
299 state: self.state.reject(browser_session, rejected_at)?,
300 ..self
301 })
302 }
303
304 /// Mark this device code grant as exchanged, returning the updated grant.
305 ///
306 /// # Errors
307 ///
308 /// Returns an error if the device code grant is not in the [`Fulfilled`]
309 /// state.
310 ///
311 /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled
312 pub fn exchange(
313 self,
314 session: &Session,
315 exchanged_at: DateTime<Utc>,
316 ) -> Result<Self, InvalidTransitionError> {
317 Ok(Self {
318 state: self.state.exchange(session, exchanged_at)?,
319 ..self
320 })
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use rand::SeedableRng;
327 use rand_chacha::ChaCha8Rng;
328
329 use super::*;
330
331 #[test]
332 fn test_generate_user_code_uses_the_crockford_alphabet() {
333 let mut rng = ChaCha8Rng::seed_from_u64(42);
334
335 for _ in 0..1000 {
336 let code = generate_user_code(&mut rng);
337
338 assert_eq!(code.chars().count(), USER_CODE_LENGTH);
339 for c in code.chars() {
340 assert!(
341 USER_CODE_ALPHABET.contains(&u8::try_from(c).unwrap()),
342 "generated code {code:?} has {c:?}, which is not in the alphabet"
343 );
344 // The whole point of the alphabet: these are the characters
345 // which get confused for `0` and `1`, plus the one Crockford
346 // drops to avoid obscenities.
347 assert!(
348 !matches!(c, 'I' | 'L' | 'O' | 'U'),
349 "generated code {code:?} has an excluded character {c:?}"
350 );
351 }
352 }
353 }
354
355 /// Normalising a freshly generated code must be a no-op, otherwise the
356 /// codes we hand out would not match themselves when typed back in
357 /// correctly. This holds by construction — the alphabet is uppercase and
358 /// contains none of `I`, `L` or `O` — and this test is what keeps
359 /// [`generate_user_code`] and [`normalize_user_code`] in step if either is
360 /// edited later.
361 #[test]
362 fn test_generated_user_codes_normalize_to_themselves() {
363 let mut rng = ChaCha8Rng::seed_from_u64(42);
364
365 for _ in 0..1000 {
366 let code = generate_user_code(&mut rng);
367 assert_eq!(normalize_user_code(&code), code);
368 }
369 }
370
371 #[test]
372 fn test_normalize_user_code_applies_the_decode_mapping() {
373 assert_eq!(normalize_user_code("01ILO"), "01110");
374 assert_eq!(normalize_user_code("hello"), "HE110");
375 // Crockford defines no mapping for these, so both members of each pair
376 // stay ambiguous and must be left as they are.
377 assert_eq!(normalize_user_code("8bs5"), "8BS5");
378 assert_eq!(normalize_user_code("2z6g9q"), "2Z6G9Q");
379 // `U` is not in the alphabet but has no mapping either.
380 assert_eq!(normalize_user_code("u"), "U");
381 }
382
383 /// We never put separators into the codes we hand out, so there is nothing
384 /// for a user to optionally include and nothing to strip. Anything which
385 /// isn't part of the code is passed through, and will simply fail to match.
386 #[test]
387 fn test_normalize_user_code_does_not_strip_separators() {
388 assert_eq!(normalize_user_code("abc-def"), "ABC-DEF");
389 assert_eq!(normalize_user_code(" abcdef "), " ABCDEF ");
390 assert_eq!(normalize_user_code("---"), "---");
391 }
392
393 #[test]
394 fn test_normalize_user_code_is_idempotent() {
395 for input in ["01ILO", "hello", "abc-def", " 8b s5 ", "xil9oz", ""] {
396 let once = normalize_user_code(input);
397 assert_eq!(normalize_user_code(&once), once);
398 }
399 }
400}