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