Skip to main content

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}