1use crate::{library::oauth::Digest, prelude::*};
2use chrono::{DateTime, Duration, Utc};
3use serde::{Deserialize, Serialize};
4use sqlx::{FromRow, PgConnection, Type};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
11#[sqlx(type_name = "device_code_status", rename_all = "snake_case")]
12#[serde(rename_all = "snake_case")]
13pub enum DeviceCodeStatus {
14 Pending,
15 Approved,
16 Denied,
17}
18
19#[derive(Debug, Serialize, Deserialize, FromRow)]
24pub struct OAuthDeviceCode {
25 pub device_code_digest: Digest,
26 pub user_code: String,
27 pub client_id: Uuid,
28 pub user_id: Option<Uuid>,
30 pub scopes: Vec<String>,
31 pub status: DeviceCodeStatus,
32 pub jti: Uuid,
33 pub interval_seconds: i32,
34 pub last_polled_at: Option<DateTime<Utc>>,
35 pub expires_at: DateTime<Utc>,
36 pub created_at: DateTime<Utc>,
37 pub updated_at: DateTime<Utc>,
38 pub metadata: serde_json::Value,
39}
40
41#[derive(Debug, Clone)]
42pub struct NewDeviceCodeParams<'a> {
43 pub device_code_digest: &'a Digest,
44 pub user_code: &'a str,
45 pub client_id: Uuid,
46 pub scopes: &'a [String],
47 pub interval_seconds: i32,
48 pub expires_at: DateTime<Utc>,
49 pub metadata: serde_json::Map<String, serde_json::Value>,
50}
51
52#[derive(Debug, Clone)]
60pub struct DeviceCodePoll {
61 pub status: DeviceCodeStatus,
62 pub expires_at: DateTime<Utc>,
63 pub interval_seconds: i32,
64 pub previous_polled_at: Option<DateTime<Utc>>,
65 pub user_id: Option<Uuid>,
66 pub client_id: Uuid,
67 pub scopes: Vec<String>,
68}
69
70impl OAuthDeviceCode {
71 pub async fn insert(
73 conn: &mut PgConnection,
74 params: NewDeviceCodeParams<'_>,
75 ) -> ModelResult<()> {
76 sqlx::query!(
77 r#"
78 INSERT INTO oauth_device_codes (
79 device_code_digest,
80 user_code,
81 client_id,
82 scopes,
83 interval_seconds,
84 expires_at,
85 metadata
86 )
87 VALUES (
88 $1,$2,$3,$4,$5,$6,$7
89 )
90 "#,
91 params.device_code_digest.as_bytes(),
92 params.user_code,
93 params.client_id,
94 params.scopes,
95 params.interval_seconds,
96 params.expires_at,
97 serde_json::Value::Object(params.metadata)
98 )
99 .execute(conn)
100 .await?;
101
102 Ok(())
103 }
104
105 pub const EXPIRED_RETENTION: Duration = Duration::hours(1);
111
112 pub async fn delete_expired(conn: &mut PgConnection) -> ModelResult<u64> {
122 let deleted = sqlx::query!(
123 r#"DELETE FROM oauth_device_codes WHERE expires_at < now() - $1::interval"#,
124 Self::EXPIRED_RETENTION as Duration
125 )
126 .execute(conn)
127 .await?
128 .rows_affected();
129 Ok(deleted)
130 }
131
132 pub async fn find_pending_by_user_code(
137 conn: &mut PgConnection,
138 user_code: &str,
139 ) -> ModelResult<OAuthDeviceCode> {
140 let row = sqlx::query_as!(
141 OAuthDeviceCode,
142 r#"
143 SELECT *
144 FROM oauth_device_codes
145 WHERE user_code = $1
146 AND status = 'pending'
147 AND expires_at > now()
148 "#,
149 user_code
150 )
151 .fetch_one(conn)
152 .await?;
153
154 Ok(row)
155 }
156
157 pub async fn approve(
161 conn: &mut PgConnection,
162 user_code: &str,
163 user_id: Uuid,
164 ) -> ModelResult<OAuthDeviceCode> {
165 let row = sqlx::query_as!(
166 OAuthDeviceCode,
167 r#"
168 UPDATE oauth_device_codes
169 SET user_id = $2,
170 status = 'approved'
171 WHERE user_code = $1
172 AND status = 'pending'
173 AND expires_at > now()
174 RETURNING *
175 "#,
176 user_code,
177 user_id
178 )
179 .fetch_one(conn)
180 .await?;
181
182 Ok(row)
183 }
184
185 pub async fn deny(conn: &mut PgConnection, user_code: &str) -> ModelResult<OAuthDeviceCode> {
189 let row = sqlx::query_as!(
190 OAuthDeviceCode,
191 r#"
192 UPDATE oauth_device_codes
193 SET status = 'denied'
194 WHERE user_code = $1
195 AND status = 'pending'
196 AND expires_at > now()
197 RETURNING *
198 "#,
199 user_code
200 )
201 .fetch_one(conn)
202 .await?;
203
204 Ok(row)
205 }
206
207 pub async fn record_poll(
215 conn: &mut PgConnection,
216 device_code_digest: &Digest,
217 ) -> ModelResult<DeviceCodePoll> {
218 let row = sqlx::query!(
219 r#"
220 WITH current AS (
221 SELECT device_code_digest, last_polled_at
222 FROM oauth_device_codes
223 WHERE device_code_digest = $1
224 FOR UPDATE
225 )
226 UPDATE oauth_device_codes d
227 SET last_polled_at = now()
228 FROM current c
229 WHERE d.device_code_digest = c.device_code_digest
230 RETURNING
231 c.last_polled_at AS "previous_polled_at",
232 d.status,
233 d.expires_at AS "expires_at!",
234 d.interval_seconds AS "interval_seconds!",
235 d.user_id,
236 d.client_id AS "client_id!",
237 d.scopes AS "scopes!"
238 "#,
239 device_code_digest.as_bytes()
240 )
241 .fetch_one(conn)
242 .await?;
243
244 Ok(DeviceCodePoll {
245 status: row.status,
246 expires_at: row.expires_at,
247 interval_seconds: row.interval_seconds,
248 previous_polled_at: row.previous_polled_at,
249 user_id: row.user_id,
250 client_id: row.client_id,
251 scopes: row.scopes,
252 })
253 }
254
255 pub async fn consume_approved_in_transaction(
262 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
263 device_code_digest: &Digest,
264 ) -> ModelResult<OAuthDeviceCode> {
265 let row = sqlx::query_as!(
266 OAuthDeviceCode,
267 r#"
268 DELETE FROM oauth_device_codes
269 WHERE device_code_digest = $1
270 AND status = 'approved'
271 AND expires_at > now()
272 RETURNING *
273 "#,
274 device_code_digest.as_bytes()
275 )
276 .fetch_one(&mut **tx)
277 .await?;
278
279 Ok(row)
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::{
287 library::oauth::{
288 GrantTypeName, generate_user_code,
289 pkce::PkceMethod,
290 tokens::{generate_access_token, token_digest_sha256},
291 },
292 oauth_client::{ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod},
293 test_helper::*,
294 };
295 use chrono::Duration;
296 use secrecy::SecretString;
297
298 fn hmac_key() -> SecretString {
299 SecretString::new("test-device-code-hmac-key".to_string().into())
300 }
301
302 async fn insert_public_client(conn: &mut PgConnection) -> OAuthClient {
303 let client_id = format!("cli-{}", &generate_access_token()[..12]);
304 OAuthClient::insert(
305 conn,
306 NewClientParams {
307 client_id: &client_id,
308 client_name: "Device flow test client",
309 application_type: ApplicationType::Native,
310 token_endpoint_auth_method: TokenEndpointAuthMethod::None,
311 client_secret: None,
312 client_secret_expires_at: None,
313 redirect_uris: &["urn:ietf:wg:oauth:2.0:oob".to_string()],
314 post_logout_redirect_uris: None,
315 allowed_grant_types: &[GrantTypeName::DeviceCode, GrantTypeName::RefreshToken],
316 scopes: &["exercise-services".to_string()],
317 require_pkce: true,
318 pkce_methods_allowed: &[PkceMethod::S256],
319 allowed_origins: None,
320 bearer_allowed: true,
321 },
322 )
323 .await
324 .unwrap()
325 }
326
327 fn new_params<'a>(
328 digest: &'a Digest,
329 user_code: &'a str,
330 client_id: Uuid,
331 scopes: &'a [String],
332 ) -> NewDeviceCodeParams<'a> {
333 NewDeviceCodeParams {
334 device_code_digest: digest,
335 user_code,
336 client_id,
337 scopes,
338 interval_seconds: 5,
339 expires_at: Utc::now() + Duration::minutes(15),
340 metadata: serde_json::Map::new(),
341 }
342 }
343
344 #[tokio::test]
345 async fn insert_find_approve_and_consume() {
346 insert_data!(:tx, :user);
347 let client = insert_public_client(tx.as_mut()).await;
348
349 let user_code = generate_user_code();
350 let device_code = generate_access_token();
351 let digest = token_digest_sha256(&device_code, &hmac_key());
352 let scopes = vec!["exercise-services".to_string()];
353
354 OAuthDeviceCode::insert(
355 tx.as_mut(),
356 new_params(&digest, &user_code, client.id, &scopes),
357 )
358 .await
359 .unwrap();
360
361 let pending = OAuthDeviceCode::find_pending_by_user_code(tx.as_mut(), &user_code)
362 .await
363 .unwrap();
364 assert_eq!(pending.status, DeviceCodeStatus::Pending);
365 assert_eq!(pending.user_id, None);
366 assert_eq!(pending.scopes, scopes);
367
368 let approved = OAuthDeviceCode::approve(tx.as_mut(), &user_code, user)
369 .await
370 .unwrap();
371 assert_eq!(approved.status, DeviceCodeStatus::Approved);
372 assert_eq!(approved.user_id, Some(user));
373
374 assert!(
376 OAuthDeviceCode::find_pending_by_user_code(tx.as_mut(), &user_code)
377 .await
378 .is_err()
379 );
380
381 let mut inner = tx.begin().await;
383 let consumed = OAuthDeviceCode::consume_approved_in_transaction(inner.as_mut(), &digest)
384 .await
385 .unwrap();
386 assert_eq!(consumed.user_id, Some(user));
387 assert_eq!(consumed.client_id, client.id);
388 assert!(
390 OAuthDeviceCode::consume_approved_in_transaction(inner.as_mut(), &digest)
391 .await
392 .is_err()
393 );
394 inner.rollback().await;
395 }
396
397 #[tokio::test]
398 async fn deny_marks_denied() {
399 insert_data!(:tx, :user);
400 let _ = user;
401 let client = insert_public_client(tx.as_mut()).await;
402
403 let user_code = generate_user_code();
404 let digest = token_digest_sha256(&generate_access_token(), &hmac_key());
405 let scopes = vec!["exercise-services".to_string()];
406
407 OAuthDeviceCode::insert(
408 tx.as_mut(),
409 new_params(&digest, &user_code, client.id, &scopes),
410 )
411 .await
412 .unwrap();
413
414 let denied = OAuthDeviceCode::deny(tx.as_mut(), &user_code)
415 .await
416 .unwrap();
417 assert_eq!(denied.status, DeviceCodeStatus::Denied);
418
419 let mut inner = tx.begin().await;
421 assert!(
422 OAuthDeviceCode::consume_approved_in_transaction(inner.as_mut(), &digest)
423 .await
424 .is_err()
425 );
426 inner.rollback().await;
427 }
428
429 #[tokio::test]
430 async fn record_poll_reports_previous_poll_time() {
431 insert_data!(:tx, :user);
432 let _ = user;
433 let client = insert_public_client(tx.as_mut()).await;
434
435 let user_code = generate_user_code();
436 let digest = token_digest_sha256(&generate_access_token(), &hmac_key());
437 let scopes = vec!["exercise-services".to_string()];
438
439 OAuthDeviceCode::insert(
440 tx.as_mut(),
441 new_params(&digest, &user_code, client.id, &scopes),
442 )
443 .await
444 .unwrap();
445
446 let first = OAuthDeviceCode::record_poll(tx.as_mut(), &digest)
448 .await
449 .unwrap();
450 assert_eq!(first.status, DeviceCodeStatus::Pending);
451 assert!(first.previous_polled_at.is_none());
452 assert_eq!(first.interval_seconds, 5);
453
454 let second = OAuthDeviceCode::record_poll(tx.as_mut(), &digest)
456 .await
457 .unwrap();
458 assert!(second.previous_polled_at.is_some());
459 }
460}