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