Skip to main content

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}