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