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