Skip to main content

alibi_api/plugins/device_authorization/
mod.rs

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