1mod http;
2mod issuance;
3mod redemption;
4pub(super) mod types;
5
6use crate::plugins::helpers::{SessionIssueError, create_user_session_record};
7use alibi_core::entity::{AuthSession, AuthUser};
8use alibi_core::{
9 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, CreateDeviceCode, RequestMeta,
10 UpdateDeviceCode,
11};
12use chrono::{Duration, Utc};
13use rand::distr::{Alphanumeric, SampleString};
14use std::fmt;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18use types::{
19 DeviceActionRequest, DeviceActionResponse, DeviceCodeRequest, DeviceCodeResponse,
20 DeviceErrorResponse, DeviceTokenRequest, DeviceTokenResponse, DeviceVerifyResponse,
21};
22use url::Url;
23
24const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
25
26const DEVICE_STATUS_PENDING: &str = "pending";
27
28const DEVICE_STATUS_APPROVED: &str = "approved";
29
30const DEVICE_STATUS_DENIED: &str = "denied";
31
32const DEFAULT_USER_CODE_CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
33
34const INVALID_DEVICE_CODE: &str = "Invalid device code";
35
36const EXPIRED_DEVICE_CODE: &str = "Device code has expired";
37
38const EXPIRED_USER_CODE: &str = "User code has expired";
39
40const AUTHORIZATION_PENDING: &str = "Authorization pending";
41
42const ACCESS_DENIED: &str = "Access denied";
43
44const INVALID_USER_CODE: &str = "Invalid user code";
45
46const DEVICE_CODE_ALREADY_PROCESSED: &str = "Device code already processed";
47
48const DEVICE_CODE_NOT_CLAIMED: &str = "Device code has not been claimed by a verifying session; call `GET /device` with the `user_code` while signed in before approving or denying";
49
50const POLLING_TOO_FREQUENTLY: &str = "Polling too frequently";
51
52const USER_NOT_FOUND: &str = "User not found";
53
54const FAILED_TO_CREATE_SESSION: &str = "Failed to create session";
55
56const INVALID_DEVICE_CODE_STATUS: &str = "Invalid device code status";
57
58const AUTHENTICATION_REQUIRED: &str = "Authentication required";
59
60const INVALID_CLIENT_ID: &str = "Invalid client ID";
61
62const CLIENT_ID_MISMATCH: &str = "Client ID mismatch";
63
64const INVALID_REQUEST: &str = "Invalid request";
65
66type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
67
68type ValidateClientCallback = dyn Fn(String) -> BoxFuture<AuthResult<bool>> + Send + Sync;
69
70type DeviceAuthRequestCallback =
71 dyn Fn(String, Option<String>) -> BoxFuture<AuthResult<()>> + Send + Sync;
72
73type CodeGenerator = dyn Fn() -> BoxFuture<AuthResult<String>> + Send + Sync;
74
75#[derive(Clone)]
76struct DeviceAuthorizationConfig {
77 expires_in: Duration,
78 interval: Duration,
79 device_code_length: usize,
80 user_code_length: usize,
81 generate_device_code: Option<Arc<CodeGenerator>>,
82 generate_user_code: Option<Arc<CodeGenerator>>,
83 validate_client: Option<Arc<ValidateClientCallback>>,
84 on_device_auth_request: Option<Arc<DeviceAuthRequestCallback>>,
85 verification_uri: Option<String>,
86}
87
88impl Default for DeviceAuthorizationConfig {
89 fn default() -> Self {
90 Self {
91 expires_in: Duration::minutes(30),
92 interval: Duration::seconds(5),
93 device_code_length: 40,
94 user_code_length: 8,
95 generate_device_code: None,
96 generate_user_code: None,
97 validate_client: None,
98 on_device_auth_request: None,
99 verification_uri: None,
100 }
101 }
102}
103
104impl fmt::Debug for DeviceAuthorizationConfig {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 f.debug_struct("DeviceAuthorizationConfig")
107 .field("expires_in", &self.expires_in)
108 .field("interval", &self.interval)
109 .field("device_code_length", &self.device_code_length)
110 .field("user_code_length", &self.user_code_length)
111 .field(
112 "generate_device_code",
113 &self.generate_device_code.as_ref().map(|_| "custom"),
114 )
115 .field(
116 "generate_user_code",
117 &self.generate_user_code.as_ref().map(|_| "custom"),
118 )
119 .field(
120 "validate_client",
121 &self.validate_client.as_ref().map(|_| "custom"),
122 )
123 .field(
124 "on_device_auth_request",
125 &self.on_device_auth_request.as_ref().map(|_| "custom"),
126 )
127 .field("verification_uri", &self.verification_uri)
128 .finish()
129 }
130}
131
132#[derive(Clone, Copy)]
133enum DeviceDecision {
134 Approve,
135 Deny,
136}
137
138impl DeviceDecision {
139 const fn status(self) -> &'static str {
140 match self {
141 Self::Approve => DEVICE_STATUS_APPROVED,
142 Self::Deny => DEVICE_STATUS_DENIED,
143 }
144 }
145
146 const fn forbidden_message(self) -> &'static str {
147 match self {
148 Self::Approve => "You are not authorized to approve this device authorization",
149 Self::Deny => "You are not authorized to deny this device authorization",
150 }
151 }
152}
153
154pub struct DeviceAuthorizationPlugin {
156 config: DeviceAuthorizationConfig,
157}
158
159impl fmt::Debug for DeviceAuthorizationPlugin {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 f.debug_struct("DeviceAuthorizationPlugin")
162 .finish_non_exhaustive()
163 }
164}
165
166impl Default for DeviceAuthorizationPlugin {
167 fn default() -> Self {
168 Self::new()
169 }
170}
171
172impl DeviceAuthorizationPlugin {
173 #[must_use]
175 pub fn new() -> Self {
176 Self {
177 config: DeviceAuthorizationConfig::default(),
178 }
179 }
180
181 #[must_use]
183 pub const fn expires_in(mut self, duration: Duration) -> Self {
184 self.config.expires_in = duration;
185 self
186 }
187
188 #[must_use]
190 pub const fn interval(mut self, duration: Duration) -> Self {
191 self.config.interval = duration;
192 self
193 }
194
195 #[must_use]
197 pub const fn device_code_length(mut self, length: usize) -> Self {
198 self.config.device_code_length = length;
199 self
200 }
201
202 #[must_use]
204 pub const fn user_code_length(mut self, length: usize) -> Self {
205 self.config.user_code_length = length;
206 self
207 }
208
209 #[must_use]
211 pub fn verification_uri(mut self, uri: impl Into<String>) -> Self {
212 self.config.verification_uri = Some(uri.into());
213 self
214 }
215
216 #[must_use]
218 pub fn generate_device_code_with<F>(mut self, generator: F) -> Self
219 where
220 F: Fn() -> String + Send + Sync + 'static,
221 {
222 self.config.generate_device_code = Some(Arc::new(move || {
223 let code = generator();
224 Box::pin(async move { Ok(code) })
225 }));
226 self
227 }
228
229 #[must_use]
231 pub fn generate_user_code_with<F>(mut self, generator: F) -> Self
232 where
233 F: Fn() -> String + Send + Sync + 'static,
234 {
235 self.config.generate_user_code = Some(Arc::new(move || {
236 let code = generator();
237 Box::pin(async move { Ok(code) })
238 }));
239 self
240 }
241
242 #[must_use]
244 pub fn generate_device_code_async_with<F, Fut>(mut self, generator: F) -> Self
245 where
246 F: Fn() -> Fut + Send + Sync + 'static,
247 Fut: Future<Output = AuthResult<String>> + Send + 'static,
248 {
249 self.config.generate_device_code = Some(Arc::new(move || Box::pin(generator())));
250 self
251 }
252
253 #[must_use]
255 pub fn generate_user_code_async_with<F, Fut>(mut self, generator: F) -> Self
256 where
257 F: Fn() -> Fut + Send + Sync + 'static,
258 Fut: Future<Output = AuthResult<String>> + Send + 'static,
259 {
260 self.config.generate_user_code = Some(Arc::new(move || Box::pin(generator())));
261 self
262 }
263
264 #[must_use]
266 pub fn validate_client<F, Fut>(mut self, callback: F) -> Self
267 where
268 F: Fn(String) -> Fut + Send + Sync + 'static,
269 Fut: Future<Output = AuthResult<bool>> + Send + 'static,
270 {
271 self.config.validate_client =
272 Some(Arc::new(move |client_id| Box::pin(callback(client_id))));
273 self
274 }
275
276 #[must_use]
278 pub fn on_device_auth_request<F, Fut>(mut self, callback: F) -> Self
279 where
280 F: Fn(String, Option<String>) -> Fut + Send + Sync + 'static,
281 Fut: Future<Output = AuthResult<()>> + Send + 'static,
282 {
283 self.config.on_device_auth_request = Some(Arc::new(move |client_id, scope| {
284 Box::pin(callback(client_id, scope))
285 }));
286 self
287 }
288
289 async fn validate_client_id(&self, client_id: &str) -> AuthResult<bool> {
290 match &self.config.validate_client {
291 Some(callback) => callback(client_id.to_owned())
292 .await
293 .map_err(device_callback_error),
294 None => Ok(true),
295 }
296 }
297
298 async fn generate_device_code(&self) -> AuthResult<String> {
299 match &self.config.generate_device_code {
300 Some(generator) => generator().await.map_err(device_callback_error),
301 None => {
302 Ok(Alphanumeric.sample_string(&mut rand::rng(), self.config.device_code_length))
303 }
304 }
305 }
306
307 async fn generate_user_code(&self) -> AuthResult<String> {
308 match &self.config.generate_user_code {
309 Some(generator) => generator().await.map_err(device_callback_error),
310 None => Ok(default_generate_user_code(self.config.user_code_length)),
311 }
312 }
313}
314
315#[derive(Clone, Copy)]
316enum DeviceRequestKind {
317 Issuance,
318 Token,
319 Decision,
320}
321
322alibi_core::impl_auth_plugin! {
323 DeviceAuthorizationPlugin, "device-authorization";
324 routes {
325 post "/device/code" => handle_device_code, "device_code";
326 post "/device/token" => handle_device_token, "device_token";
327 get "/device" => handle_device_verify, "device_verify";
328 post "/device/approve" => handle_device_approve, "device_approve";
329 post "/device/deny" => handle_device_deny, "device_deny";
330 }
331 extra {
332 fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
333 crate::metadata::plugin_metadata(<Self as alibi_core::AuthPlugin<S>>::name(self), &<Self as alibi_core::AuthPlugin<S>>::routes(self))
334 }
335
336 fn openapi_metadata(&self, ctx: &alibi_core::AuthInitContext<S>) -> alibi_core::PluginOpenApiMetadata {
337 crate::metadata::instance_plugin_metadata(<Self as alibi_core::AuthPlugin<S>>::name(self), &<Self as alibi_core::AuthPlugin<S>>::routes(self), ctx)
338 }
339
340 fn rate_limits(&self) -> Vec<alibi_core::PluginRateLimit> {
341 vec![alibi_core::PluginRateLimit { matches: |path| path == "/device", limit: alibi_core::EndpointRateLimit { window_seconds: self.config.expires_in.to_std().map_or(0.0, |duration| duration.as_secs_f64()), max_requests: 5.0 } }]
342 }
343 fn allowed_media_types(&self, route: &alibi_core::AuthRoute) -> Vec<&'static str> {
344 if route.path == "/device/code" {
345 vec!["application/json", "application/x-www-form-urlencoded"]
346 } else {
347 vec!["application/json"]
348 }
349 }
350 }
351}
352
353fn device_callback_error(error: AuthError) -> AuthError {
354 match error {
355 AuthError::Api { .. } | AuthError::Upstream { .. } | AuthError::CallbackFailure(_) => error,
356 error => AuthError::CallbackFailure(Box::new(error)),
357 }
358}
359
360fn set_device_no_store_headers(req: &AuthRequest) {
363 req.queue_response_header("Cache-Control", "no-store");
364 req.queue_response_header("Pragma", "no-cache");
365 if let Some(call) = alibi_core::endpoint::current_endpoint_call_context() {
366 call.set_response_header("Cache-Control", "no-store");
367 call.set_response_header("Pragma", "no-cache");
368 }
369}
370
371fn duration_seconds_floor(duration: Duration) -> i64 {
372 let seconds = duration.num_seconds();
373 seconds - i64::from(duration < Duration::seconds(seconds))
375}
376
377fn is_unique_constraint_error(error: &AuthError) -> bool {
378 let AuthError::Database(alibi_core::DatabaseError::Constraint(message)) = error else {
379 return false;
380 };
381 let message = message.to_ascii_lowercase();
382 message.contains("unique constraint")
383 || message.contains("unique key constraint")
384 || message.contains("duplicate entry")
385 || message.contains("duplicate key")
386 || message.contains("e11000")
387}
388
389fn deserialize_device_body<T: serde::de::DeserializeOwned>(
390 value: serde_json::Value,
391) -> Result<T, AuthResponse> {
392 serde_json::from_value(value).map_err(|_error| AuthResponse::text(400, "Invalid request body"))
393}
394
395fn validate_device_media(req: &AuthRequest, issuance: bool) -> Result<bool, AuthResponse> {
396 let raw_content_type = req
397 .headers
398 .iter()
399 .find(|(key, _)| key.eq_ignore_ascii_case("content-type"))
400 .map(|(_, value)| value.as_str());
401 let content_type = raw_content_type.map(|value| {
402 value
403 .split(';')
404 .next()
405 .unwrap_or_default()
406 .trim()
407 .to_ascii_lowercase()
408 });
409 let is_form = content_type.as_deref() == Some("application/x-www-form-urlencoded");
410 let allowed = if issuance {
411 "application/json, application/x-www-form-urlencoded"
412 } else {
413 "application/json"
414 };
415 let missing = req.body.is_some() && raw_content_type.is_none_or(str::is_empty);
416 if missing
417 || content_type
418 .as_deref()
419 .is_some_and(|value| value != "application/json" && !(issuance && is_form))
420 {
421 let message = if missing {
422 format!("Content-Type is required. Allowed types: {allowed}")
423 } else {
424 format!(
425 "Content-Type \"{}\" is not allowed. Allowed types: {allowed}",
426 raw_content_type.unwrap_or_default()
427 )
428 };
429 return Err(AuthResponse::json(
430 415,
431 &serde_json::json!({"message":message,"code":"UNSUPPORTED_MEDIA_TYPE"}),
432 )
433 .unwrap_or_else(|_| AuthResponse::text(415, "Unsupported media type")));
434 }
435 Ok(is_form)
436}
437
438fn parse_device_body(
439 req: &AuthRequest,
440 kind: DeviceRequestKind,
441) -> Result<serde_json::Value, AuthResponse> {
442 let issuance = matches!(kind, DeviceRequestKind::Issuance);
443 let is_form = validate_device_media(req, issuance)?;
444 let pairs = is_form.then(|| {
445 url::form_urlencoded::parse(req.body.as_deref().unwrap_or_default())
446 .into_owned()
447 .collect::<Vec<_>>()
448 });
449 let mut body = if let Some(pairs) = &pairs {
450 let mut object = serde_json::Map::new();
451 for (key, value) in pairs {
452 drop(object.insert(key.clone(), serde_json::Value::String(value.clone())));
453 }
454 serde_json::Value::Object(object)
455 } else {
456 req.body_as_json::<serde_json::Value>().map_err(|_error| {
457 AuthResponse::json(
458 400,
459 &serde_json::json!({"code":"BAD_REQUEST","message":"Invalid JSON in request body"}),
460 )
461 .unwrap_or_else(|_| AuthResponse::text(400, "Invalid JSON"))
462 })?
463 };
464 let fields = match kind {
465 DeviceRequestKind::Issuance => &["client_id", "user_id", "scope"][..],
466 DeviceRequestKind::Token => &["grant_type", "device_code", "client_id"][..],
467 DeviceRequestKind::Decision => &["userCode"][..],
468 };
469 let mut issues = Vec::new();
470 if body.is_object() {
471 for field in fields {
472 let value = body.get(*field);
473 if !issuance && *field == "grant_type" {
474 if value.and_then(serde_json::Value::as_str) != Some(DEVICE_GRANT_TYPE) {
475 issues.push(format!(
476 "[body.grant_type] Invalid input: expected \"{DEVICE_GRANT_TYPE}\""
477 ));
478 }
479 } else if !(value.is_some_and(serde_json::Value::is_string)
480 || value.is_none() && issuance && matches!(*field, "user_id" | "scope"))
481 {
482 issues.push(format!(
483 "[body.{field}] Invalid input: expected string, received {}",
484 json_type(value)
485 ));
486 }
487 }
488 } else {
489 issues.push(format!(
490 "[body] Invalid input: expected object, received {}",
491 json_type(Some(&body))
492 ));
493 }
494 if !issues.is_empty() {
495 let message = issues.join("; ");
496 return Err(if issuance {
497 device_error_response(400, "invalid_request", &message)
498 .unwrap_or_else(|_| AuthResponse::text(400, message))
499 } else {
500 AuthResponse::json(
501 400,
502 &serde_json::json!({ "message": message, "code": "VALIDATION_ERROR" }),
503 )
504 .unwrap_or_else(|_| AuthResponse::text(400, "Validation failed"))
505 });
506 }
507 if let Some(pairs) = &pairs {
508 for field in fields {
509 let values = pairs
510 .iter()
511 .filter(|(key, value)| key == field && !value.is_empty())
512 .map(|(_, value)| value)
513 .collect::<Vec<_>>();
514 if values.len() > 1 {
515 return Err(device_error_response(
516 400,
517 "invalid_request",
518 &format!("{field} must not be repeated"),
519 )
520 .unwrap_or_else(|_| AuthResponse::text(400, "Repeated request parameter"))
521 .with_header("Cache-Control", "no-store")
522 .with_header("Pragma", "no-cache"));
523 }
524 if let Some(value) = values.first()
525 && let Some(object) = body.as_object_mut()
526 {
527 drop(object.insert(
528 (*field).to_owned(),
529 serde_json::Value::String((*value).clone()),
530 ));
531 }
532 }
533 }
534 Ok(body)
535}
536
537const fn json_type(value: Option<&serde_json::Value>) -> &'static str {
538 match value {
539 None => "undefined",
540 Some(serde_json::Value::Null) => "null",
541 Some(serde_json::Value::Bool(_)) => "boolean",
542 Some(serde_json::Value::Number(_)) => "number",
543 Some(serde_json::Value::String(_)) => "string",
544 Some(serde_json::Value::Array(_)) => "array",
545 Some(serde_json::Value::Object(_)) => "object",
546 }
547}
548
549fn build_verification_uris(
550 verification_uri: Option<&str>,
551 base_url: &str,
552 user_code: &str,
553) -> AuthResult<(String, String)> {
554 let uri = verification_uri
555 .filter(|uri| !uri.is_empty())
556 .unwrap_or("/device");
557 let parsed_verification_url = match Url::parse(uri) {
558 Ok(url) => url,
559 Err(_) => Url::parse(base_url)
560 .map_err(|error| AuthError::config(format!("Invalid base URL: {error}")))?
561 .join(uri)
562 .map_err(|error| {
563 AuthError::bad_request(format!("Invalid verification URI: {error}"))
564 })?,
565 };
566
567 let mut verification_uri_complete = parsed_verification_url.clone();
568 let mut replaced = false;
571 let mut pairs = Vec::new();
572 for (key, value) in verification_uri_complete.query_pairs() {
573 if key == "user_code" {
574 if !replaced {
575 pairs.push((key.into_owned(), user_code.to_owned()));
576 replaced = true;
577 }
578 } else {
579 pairs.push((key.into_owned(), value.into_owned()));
580 }
581 }
582 if !replaced {
583 pairs.push(("user_code".to_owned(), user_code.to_owned()));
584 }
585 let _ignored_extend_pairs = verification_uri_complete
586 .query_pairs_mut()
587 .clear()
588 .extend_pairs(pairs);
589
590 Ok((
591 parsed_verification_url.to_string(),
592 verification_uri_complete.to_string(),
593 ))
594}
595
596async fn find_device_code_by_user_code(
597 ctx: &AuthContext<impl alibi_core::AuthSchema>,
598 user_code: &str,
599) -> AuthResult<Option<alibi_core::DeviceCode>> {
600 if let Some(record) = ctx.database.get_device_code_by_user_code(user_code).await?
603 && record.user_code == user_code
604 {
605 return Ok(Some(record));
606 }
607 let normalized = user_code
608 .chars()
609 .filter(char::is_ascii_alphanumeric)
610 .map(|character| character.to_ascii_uppercase())
611 .collect::<String>();
612 if normalized == user_code
613 || normalized.is_empty()
614 || !normalized
615 .bytes()
616 .all(|byte| DEFAULT_USER_CODE_CHARSET.contains(&byte))
617 {
618 return Ok(None);
619 }
620 Ok(ctx
621 .database
622 .get_device_code_by_user_code(&normalized)
623 .await?
624 .filter(|record| record.user_code == normalized))
625}
626
627fn default_generate_user_code(length: usize) -> String {
628 let mut bytes = vec![0u8; length];
629 rand::fill(&mut bytes);
630 bytes
631 .into_iter()
632 .map(|byte| {
633 let index = usize::from(byte) % DEFAULT_USER_CODE_CHARSET.len();
634 char::from(
635 DEFAULT_USER_CODE_CHARSET
636 .get(index)
637 .copied()
638 .unwrap_or(b'A'),
639 )
640 })
641 .collect()
642}
643
644fn device_error_response(
645 status: u16,
646 error: &str,
647 error_description: &str,
648) -> AuthResult<AuthResponse> {
649 AuthResponse::json(
650 status,
651 &DeviceErrorResponse {
652 error: error.to_owned(),
653 error_description: error_description.to_owned(),
654 },
655 )
656 .map_err(AuthError::from)
657}
658
659#[cfg(test)]
661mod tests {
662 use super::*;
663 use crate::plugins::test_helpers;
664 use alibi_core::{AuthResponse, CreateDeviceCode, CreateUser, HttpMethod};
665 use chrono::{Duration, Utc};
666 use serde_json::Value;
667 use std::collections::HashMap;
668 use std::sync::{
669 Arc,
670 atomic::{AtomicUsize, Ordering},
671 };
672
673 type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
674
675 #[tokio::test]
676 async fn issuance_retries_collisions_three_times_and_runs_request_hook_once() {
677 let ctx = test_helpers::create_test_context().await;
678 let attempts = Arc::new(AtomicUsize::new(0));
679 let hooks = Arc::new(AtomicUsize::new(0));
680 let plugin = DeviceAuthorizationPlugin::new()
681 .generate_device_code_with({
682 let attempts = std::sync::Arc::clone(&attempts);
683 move || {
684 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
685 if attempt < 2 {
686 "duplicate-device".to_owned()
687 } else {
688 "unique-device".to_owned()
689 }
690 }
691 })
692 .generate_user_code_async_with(|| async { Ok("custom-code".to_owned()) })
693 .on_device_auth_request({
694 let hooks = std::sync::Arc::clone(&hooks);
695 move |_, scope| {
696 let hooks = std::sync::Arc::clone(&hooks);
697 async move {
698 assert_eq!(scope, None);
699 hooks.fetch_add(1, Ordering::SeqCst);
700 Ok(())
701 }
702 }
703 });
704 ctx.database
705 .create_device_code(CreateDeviceCode {
706 device_code: "duplicate-device".to_owned(),
707 user_code: "existing-code".to_owned(),
708 user_id: None,
709 expires_at: Utc::now() + Duration::hours(1),
710 status: "pending".to_owned(),
711 last_polled_at: None,
712 polling_interval: Some(5000),
713 client_id: Some("client".to_owned()),
714 scope: None,
715 })
716 .await
717 .unwrap();
718 let request = test_helpers::create_auth_json_request_no_query(
719 HttpMethod::Post,
720 "/device/code",
721 None,
722 Some(serde_json::json!({ "client_id": "client", "scope": "", "user_id": "" })),
723 );
724 let response = plugin.handle_device_code(&request, &ctx).await.unwrap();
725 assert_eq!(response.status, 200);
726 assert_eq!(
727 (*(json_body(&response))
728 .get("device_code")
729 .unwrap_or(&Value::Null)),
730 "unique-device"
731 );
732 assert_eq!(attempts.load(Ordering::SeqCst), 3);
733 assert_eq!(hooks.load(Ordering::SeqCst), 1);
734 let stored = ctx
735 .database
736 .get_device_code_by_device_code("unique-device")
737 .await
738 .unwrap()
739 .unwrap();
740 assert!(stored.scope.is_none());
741 assert!(stored.user_id.is_none());
742
743 let exhausted_attempts = Arc::new(AtomicUsize::new(0));
744 let exhausted = DeviceAuthorizationPlugin::new().generate_device_code_with({
745 let attempts_2 = std::sync::Arc::clone(&exhausted_attempts);
746 move || {
747 attempts_2.fetch_add(1, Ordering::SeqCst);
748 "duplicate-device".to_owned()
749 }
750 });
751 let failure = exhausted.handle_device_code(&request, &ctx).await.unwrap();
752 assert_eq!(failure.status, 500);
753 assert_eq!(
754 json_body(&failure),
755 serde_json::json!({ "error": "server_error", "error_description": "Failed to generate a unique device code" })
756 );
757 assert_eq!(exhausted_attempts.load(Ordering::SeqCst), 3);
758 assert_eq!(
759 ctx.database
760 .get_device_code_by_device_code("duplicate-device")
761 .await
762 .unwrap()
763 .unwrap()
764 .user_code,
765 "existing-code"
766 );
767 }
768
769 #[tokio::test]
770 async fn generators_enforce_unicode_character_limit_before_persistence() {
771 let ctx = test_helpers::create_test_context().await;
772 let request = test_helpers::create_auth_json_request_no_query(
773 HttpMethod::Post,
774 "/device/code",
775 None,
776 Some(serde_json::json!({ "client_id": "client" })),
777 );
778 let too_long = DeviceAuthorizationPlugin::new()
779 .generate_device_code_async_with(|| async { Ok("🦀".repeat(192)) });
780 let failure = too_long.handle_device_code(&request, &ctx).await.unwrap();
781 assert_eq!(failure.status, 400);
782 assert_eq!(
783 (*(json_body(&failure))
784 .get("error_description")
785 .unwrap_or(&Value::Null)),
786 "Generated device code must be at most 191 characters"
787 );
788 assert!(
789 ctx.database
790 .get_device_code_by_device_code(&"🦀".repeat(192))
791 .await
792 .unwrap()
793 .is_none()
794 );
795 let too_long_user = DeviceAuthorizationPlugin::new()
796 .generate_device_code_with(|| "short-device".to_owned())
797 .generate_user_code_with(|| "🦀".repeat(192));
798 let failure_2 = too_long_user
799 .handle_device_code(&request, &ctx)
800 .await
801 .unwrap();
802 assert_eq!(failure_2.status, 400);
803 assert_eq!(
804 (*(json_body(&failure_2))
805 .get("error_description")
806 .unwrap_or(&Value::Null)),
807 "Generated user code must be at most 191 characters"
808 );
809 assert!(
810 ctx.database
811 .get_device_code_by_device_code("short-device")
812 .await
813 .unwrap()
814 .is_none()
815 );
816 let valid = DeviceAuthorizationPlugin::new()
817 .generate_device_code_with(|| "🦀".repeat(191))
818 .generate_user_code_with(|| "🦀".repeat(191));
819 assert_eq!(
820 valid
821 .handle_device_code(&request, &ctx)
822 .await
823 .unwrap()
824 .status,
825 200
826 );
827 assert!(
828 ctx.database
829 .get_device_code_by_device_code(&"🦀".repeat(191))
830 .await
831 .unwrap()
832 .is_some()
833 );
834 }
835
836 fn json_body(response: &AuthResponse) -> Value {
837 serde_json::from_slice(&response.body).unwrap()
838 }
839
840 fn device_token_request(device_code: &str, client_id: &str) -> AuthRequest {
841 test_helpers::create_auth_json_request_no_query(
842 HttpMethod::Post,
843 "/device/token",
844 None,
845 Some(serde_json::json!({
846 "grant_type": DEVICE_GRANT_TYPE,
847 "device_code": device_code,
848 "client_id": client_id,
849 })),
850 )
851 }
852
853 fn device_verify_request(user_code: &str) -> AuthRequest {
854 let mut query = HashMap::new();
855 drop(query.insert("user_code".to_owned(), user_code.to_owned()));
856 test_helpers::create_auth_request(HttpMethod::Get, "/device", None, None, query)
857 }
858
859 fn device_claim_request(user_code: &str, token: &str) -> AuthRequest {
862 let mut query = HashMap::new();
863 drop(query.insert("user_code".to_owned(), user_code.to_owned()));
864 test_helpers::create_auth_request(HttpMethod::Get, "/device", Some(token), None, query)
865 }
866
867 async fn create_context_with_user(
868 email: &str,
869 ) -> (
870 AuthContext<TestSchema>,
871 alibi_core::wire::UserView,
872 alibi_core::wire::SessionView,
873 ) {
874 test_helpers::create_test_context_with_user(
875 CreateUser::new()
876 .with_email(email.to_owned())
877 .with_name("Device Auth User"),
878 Duration::hours(1),
879 )
880 .await
881 }
882
883 #[tokio::test]
885 async fn test_device_code_response_shape_and_storage() {
886 let plugin = DeviceAuthorizationPlugin::new()
887 .expires_in(Duration::minutes(5))
888 .interval(Duration::seconds(2))
889 .verification_uri("/auth/device?lang=en");
890 let ctx = test_helpers::create_test_context().await;
891
892 let request = test_helpers::create_auth_json_request_no_query(
893 HttpMethod::Post,
894 "/device/code",
895 None,
896 Some(serde_json::json!({
897 "client_id": "test-client",
898 "scope": "openid profile",
899 })),
900 );
901
902 let response = plugin.handle_device_code(&request, &ctx).await.unwrap();
903 let body = json_body(&response);
904
905 assert_eq!(response.status, 200);
906 assert_eq!(
907 response.headers.get("Cache-Control"),
908 Some(&"no-store".to_owned())
909 );
910 assert_eq!((*(body).get("expires_in").unwrap_or(&Value::Null)), 300);
911 assert_eq!((*(body).get("interval").unwrap_or(&Value::Null)), 2);
912 assert!(
913 (*(body).get("device_code").unwrap_or(&Value::Null))
914 .as_str()
915 .unwrap()
916 .len()
917 >= 40
918 );
919 assert!(
920 (*(body).get("user_code").unwrap_or(&Value::Null))
921 .as_str()
922 .unwrap()
923 .len()
924 >= 8
925 );
926 assert!(
927 (*(body).get("user_code").unwrap_or(&Value::Null))
928 .as_str()
929 .unwrap()
930 .chars()
931 .all(|char| {
932 u8::try_from(char).is_ok_and(|byte| DEFAULT_USER_CODE_CHARSET.contains(&byte))
933 })
934 );
935 assert!(
936 (*(body).get("verification_uri").unwrap_or(&Value::Null))
937 .as_str()
938 .unwrap()
939 .contains("/auth/device?lang=en")
940 );
941 assert!(
942 (*(body)
943 .get("verification_uri_complete")
944 .unwrap_or(&Value::Null))
945 .as_str()
946 .unwrap()
947 .contains("lang=en")
948 );
949 assert!(
950 (*(body)
951 .get("verification_uri_complete")
952 .unwrap_or(&Value::Null))
953 .as_str()
954 .unwrap()
955 .contains("user_code=")
956 );
957
958 let stored = ctx
959 .database
960 .get_device_code_by_device_code(
961 (*(body).get("device_code").unwrap_or(&Value::Null))
962 .as_str()
963 .unwrap(),
964 )
965 .await
966 .unwrap()
967 .unwrap();
968 assert_eq!(stored.polling_interval, Some(2000));
969 assert_eq!(stored.client_id.as_deref(), Some("test-client"));
970 assert_eq!(stored.scope.as_deref(), Some("openid profile"));
971 }
972
973 #[tokio::test]
975 async fn test_device_code_rejects_invalid_client() {
976 let plugin = DeviceAuthorizationPlugin::new()
977 .validate_client(|client_id| async move { Ok(client_id == "valid-client") });
978 let ctx = test_helpers::create_test_context().await;
979
980 let request = test_helpers::create_auth_json_request_no_query(
981 HttpMethod::Post,
982 "/device/code",
983 None,
984 Some(serde_json::json!({
985 "client_id": "invalid-client",
986 })),
987 );
988
989 let response = plugin.handle_device_code(&request, &ctx).await.unwrap();
990 let body = json_body(&response);
991
992 assert_eq!(response.status, 400);
993 assert_eq!(
994 (*(body).get("error").unwrap_or(&Value::Null)),
995 "invalid_client"
996 );
997 assert_eq!(
998 (*(body).get("error_description").unwrap_or(&Value::Null)),
999 INVALID_CLIENT_ID
1000 );
1001 }
1002
1003 #[tokio::test]
1005 async fn test_device_code_uses_custom_generators_and_hook() {
1006 let hook_calls = Arc::new(AtomicUsize::new(0));
1007 let hook_calls_clone = std::sync::Arc::clone(&hook_calls);
1008 let plugin = DeviceAuthorizationPlugin::new()
1009 .generate_device_code_with(|| "custom-device-code".to_owned())
1010 .generate_user_code_with(|| "CUSTOM12".to_owned())
1011 .on_device_auth_request(move |client_id, scope| {
1012 let hook_calls_2 = std::sync::Arc::clone(&hook_calls_clone);
1013 async move {
1014 assert_eq!(client_id, "hook-client");
1015 assert_eq!(scope.as_deref(), Some("openid"));
1016 hook_calls_2.fetch_add(1, Ordering::SeqCst);
1017 Ok(())
1018 }
1019 })
1020 .verification_uri("https://example.com/device");
1021 let ctx = test_helpers::create_test_context().await;
1022
1023 let request = test_helpers::create_auth_json_request_no_query(
1024 HttpMethod::Post,
1025 "/device/code",
1026 None,
1027 Some(serde_json::json!({
1028 "client_id": "hook-client",
1029 "scope": "openid",
1030 })),
1031 );
1032
1033 let response = plugin.handle_device_code(&request, &ctx).await.unwrap();
1034 let body = json_body(&response);
1035
1036 assert_eq!(
1037 (*(body).get("device_code").unwrap_or(&Value::Null)),
1038 "custom-device-code"
1039 );
1040 assert_eq!(
1041 (*(body).get("user_code").unwrap_or(&Value::Null)),
1042 "CUSTOM12"
1043 );
1044 assert_eq!(
1045 (*(body).get("verification_uri").unwrap_or(&Value::Null)),
1046 "https://example.com/device"
1047 );
1048 assert_eq!(
1049 (*(body)
1050 .get("verification_uri_complete")
1051 .unwrap_or(&Value::Null)),
1052 "https://example.com/device?user_code=CUSTOM12"
1053 );
1054 assert_eq!(hook_calls.load(Ordering::SeqCst), 1);
1055 }
1056
1057 #[tokio::test]
1059 async fn test_device_token_pending_returns_authorization_pending() {
1060 let plugin = DeviceAuthorizationPlugin::new();
1061 let ctx = test_helpers::create_test_context().await;
1062
1063 let create_request = test_helpers::create_auth_json_request_no_query(
1064 HttpMethod::Post,
1065 "/device/code",
1066 None,
1067 Some(serde_json::json!({ "client_id": "test-client" })),
1068 );
1069 let create_response = plugin
1070 .handle_device_code(&create_request, &ctx)
1071 .await
1072 .unwrap();
1073 let create_body = json_body(&create_response);
1074
1075 let token_request = device_token_request(
1076 (*(create_body).get("device_code").unwrap_or(&Value::Null))
1077 .as_str()
1078 .unwrap(),
1079 "test-client",
1080 );
1081 let token_response = plugin
1082 .handle_device_token(&token_request, &ctx)
1083 .await
1084 .unwrap();
1085 let token_body = json_body(&token_response);
1086
1087 assert_eq!(token_response.status, 400);
1088 assert_eq!(
1089 (*(token_body).get("error").unwrap_or(&Value::Null)),
1090 "authorization_pending"
1091 );
1092 assert_eq!(
1093 (*(token_body)
1094 .get("error_description")
1095 .unwrap_or(&Value::Null)),
1096 AUTHORIZATION_PENDING
1097 );
1098 }
1099
1100 #[tokio::test]
1102 async fn test_device_token_expired_returns_error_and_deletes_record() {
1103 let plugin = DeviceAuthorizationPlugin::new();
1104 let ctx = test_helpers::create_test_context().await;
1105
1106 let stored = ctx
1107 .database
1108 .create_device_code(CreateDeviceCode {
1109 device_code: "expired-device-code".to_owned(),
1110 user_code: "EXPIRED12".to_owned(),
1111 user_id: None,
1112 expires_at: Utc::now() - Duration::seconds(1),
1113 status: DEVICE_STATUS_PENDING.to_owned(),
1114 last_polled_at: None,
1115 polling_interval: Some(5000),
1116 client_id: Some("test-client".to_owned()),
1117 scope: None,
1118 })
1119 .await
1120 .unwrap();
1121
1122 let response = plugin
1123 .handle_device_token(
1124 &device_token_request(&stored.device_code, "test-client"),
1125 &ctx,
1126 )
1127 .await
1128 .unwrap();
1129 let body = json_body(&response);
1130
1131 assert_eq!(response.status, 400);
1132 assert_eq!(
1133 (*(body).get("error").unwrap_or(&Value::Null)),
1134 "expired_token"
1135 );
1136 assert_eq!(
1137 (*(body).get("error_description").unwrap_or(&Value::Null)),
1138 EXPIRED_DEVICE_CODE
1139 );
1140 assert!(
1141 ctx.database
1142 .get_device_code_by_device_code(&stored.device_code)
1143 .await
1144 .unwrap()
1145 .is_none()
1146 );
1147 }
1148
1149 #[tokio::test]
1151 async fn test_device_token_invalid_device_code_returns_invalid_grant() {
1152 let plugin = DeviceAuthorizationPlugin::new();
1153 let ctx = test_helpers::create_test_context().await;
1154
1155 let response = plugin
1156 .handle_device_token(
1157 &device_token_request("invalid-device-code", "test-client"),
1158 &ctx,
1159 )
1160 .await
1161 .unwrap();
1162 let body = json_body(&response);
1163
1164 assert_eq!(response.status, 400);
1165 assert_eq!(
1166 (*(body).get("error").unwrap_or(&Value::Null)),
1167 "invalid_grant"
1168 );
1169 assert_eq!(
1170 (*(body).get("error_description").unwrap_or(&Value::Null)),
1171 INVALID_DEVICE_CODE
1172 );
1173 }
1174
1175 #[tokio::test]
1177 async fn test_device_token_rate_limits_with_slow_down() {
1178 let plugin = DeviceAuthorizationPlugin::new().interval(Duration::seconds(5));
1179 let ctx = test_helpers::create_test_context().await;
1180
1181 let create_request = test_helpers::create_auth_json_request_no_query(
1182 HttpMethod::Post,
1183 "/device/code",
1184 None,
1185 Some(serde_json::json!({ "client_id": "test-client" })),
1186 );
1187 let create_response = plugin
1188 .handle_device_code(&create_request, &ctx)
1189 .await
1190 .unwrap();
1191 let create_body = json_body(&create_response);
1192 let device_code = (*(create_body).get("device_code").unwrap_or(&Value::Null))
1193 .as_str()
1194 .unwrap();
1195
1196 let first = plugin
1197 .handle_device_token(&device_token_request(device_code, "test-client"), &ctx)
1198 .await
1199 .unwrap();
1200 assert_eq!(
1201 (*(json_body(&first)).get("error").unwrap_or(&Value::Null)),
1202 "authorization_pending"
1203 );
1204
1205 let second = plugin
1206 .handle_device_token(&device_token_request(device_code, "test-client"), &ctx)
1207 .await
1208 .unwrap();
1209 let second_body = json_body(&second);
1210
1211 assert_eq!(second.status, 400);
1212 assert_eq!(
1213 (*(second_body).get("error").unwrap_or(&Value::Null)),
1214 "slow_down"
1215 );
1216 assert_eq!(
1217 (*(second_body)
1218 .get("error_description")
1219 .unwrap_or(&Value::Null)),
1220 POLLING_TOO_FREQUENTLY
1221 );
1222 }
1223
1224 #[tokio::test]
1226 async fn test_device_verify_strips_hyphens_and_preserves_input_shape() {
1227 let plugin = DeviceAuthorizationPlugin::new();
1228 let ctx = test_helpers::create_test_context().await;
1229
1230 ctx.database
1231 .create_device_code(CreateDeviceCode {
1232 device_code: "verify-device-code".to_owned(),
1233 user_code: "ABCD2345".to_owned(),
1234 user_id: None,
1235 expires_at: Utc::now() + Duration::minutes(5),
1236 status: DEVICE_STATUS_PENDING.to_owned(),
1237 last_polled_at: None,
1238 polling_interval: Some(5000),
1239 client_id: Some("test-client".to_owned()),
1240 scope: None,
1241 })
1242 .await
1243 .unwrap();
1244
1245 let response = plugin
1246 .handle_device_verify(&device_verify_request("ABCD-2345"), &ctx)
1247 .await
1248 .unwrap();
1249 let body = json_body(&response);
1250
1251 assert_eq!(response.status, 200);
1252 assert_eq!(
1253 (*(body).get("user_code").unwrap_or(&Value::Null)),
1254 "ABCD-2345"
1255 );
1256 assert_eq!(
1257 (*(body).get("status").unwrap_or(&Value::Null)),
1258 DEVICE_STATUS_PENDING
1259 );
1260 }
1261
1262 #[tokio::test]
1264 async fn test_device_verify_invalid_user_code_returns_error() {
1265 let plugin = DeviceAuthorizationPlugin::new();
1266 let ctx = test_helpers::create_test_context().await;
1267
1268 let response = plugin
1269 .handle_device_verify(&device_verify_request("INVALID"), &ctx)
1270 .await
1271 .unwrap();
1272 let body = json_body(&response);
1273
1274 assert_eq!(response.status, 400);
1275 assert_eq!(
1276 (*(body).get("error").unwrap_or(&Value::Null)),
1277 "invalid_request"
1278 );
1279 assert_eq!(
1280 (*(body).get("error_description").unwrap_or(&Value::Null)),
1281 INVALID_USER_CODE
1282 );
1283 }
1284
1285 #[tokio::test]
1287 async fn test_device_approve_flow_creates_session_and_returns_oauth_token_response() {
1288 let plugin = DeviceAuthorizationPlugin::new();
1289 let (ctx, _user, session) = create_context_with_user("approve@example.com").await;
1290
1291 let create_request = test_helpers::create_auth_json_request_no_query(
1292 HttpMethod::Post,
1293 "/device/code",
1294 None,
1295 Some(serde_json::json!({
1296 "client_id": "test-client",
1297 "scope": "read write profile",
1298 })),
1299 );
1300 let create_response = plugin
1301 .handle_device_code(&create_request, &ctx)
1302 .await
1303 .unwrap();
1304 let create_body = json_body(&create_response);
1305 let device_code = (*(create_body).get("device_code").unwrap_or(&Value::Null))
1306 .as_str()
1307 .unwrap()
1308 .to_owned();
1309 let user_code = (*(create_body).get("user_code").unwrap_or(&Value::Null))
1310 .as_str()
1311 .unwrap()
1312 .to_owned();
1313
1314 plugin
1315 .handle_device_verify(&device_claim_request(&user_code, &session.token), &ctx)
1316 .await
1317 .unwrap();
1318
1319 let approve_request = test_helpers::create_auth_json_request_no_query(
1320 HttpMethod::Post,
1321 "/device/approve",
1322 Some(&session.token),
1323 Some(serde_json::json!({ "userCode": user_code })),
1324 );
1325 let approve_response = plugin
1326 .handle_device_approve(&approve_request, &ctx)
1327 .await
1328 .unwrap();
1329 assert_eq!(
1330 (*(json_body(&approve_response))
1331 .get("success")
1332 .unwrap_or(&Value::Null)),
1333 true
1334 );
1335
1336 let token_response = plugin
1337 .handle_device_token(&device_token_request(&device_code, "test-client"), &ctx)
1338 .await
1339 .unwrap();
1340 let token_body = json_body(&token_response);
1341
1342 assert_eq!(token_response.status, 200);
1343 assert_eq!(
1344 token_response.headers.get("Cache-Control"),
1345 Some(&"no-store".to_owned())
1346 );
1347 assert_eq!(
1348 token_response.headers.get("Pragma"),
1349 Some(&"no-cache".to_owned())
1350 );
1351 assert_eq!(
1352 (*(token_body).get("token_type").unwrap_or(&Value::Null)),
1353 "Bearer"
1354 );
1355 assert_eq!(
1356 (*(token_body).get("scope").unwrap_or(&Value::Null)),
1357 "read write profile"
1358 );
1359 assert!(
1360 (*(token_body).get("expires_in").unwrap_or(&Value::Null))
1361 .as_i64()
1362 .unwrap()
1363 > 0
1364 );
1365
1366 let access_token = (*(token_body).get("access_token").unwrap_or(&Value::Null))
1367 .as_str()
1368 .unwrap();
1369 assert!(
1370 ctx.database
1371 .get_session(access_token)
1372 .await
1373 .unwrap()
1374 .is_some()
1375 );
1376 assert!(
1377 ctx.database
1378 .get_device_code_by_device_code(&device_code)
1379 .await
1380 .unwrap()
1381 .is_none()
1382 );
1383 }
1384
1385 #[tokio::test]
1389 async fn test_device_approve_rejects_unclaimed_code() {
1390 let plugin = DeviceAuthorizationPlugin::new();
1391 let (ctx, _user, session) = create_context_with_user("unclaimed@example.com").await;
1392
1393 let create_request = test_helpers::create_auth_json_request_no_query(
1394 HttpMethod::Post,
1395 "/device/code",
1396 None,
1397 Some(serde_json::json!({ "client_id": "test-client" })),
1398 );
1399 let create_response = plugin
1400 .handle_device_code(&create_request, &ctx)
1401 .await
1402 .unwrap();
1403 let create_body = json_body(&create_response);
1404 let device_code = (*(create_body).get("device_code").unwrap_or(&Value::Null))
1405 .as_str()
1406 .unwrap()
1407 .to_owned();
1408 let user_code = (*(create_body).get("user_code").unwrap_or(&Value::Null))
1409 .as_str()
1410 .unwrap()
1411 .to_owned();
1412
1413 let approve_request = test_helpers::create_auth_json_request_no_query(
1415 HttpMethod::Post,
1416 "/device/approve",
1417 Some(&session.token),
1418 Some(serde_json::json!({ "userCode": user_code })),
1419 );
1420 let approve_response = plugin
1421 .handle_device_approve(&approve_request, &ctx)
1422 .await
1423 .unwrap();
1424 let approve_body = json_body(&approve_response);
1425
1426 assert_eq!(approve_response.status, 400);
1427 assert_eq!(
1428 (*(approve_body).get("error").unwrap_or(&Value::Null)),
1429 "invalid_request"
1430 );
1431 assert_eq!(
1432 (*(approve_body)
1433 .get("error_description")
1434 .unwrap_or(&Value::Null)),
1435 DEVICE_CODE_NOT_CLAIMED
1436 );
1437
1438 let stored = ctx
1439 .database
1440 .get_device_code_by_device_code(&device_code)
1441 .await
1442 .unwrap()
1443 .unwrap();
1444 assert_eq!(stored.status, DEVICE_STATUS_PENDING);
1445 assert_eq!(stored.user_id, None);
1446 }
1447
1448 #[tokio::test]
1450 async fn test_device_deny_flow_returns_access_denied_and_deletes_record() {
1451 let plugin = DeviceAuthorizationPlugin::new();
1452 let (ctx, _user, session) = create_context_with_user("deny@example.com").await;
1453
1454 let create_request = test_helpers::create_auth_json_request_no_query(
1455 HttpMethod::Post,
1456 "/device/code",
1457 None,
1458 Some(serde_json::json!({ "client_id": "test-client" })),
1459 );
1460 let create_response = plugin
1461 .handle_device_code(&create_request, &ctx)
1462 .await
1463 .unwrap();
1464 let create_body = json_body(&create_response);
1465 let device_code = (*(create_body).get("device_code").unwrap_or(&Value::Null))
1466 .as_str()
1467 .unwrap()
1468 .to_owned();
1469 let user_code = (*(create_body).get("user_code").unwrap_or(&Value::Null))
1470 .as_str()
1471 .unwrap()
1472 .to_owned();
1473
1474 plugin
1475 .handle_device_verify(&device_claim_request(&user_code, &session.token), &ctx)
1476 .await
1477 .unwrap();
1478
1479 let deny_request = test_helpers::create_auth_json_request_no_query(
1480 HttpMethod::Post,
1481 "/device/deny",
1482 Some(&session.token),
1483 Some(serde_json::json!({ "userCode": user_code })),
1484 );
1485 let deny_response = plugin
1486 .handle_device_deny(&deny_request, &ctx)
1487 .await
1488 .unwrap();
1489 assert_eq!(
1490 (*(json_body(&deny_response))
1491 .get("success")
1492 .unwrap_or(&Value::Null)),
1493 true
1494 );
1495
1496 let token_response = plugin
1497 .handle_device_token(&device_token_request(&device_code, "test-client"), &ctx)
1498 .await
1499 .unwrap();
1500 let token_body = json_body(&token_response);
1501
1502 assert_eq!(token_response.status, 400);
1503 assert_eq!(
1504 (*(token_body).get("error").unwrap_or(&Value::Null)),
1505 "access_denied"
1506 );
1507 assert_eq!(
1508 (*(token_body)
1509 .get("error_description")
1510 .unwrap_or(&Value::Null)),
1511 ACCESS_DENIED
1512 );
1513 assert!(
1514 ctx.database
1515 .get_device_code_by_device_code(&device_code)
1516 .await
1517 .unwrap()
1518 .is_none()
1519 );
1520 }
1521
1522 #[tokio::test]
1524 async fn test_device_approve_requires_authentication_and_blocks_double_processing() {
1525 let plugin = DeviceAuthorizationPlugin::new();
1526 let (ctx, _user, session) = create_context_with_user("double@example.com").await;
1527
1528 let create_request = test_helpers::create_auth_json_request_no_query(
1529 HttpMethod::Post,
1530 "/device/code",
1531 None,
1532 Some(serde_json::json!({ "client_id": "test-client" })),
1533 );
1534 let create_response = plugin
1535 .handle_device_code(&create_request, &ctx)
1536 .await
1537 .unwrap();
1538 let create_body = json_body(&create_response);
1539 let user_code = (*(create_body).get("user_code").unwrap_or(&Value::Null))
1540 .as_str()
1541 .unwrap()
1542 .to_owned();
1543
1544 plugin
1545 .handle_device_verify(&device_claim_request(&user_code, &session.token), &ctx)
1546 .await
1547 .unwrap();
1548
1549 let unauthenticated_request = test_helpers::create_auth_json_request_no_query(
1550 HttpMethod::Post,
1551 "/device/approve",
1552 None,
1553 Some(serde_json::json!({ "userCode": user_code.clone() })),
1554 );
1555 let unauthenticated_response = plugin
1556 .handle_device_approve(&unauthenticated_request, &ctx)
1557 .await
1558 .unwrap();
1559 let unauthenticated_body = json_body(&unauthenticated_response);
1560 assert_eq!(unauthenticated_response.status, 401);
1561 assert_eq!(
1562 (*(unauthenticated_body).get("error").unwrap_or(&Value::Null)),
1563 "unauthorized"
1564 );
1565 assert_eq!(
1566 (*(unauthenticated_body)
1567 .get("error_description")
1568 .unwrap_or(&Value::Null)),
1569 AUTHENTICATION_REQUIRED
1570 );
1571
1572 let approve_request = test_helpers::create_auth_json_request_no_query(
1573 HttpMethod::Post,
1574 "/device/approve",
1575 Some(&session.token),
1576 Some(serde_json::json!({ "userCode": user_code.clone() })),
1577 );
1578 let first_response = plugin
1579 .handle_device_approve(&approve_request, &ctx)
1580 .await
1581 .unwrap();
1582 assert_eq!(
1583 (*(json_body(&first_response))
1584 .get("success")
1585 .unwrap_or(&Value::Null)),
1586 true
1587 );
1588
1589 let second_response = plugin
1590 .handle_device_approve(&approve_request, &ctx)
1591 .await
1592 .unwrap();
1593 let second_body = json_body(&second_response);
1594 assert_eq!(second_response.status, 400);
1595 assert_eq!(
1596 (*(second_body).get("error").unwrap_or(&Value::Null)),
1597 "invalid_request"
1598 );
1599 assert_eq!(
1600 (*(second_body)
1601 .get("error_description")
1602 .unwrap_or(&Value::Null)),
1603 DEVICE_CODE_ALREADY_PROCESSED
1604 );
1605 }
1606
1607 #[tokio::test]
1609 async fn test_device_token_rejects_client_id_mismatch() {
1610 let plugin = DeviceAuthorizationPlugin::new();
1611 let ctx = test_helpers::create_test_context().await;
1612
1613 let create_request = test_helpers::create_auth_json_request_no_query(
1614 HttpMethod::Post,
1615 "/device/code",
1616 None,
1617 Some(serde_json::json!({ "client_id": "client-a" })),
1618 );
1619 let create_response = plugin
1620 .handle_device_code(&create_request, &ctx)
1621 .await
1622 .unwrap();
1623 let create_body = json_body(&create_response);
1624
1625 let response = plugin
1626 .handle_device_token(
1627 &device_token_request(
1628 (*(create_body).get("device_code").unwrap_or(&Value::Null))
1629 .as_str()
1630 .unwrap(),
1631 "client-b",
1632 ),
1633 &ctx,
1634 )
1635 .await
1636 .unwrap();
1637 let body = json_body(&response);
1638
1639 assert_eq!(response.status, 400);
1640 assert_eq!(
1641 (*(body).get("error").unwrap_or(&Value::Null)),
1642 "invalid_grant"
1643 );
1644 assert_eq!(
1645 (*(body).get("error_description").unwrap_or(&Value::Null)),
1646 CLIENT_ID_MISMATCH
1647 );
1648 }
1649}
1650