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