Skip to main content

alibi_plugins/device_authorization/
mod.rs

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