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}