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 serde::Serialize;
12use ulid::Ulid;
13
14use crate::{BrowserSession, InvalidTransitionError, Session};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "snake_case", tag = "state")]
18pub enum DeviceCodeGrantState {
19    /// The device code grant is pending.
20    Pending,
21
22    /// The device code grant has been fulfilled by a user.
23    Fulfilled {
24        /// The browser session which was used to complete this device code
25        /// grant.
26        browser_session_id: Ulid,
27
28        /// The time at which this device code grant was fulfilled.
29        fulfilled_at: DateTime<Utc>,
30    },
31
32    /// The device code grant has been rejected by a user.
33    Rejected {
34        /// The browser session which was used to reject this device code grant.
35        browser_session_id: Ulid,
36
37        /// The time at which this device code grant was rejected.
38        rejected_at: DateTime<Utc>,
39    },
40
41    /// The device code grant was exchanged for an access token.
42    Exchanged {
43        /// The browser session which was used to exchange this device code
44        /// grant.
45        browser_session_id: Ulid,
46
47        /// The time at which the device code grant was fulfilled.
48        fulfilled_at: DateTime<Utc>,
49
50        /// The time at which this device code grant was exchanged.
51        exchanged_at: DateTime<Utc>,
52
53        /// The OAuth 2.0 session ID which was created by this device code
54        /// grant.
55        session_id: Ulid,
56    },
57}
58
59impl DeviceCodeGrantState {
60    /// Mark this device code grant as fulfilled, returning a new state.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if the device code grant is not in the [`Pending`]
65    /// state.
66    ///
67    /// [`Pending`]: DeviceCodeGrantState::Pending
68    pub fn fulfill(
69        self,
70        browser_session: &BrowserSession,
71        fulfilled_at: DateTime<Utc>,
72    ) -> Result<Self, InvalidTransitionError> {
73        match self {
74            DeviceCodeGrantState::Pending => Ok(DeviceCodeGrantState::Fulfilled {
75                browser_session_id: browser_session.id,
76                fulfilled_at,
77            }),
78            _ => Err(InvalidTransitionError),
79        }
80    }
81
82    /// Mark this device code grant as rejected, returning a new state.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the device code grant is not in the [`Pending`]
87    /// state.
88    ///
89    /// [`Pending`]: DeviceCodeGrantState::Pending
90    pub fn reject(
91        self,
92        browser_session: &BrowserSession,
93        rejected_at: DateTime<Utc>,
94    ) -> Result<Self, InvalidTransitionError> {
95        match self {
96            DeviceCodeGrantState::Pending => Ok(DeviceCodeGrantState::Rejected {
97                browser_session_id: browser_session.id,
98                rejected_at,
99            }),
100            _ => Err(InvalidTransitionError),
101        }
102    }
103
104    /// Mark this device code grant as exchanged, returning a new state.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the device code grant is not in the [`Fulfilled`]
109    /// state.
110    ///
111    /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled
112    pub fn exchange(
113        self,
114        session: &Session,
115        exchanged_at: DateTime<Utc>,
116    ) -> Result<Self, InvalidTransitionError> {
117        match self {
118            DeviceCodeGrantState::Fulfilled {
119                fulfilled_at,
120                browser_session_id,
121                ..
122            } => Ok(DeviceCodeGrantState::Exchanged {
123                browser_session_id,
124                fulfilled_at,
125                exchanged_at,
126                session_id: session.id,
127            }),
128            _ => Err(InvalidTransitionError),
129        }
130    }
131
132    /// Returns `true` if the device code grant state is [`Pending`].
133    ///
134    /// [`Pending`]: DeviceCodeGrantState::Pending
135    #[must_use]
136    pub fn is_pending(&self) -> bool {
137        matches!(self, Self::Pending)
138    }
139
140    /// Returns `true` if the device code grant state is [`Fulfilled`].
141    ///
142    /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled
143    #[must_use]
144    pub fn is_fulfilled(&self) -> bool {
145        matches!(self, Self::Fulfilled { .. })
146    }
147
148    /// Returns `true` if the device code grant state is [`Rejected`].
149    ///
150    /// [`Rejected`]: DeviceCodeGrantState::Rejected
151    #[must_use]
152    pub fn is_rejected(&self) -> bool {
153        matches!(self, Self::Rejected { .. })
154    }
155
156    /// Returns `true` if the device code grant state is [`Exchanged`].
157    ///
158    /// [`Exchanged`]: DeviceCodeGrantState::Exchanged
159    #[must_use]
160    pub fn is_exchanged(&self) -> bool {
161        matches!(self, Self::Exchanged { .. })
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
166pub struct DeviceCodeGrant {
167    pub id: Ulid,
168    #[serde(flatten)]
169    pub state: DeviceCodeGrantState,
170
171    /// The client ID which requested this device code grant.
172    pub client_id: Ulid,
173
174    /// The scope which was requested by this device code grant.
175    pub scope: Scope,
176
177    /// The user code which was generated for this device code grant.
178    /// This is the one that the user will enter into their client.
179    pub user_code: String,
180
181    /// The device code which was generated for this device code grant.
182    /// This is the one that the client will use to poll for an access token.
183    pub device_code: String,
184
185    /// The time at which this device code grant was created.
186    pub created_at: DateTime<Utc>,
187
188    /// The time at which this device code grant will expire.
189    pub expires_at: DateTime<Utc>,
190
191    /// The IP address of the client which requested this device code grant.
192    pub ip_address: Option<IpAddr>,
193
194    /// The user agent used to request this device code grant.
195    pub user_agent: Option<String>,
196
197    /// The locale detected from the browser which fulfilled this device code
198    /// grant. Used to render a human-readable device name. [`None`] until the
199    /// grant is fulfilled.
200    pub locale: Option<String>,
201}
202
203impl std::ops::Deref for DeviceCodeGrant {
204    type Target = DeviceCodeGrantState;
205
206    fn deref(&self) -> &Self::Target {
207        &self.state
208    }
209}
210
211impl DeviceCodeGrant {
212    /// Mark this device code grant as fulfilled, returning the updated grant.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if the device code grant is not in the [`Pending`]
217    /// state.
218    ///
219    /// [`Pending`]: DeviceCodeGrantState::Pending
220    pub fn fulfill(
221        self,
222        browser_session: &BrowserSession,
223        locale: Option<String>,
224        fulfilled_at: DateTime<Utc>,
225    ) -> Result<Self, InvalidTransitionError> {
226        Ok(Self {
227            state: self.state.fulfill(browser_session, fulfilled_at)?,
228            locale,
229            ..self
230        })
231    }
232
233    /// Mark this device code grant as rejected, returning the updated grant.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the device code grant is not in the [`Pending`]
238    ///
239    /// [`Pending`]: DeviceCodeGrantState::Pending
240    pub fn reject(
241        self,
242        browser_session: &BrowserSession,
243        rejected_at: DateTime<Utc>,
244    ) -> Result<Self, InvalidTransitionError> {
245        Ok(Self {
246            state: self.state.reject(browser_session, rejected_at)?,
247            ..self
248        })
249    }
250
251    /// Mark this device code grant as exchanged, returning the updated grant.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the device code grant is not in the [`Fulfilled`]
256    /// state.
257    ///
258    /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled
259    pub fn exchange(
260        self,
261        session: &Session,
262        exchanged_at: DateTime<Utc>,
263    ) -> Result<Self, InvalidTransitionError> {
264        Ok(Self {
265            state: self.state.exchange(session, exchanged_at)?,
266            ..self
267        })
268    }
269}