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