Skip to main content

better_auth_api/plugins/device_authorization/
mod.rs

1mod grant;
2mod redemption;
3mod request;
4mod request_fields;
5pub use better_auth_core::DeviceCodeOwnership;
6use chrono::{Duration, Utc};
7pub use grant::{DeviceGrant, DeviceGrantAuthorization, DeviceGrantFuture};
8use rand::{
9    RngCore,
10    distributions::{Alphanumeric, DistString},
11};
12pub use redemption::{
13    DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, DeviceRedemptionFuture,
14    redeem_device_code,
15};
16pub use request_fields::{
17    DeviceFieldValidation, DeviceRequestField, DeviceRequestFields, DeviceRequestIssue,
18};
19use std::fmt;
20use std::future::Future;
21use std::pin::Pin;
22use std::sync::Arc;
23pub use types::DeviceAuthorizationRequest;
24use url::Url;
25
26use crate::plugins::endpoint_context::EndpointContext;
27use crate::plugins::helpers::{SessionIssueError, issue_user_session};
28use better_auth_core::entity::{AuthSession, AuthUser};
29use better_auth_core::{
30    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, CreateDeviceCode, DatabaseError,
31    DeviceCode, RequestMeta, UpdateDeviceCode,
32};
33
34pub(super) mod types;
35
36#[cfg(test)]
37mod tests;
38
39use types::{
40    DeviceActionRequest, DeviceActionResponse, DeviceCodeResponse, DeviceErrorResponse,
41    DeviceReviewContext, DeviceTokenRequest, DeviceTokenResponse, DeviceVerifyResponse,
42};
43
44const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
45const DEVICE_STATUS_PENDING: &str = "pending";
46const DEVICE_STATUS_APPROVED: &str = "approved";
47const DEVICE_STATUS_DENIED: &str = "denied";
48const DEFAULT_USER_CODE_CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
49
50const INVALID_DEVICE_CODE: &str = "Invalid device code";
51const EXPIRED_DEVICE_CODE: &str = "Device code has expired";
52const EXPIRED_USER_CODE: &str = "User code has expired";
53const AUTHORIZATION_PENDING: &str = "Authorization pending";
54const ACCESS_DENIED: &str = "Access denied";
55const INVALID_USER_CODE: &str = "Invalid user code";
56const DEVICE_CODE_ALREADY_PROCESSED: &str = "Device code already processed";
57const 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";
58const POLLING_TOO_FREQUENTLY: &str = "Polling too frequently";
59const USER_NOT_FOUND: &str = "User not found";
60const FAILED_TO_CREATE_SESSION: &str = "Failed to create session";
61const INVALID_DEVICE_CODE_STATUS: &str = "Invalid device code status";
62const AUTHENTICATION_REQUIRED: &str = "Authentication required";
63const INVALID_CLIENT_ID: &str = "Invalid client ID";
64const CLIENT_ID_MISMATCH: &str = "Client ID mismatch";
65const INVALID_REQUEST: &str = "Invalid request";
66
67type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
68type ValidateClientCallback = dyn Fn(String) -> BoxFuture<AuthResult<bool>> + Send + Sync;
69type DeviceAuthRequestCallback =
70    dyn Fn(String, Option<String>) -> BoxFuture<AuthResult<()>> + Send + Sync;
71type CodeGenerator = dyn Fn() -> BoxFuture<AuthResult<String>> + Send + Sync;
72
73#[derive(Clone)]
74struct DeviceAuthorizationConfig {
75    expires_in: Duration,
76    interval: Duration,
77    device_code_length: usize,
78    user_code_length: usize,
79    generate_device_code: Option<Arc<CodeGenerator>>,
80    generate_user_code: Option<Arc<CodeGenerator>>,
81    validate_client: Option<Arc<ValidateClientCallback>>,
82    on_device_auth_request: Option<Arc<DeviceAuthRequestCallback>>,
83    verification_uri: Option<String>,
84    request_fields: Option<DeviceRequestFields>,
85    grant_fields: Option<better_auth_core::user_fields::UserConfig>,
86    grant_metadata: grant::GrantMetadata,
87}
88
89impl Default for DeviceAuthorizationConfig {
90    fn default() -> Self {
91        Self {
92            expires_in: Duration::minutes(30),
93            interval: Duration::seconds(5),
94            device_code_length: 40,
95            user_code_length: 8,
96            generate_device_code: None,
97            generate_user_code: None,
98            validate_client: None,
99            on_device_auth_request: None,
100            verification_uri: None,
101            request_fields: None,
102            grant_fields: None,
103            grant_metadata: grant::GrantMetadata::default(),
104        }
105    }
106}
107
108impl fmt::Debug for DeviceAuthorizationConfig {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.debug_struct("DeviceAuthorizationConfig")
111            .field("expires_in", &self.expires_in)
112            .field("interval", &self.interval)
113            .field("device_code_length", &self.device_code_length)
114            .field("user_code_length", &self.user_code_length)
115            .field(
116                "generate_device_code",
117                &self.generate_device_code.as_ref().map(|_| "custom"),
118            )
119            .field(
120                "generate_user_code",
121                &self.generate_user_code.as_ref().map(|_| "custom"),
122            )
123            .field(
124                "validate_client",
125                &self.validate_client.as_ref().map(|_| "custom"),
126            )
127            .field(
128                "on_device_auth_request",
129                &self.on_device_auth_request.as_ref().map(|_| "custom"),
130            )
131            .field("verification_uri", &self.verification_uri)
132            .field(
133                "request_fields",
134                &self.request_fields.as_ref().map(|_| "configured"),
135            )
136            .field("grant", &self.grant_fields.as_ref().map(|_| "configured"))
137            .finish()
138    }
139}
140
141#[derive(Clone, Copy)]
142enum DeviceDecision {
143    Approve,
144    Deny,
145}
146
147impl DeviceDecision {
148    fn status(self) -> &'static str {
149        match self {
150            Self::Approve => DEVICE_STATUS_APPROVED,
151            Self::Deny => DEVICE_STATUS_DENIED,
152        }
153    }
154
155    fn forbidden_message(self) -> &'static str {
156        match self {
157            Self::Approve => "You are not authorized to approve this device authorization",
158            Self::Deny => "You are not authorized to deny this device authorization",
159        }
160    }
161}
162
163/// OAuth 2.0 device authorization grant plugin.
164pub struct DeviceAuthorizationPlugin {
165    config: DeviceAuthorizationConfig,
166}
167
168impl Default for DeviceAuthorizationPlugin {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl DeviceAuthorizationPlugin {
175    /// Create the plugin with TS-aligned defaults.
176    pub fn new() -> Self {
177        Self {
178            config: DeviceAuthorizationConfig::default(),
179        }
180    }
181
182    /// Validate additional request fields without changing client authorization or storing those fields.
183    pub fn request_fields(mut self, fields: DeviceRequestFields) -> Self {
184        self.config.request_fields = Some(fields);
185        self
186    }
187
188    /// Override the device-code expiration window.
189    pub fn expires_in(mut self, duration: Duration) -> Self {
190        self.config.expires_in = duration;
191        self
192    }
193
194    /// Override the minimum polling interval enforced by `/device/token`.
195    pub fn interval(mut self, duration: Duration) -> Self {
196        self.config.interval = duration;
197        self
198    }
199
200    /// Override the generated device-code length.
201    pub fn device_code_length(mut self, length: usize) -> Self {
202        self.config.device_code_length = length;
203        self
204    }
205
206    /// Override the generated user-code length.
207    pub fn user_code_length(mut self, length: usize) -> Self {
208        self.config.user_code_length = length;
209        self
210    }
211
212    /// Override the verification page URI returned to devices.
213    pub fn verification_uri(mut self, uri: impl Into<String>) -> Self {
214        self.config.verification_uri = Some(uri.into());
215        self
216    }
217
218    /// Await a custom device-code generator before generating the user code.
219    pub fn generate_device_code_with<F, Fut>(mut self, generator: F) -> Self
220    where
221        F: Fn() -> Fut + Send + Sync + 'static,
222        Fut: Future<Output = AuthResult<String>> + Send + 'static,
223    {
224        self.config.generate_device_code = Some(Arc::new(move || Box::pin(generator())));
225        self
226    }
227
228    /// Await a custom user-code generator before persisting the code pair.
229    pub fn generate_user_code_with<F, Fut>(mut self, generator: F) -> Self
230    where
231        F: Fn() -> Fut + Send + Sync + 'static,
232        Fut: Future<Output = AuthResult<String>> + Send + 'static,
233    {
234        self.config.generate_user_code = Some(Arc::new(move || Box::pin(generator())));
235        self
236    }
237
238    /// Validate the OAuth client identifier before issuing or redeeming codes.
239    pub fn validate_client<F, Fut>(mut self, callback: F) -> Self
240    where
241        F: Fn(String) -> Fut + Send + Sync + 'static,
242        Fut: Future<Output = AuthResult<bool>> + Send + 'static,
243    {
244        self.config.validate_client =
245            Some(Arc::new(move |client_id| Box::pin(callback(client_id))));
246        self
247    }
248
249    /// Run a hook when a device authorization request is created.
250    pub fn on_device_auth_request<F, Fut>(mut self, callback: F) -> Self
251    where
252        F: Fn(String, Option<String>) -> Fut + Send + Sync + 'static,
253        Fut: Future<Output = AuthResult<()>> + Send + 'static,
254    {
255        self.config.on_device_auth_request = Some(Arc::new(move |client_id, scope| {
256            Box::pin(callback(client_id, scope))
257        }));
258        self
259    }
260
261    async fn handle_device_code<S: better_auth_core::AuthSchema>(
262        &self,
263        req: &AuthRequest,
264        ctx: &AuthContext<S>,
265    ) -> AuthResult<AuthResponse> {
266        let body = request::normalize_code(
267            req,
268            request::read_code(
269                req,
270                self.config.request_fields.as_ref(),
271                self.config.grant_fields.is_some(),
272            )
273            .await?,
274        )?;
275
276        let authorization = if self.config.grant_fields.is_some() {
277            let endpoint = EndpointContext::new(
278                Some(req),
279                better_auth_core::FieldValue::from_json(serde_json::to_value(&body)?)?,
280                ctx,
281            );
282            self.authorize_request(&body, &endpoint).await?
283        } else {
284            let Some(client_id) = body.client_id.as_ref() else {
285                return Err(device_error_response(
286                    400,
287                    "invalid_request",
288                    "client_id is required",
289                )?
290                .into());
291            };
292            if !self.validate_client_id(client_id).await? {
293                return device_error_response(400, "invalid_client", INVALID_CLIENT_ID);
294            }
295            DeviceGrantAuthorization {
296                client_id: client_id.clone(),
297                additional_fields: Default::default(),
298            }
299        };
300
301        if let Some(callback) = &self.config.on_device_auth_request {
302            callback(authorization.client_id.clone(), body.scope.clone()).await?;
303        }
304
305        let expires_at = Utc::now() + self.config.expires_in;
306        let polling_interval = self.config.interval.num_seconds() as f64 * 1000.0
307            + f64::from(self.config.interval.subsec_nanos()) / 1_000_000.0;
308        for _ in 0..3 {
309            let device_code = self.generate_device_code().await?;
310            let user_code = self.generate_user_code().await?;
311            match ctx
312                .database
313                .create_device_code(CreateDeviceCode {
314                    additional_fields: authorization.additional_fields.clone(),
315                    device_code: device_code.clone(),
316                    user_code: user_code.clone(),
317                    user_id: body.user_id.clone().filter(|id| !id.is_empty()),
318                    expires_at: expires_at.into(),
319                    status: DEVICE_STATUS_PENDING.to_string(),
320                    last_polled_at: None,
321                    polling_interval: Some(polling_interval),
322                    client_id: Some(authorization.client_id.clone()),
323                    scope: body
324                        .scope
325                        .clone()
326                        .map(|scope| Some(scope).into())
327                        .unwrap_or_default(),
328                })
329                .await
330            {
331                Ok(_) => {}
332                Err(AuthError::Database(DatabaseError::UniqueConstraint(_))) => continue,
333                Err(error) => return Err(error),
334            }
335
336            let (verification_uri, verification_uri_complete) = build_verification_uris(
337                self.config.verification_uri.as_deref(),
338                ctx.base_url(),
339                &user_code,
340            )?;
341
342            return Ok(AuthResponse::json(
343                200,
344                &DeviceCodeResponse {
345                    device_code,
346                    user_code,
347                    verification_uri,
348                    verification_uri_complete,
349                    expires_in: self.config.expires_in.as_seconds_f64().floor() as i64,
350                    interval: self.config.interval.as_seconds_f64().floor() as i64,
351                },
352            )?
353            .with_header("Cache-Control", "no-store")
354            .with_header("Pragma", "no-cache"));
355        }
356        device_error_response(
357            500,
358            "server_error",
359            "Failed to generate a unique device code",
360        )
361    }
362
363    async fn handle_device_token<S: better_auth_core::AuthSchema>(
364        &self,
365        req: &AuthRequest,
366        ctx: &AuthContext<S>,
367    ) -> AuthResult<AuthResponse> {
368        let body: DeviceTokenRequest = request::read(req, request::token)?;
369
370        if !self.validate_client_id(&body.client_id).await? {
371            return device_error_response(400, "invalid_grant", INVALID_CLIENT_ID);
372        }
373
374        let endpoint = EndpointContext::new(
375            Some(req),
376            better_auth_core::FieldValue::from_json(serde_json::to_value(&body)?)?,
377            ctx,
378        );
379        let client_id = body.client_id;
380        let grant = self.configured_grant(ctx)?;
381        let redemption = redeem_device_code(
382            &endpoint,
383            &body.device_code,
384            move |device_code, endpoint| {
385                Box::pin(async move {
386                    let stored_client_id: Option<String> = serde_json::from_value(
387                        device_code
388                            .client_id
389                            .json()?
390                            .unwrap_or(serde_json::Value::Null),
391                    )?;
392                    if let Some(stored_client_id) = stored_client_id.as_deref()
393                        && stored_client_id != client_id
394                    {
395                        return Err(device_error_response(
396                            400,
397                            "invalid_grant",
398                            CLIENT_ID_MISMATCH,
399                        )?
400                        .into());
401                    }
402                    if let Some(grant) = grant {
403                        grant
404                            .assert_session_redemption(device_code, endpoint)
405                            .await?;
406                    }
407                    Ok(DeviceCodeRedemptionAuthorization {
408                        ownership: DeviceCodeOwnership::ClientId(client_id),
409                        context: (),
410                    })
411                })
412            },
413            |_device_code, _authorization, _endpoint| Box::pin(async { Ok(()) }),
414        )
415        .await;
416        let device_code = match redemption {
417            Ok(redemption) => redemption.claimed_device_code,
418            Err(error @ AuthError::Response(_)) => return Ok(error.to_auth_response()),
419            Err(error) => return Err(error),
420        };
421        let Some(user_id) = device_code.user_id.as_deref() else {
422            return device_error_response(500, "server_error", INVALID_DEVICE_CODE_STATUS);
423        };
424        let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
425        let session = match issue_user_session(ctx, user_id, meta.ip_address, meta.user_agent)
426            .await
427            .map_err(SessionIssueError::into_auth_error)
428        {
429            Ok(issued) => issued.session,
430            Err(error) => {
431                better_auth_core::observability::logger::current().error(
432                    "failed to create session after device code redemption",
433                    &[
434                        better_auth_core::observability::LogArgument::Error(&error),
435                        better_auth_core::observability::LogArgument::Value(&serde_json::json!(
436                            device_code.id
437                        )),
438                        better_auth_core::observability::LogArgument::Value(&serde_json::json!(
439                            user_id
440                        )),
441                    ],
442                );
443                return device_error_response(500, "server_error", FAILED_TO_CREATE_SESSION);
444            }
445        };
446
447        Ok(AuthResponse::json(
448            200,
449            &DeviceTokenResponse {
450                access_token: session.token().to_string(),
451                token_type: "Bearer",
452                expires_in: {
453                    let seconds = ((session.expires_at().milliseconds()
454                        - Utc::now().timestamp_millis() as f64)
455                        / 1_000.0)
456                        .floor();
457                    if seconds < 0.0 { 0.0 } else { seconds }
458                }
459                .into(),
460                scope: device_code
461                    .scope
462                    .field_value()
463                    .decode::<Option<String>>()?
464                    .unwrap_or_default(),
465            },
466        )?
467        .with_header("Cache-Control", "no-store")
468        .with_header("Pragma", "no-cache"))
469    }
470
471    async fn handle_device_verify<S: better_auth_core::AuthSchema>(
472        &self,
473        req: &AuthRequest,
474        ctx: &AuthContext<S>,
475    ) -> AuthResult<AuthResponse> {
476        let Some(user_code) = req.query_string("user_code")?.map(str::to_owned) else {
477            return device_error_response(400, "invalid_request", INVALID_REQUEST);
478        };
479
480        let Some(mut device_code) = find_device_code_by_user_code(ctx, &user_code).await? else {
481            return device_error_response(400, "invalid_request", INVALID_USER_CODE);
482        };
483
484        if device_code.expires_at.milliseconds() < Utc::now().timestamp_millis() as f64 {
485            return device_error_response(400, "expired_token", EXPIRED_USER_CODE);
486        }
487
488        let user_id = match ctx.require_session(req).await {
489            Ok((user, _)) => Some(user.id().into_owned()),
490            Err(AuthError::Unauthenticated) => None,
491            Err(error) => return Err(error),
492        };
493        if let Some(user_id) = user_id.as_ref().and_then(|id| id.as_str())
494            && device_code.user_id.is_none()
495            && device_code.status == DEVICE_STATUS_PENDING
496            && ctx
497                .database
498                .claim_device_code(&device_code.id, user_id)
499                .await?
500        {
501            device_code.user_id = Some(user_id.to_string());
502        }
503        let can_review = user_id.is_some()
504            && device_code.user_id.as_deref() == user_id.as_ref().and_then(|id| id.as_str());
505        let additional_fields = if can_review {
506            match self.configured_grant(ctx)? {
507                Some(grant) => grant.verification_context_for(&device_code)?,
508                None => Default::default(),
509            }
510        } else {
511            Default::default()
512        };
513
514        AuthResponse::json(
515            200,
516            &DeviceVerifyResponse {
517                user_code,
518                status: device_code.status,
519                review: can_review.then_some(DeviceReviewContext {
520                    additional_fields,
521                    client_id: device_code.client_id,
522                    scope: device_code.scope,
523                }),
524            },
525        )
526        .map_err(AuthError::from)
527    }
528
529    async fn handle_device_approve(
530        &self,
531        req: &AuthRequest,
532        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
533    ) -> AuthResult<AuthResponse> {
534        self.handle_device_decision(req, ctx, DeviceDecision::Approve)
535            .await
536    }
537
538    async fn handle_device_deny(
539        &self,
540        req: &AuthRequest,
541        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
542    ) -> AuthResult<AuthResponse> {
543        self.handle_device_decision(req, ctx, DeviceDecision::Deny)
544            .await
545    }
546
547    async fn handle_device_decision(
548        &self,
549        req: &AuthRequest,
550        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
551        decision: DeviceDecision,
552    ) -> AuthResult<AuthResponse> {
553        let user = match ctx.require_session(req).await {
554            Ok((user, _session)) => user,
555            Err(AuthError::Unauthenticated) | Err(AuthError::SessionNotFound) => {
556                return device_error_response(401, "unauthorized", AUTHENTICATION_REQUIRED);
557            }
558            Err(error) => return Err(error),
559        };
560
561        let current_user_id = user.id().into_owned();
562        let body: DeviceActionRequest = request::read(req, request::action)?;
563
564        let Some(device_code) = find_device_code_by_user_code(ctx, &body.user_code).await? else {
565            return device_error_response(400, "invalid_request", INVALID_USER_CODE);
566        };
567
568        if device_code.expires_at.milliseconds() < Utc::now().timestamp_millis() as f64 {
569            return device_error_response(400, "expired_token", EXPIRED_USER_CODE);
570        }
571
572        if device_code.status != DEVICE_STATUS_PENDING {
573            return device_error_response(400, "invalid_request", DEVICE_CODE_ALREADY_PROCESSED);
574        }
575
576        // The code must already be bound to a user by `GET /device`. Without
577        // this, any signed-in caller who knows a user_code could approve a
578        // device they never claimed.
579        let Some(claimed_user_id) = device_code.user_id.as_deref() else {
580            return device_error_response(400, "invalid_request", DEVICE_CODE_NOT_CLAIMED);
581        };
582
583        if current_user_id != claimed_user_id {
584            return device_error_response(403, "access_denied", decision.forbidden_message());
585        }
586
587        let updated_user_id = claimed_user_id.to_string();
588
589        let updated = ctx
590            .database
591            .update_device_code_if_status(
592                &device_code.id,
593                DEVICE_STATUS_PENDING,
594                UpdateDeviceCode {
595                    status: Some(decision.status().to_string()),
596                    user_id: Some(Some(updated_user_id)),
597                    ..Default::default()
598                },
599            )
600            .await?;
601
602        if !updated {
603            return device_error_response(400, "invalid_request", DEVICE_CODE_ALREADY_PROCESSED);
604        }
605
606        AuthResponse::json(200, &DeviceActionResponse { success: true }).map_err(AuthError::from)
607    }
608
609    async fn validate_client_id(&self, client_id: &str) -> AuthResult<bool> {
610        match &self.config.validate_client {
611            Some(callback) => callback(client_id.to_string()).await,
612            None => Ok(true),
613        }
614    }
615
616    async fn generate_device_code(&self) -> AuthResult<String> {
617        let code = match &self.config.generate_device_code {
618            Some(generator) => generator().await?,
619            None => {
620                Alphanumeric.sample_string(&mut rand::rngs::OsRng, self.config.device_code_length)
621            }
622        };
623        validate_generated_code(code, "device")
624    }
625
626    async fn generate_user_code(&self) -> AuthResult<String> {
627        let code = match &self.config.generate_user_code {
628            Some(generator) => generator().await?,
629            None => default_generate_user_code(self.config.user_code_length),
630        };
631        validate_generated_code(code, "user")
632    }
633}
634
635fn validate_generated_code(code: String, label: &str) -> AuthResult<String> {
636    if code.chars().count() > 191 {
637        return Err(device_error_response(
638            400,
639            "invalid_request",
640            &format!("Generated {label} code must be at most 191 characters"),
641        )?
642        .into());
643    }
644    Ok(code)
645}
646
647#[async_trait::async_trait]
648impl<S: better_auth_core::AuthSchema> better_auth_core::AuthPlugin<S>
649    for DeviceAuthorizationPlugin
650{
651    fn name(&self) -> &'static str {
652        "device-authorization"
653    }
654
655    fn openapi(&self) -> AuthResult<better_auth_core::openapi::OpenApiPluginMetadata> {
656        let metadata = better_auth_core::openapi::OpenApiPluginMetadata::from_routes(
657            "device-authorization",
658            <Self as better_auth_core::AuthPlugin<S>>::routes(self),
659        )?;
660        let metadata = if self.config.grant_fields.is_some() {
661            metadata.device_authorization_grant(
662                &self.config.grant_metadata.request_error_codes,
663                &self.config.grant_metadata.request_responses,
664                &self.config.grant_metadata.verification_properties,
665            )?
666        } else {
667            metadata
668        };
669        if let Some(fields) = &self.config.request_fields {
670            let (properties, required) = fields.openapi();
671            metadata.device_authorization_request_fields(properties, &required)
672        } else {
673            Ok(metadata)
674        }
675    }
676
677    fn routes(&self) -> Vec<better_auth_core::AuthRoute> {
678        use better_auth_core::AuthRoute;
679        let code = AuthRoute::post("/device/code", "deviceCode")
680            .allowed_media_types(&["application/json", "application/x-www-form-urlencoded"]);
681        let grant = self.config.grant_fields.is_some();
682        let code = match (&self.config.request_fields, grant) {
683            (None, false) => code.body_validator(request::code),
684            (fields, grant) => {
685                let fields = fields.as_ref().cloned().unwrap_or_default();
686                code.body_validator_async(move |request| {
687                    let fields = fields.clone();
688                    async move { request::code_with_fields(&request, &fields, grant).await }
689                })
690            }
691        };
692        vec![
693            code,
694            AuthRoute::post("/device/token", "deviceToken").body_validator(request::token),
695            AuthRoute::get("/device", "deviceVerify")
696                .query_validator(crate::plugins::query_input::device),
697            AuthRoute::post("/device/approve", "deviceApprove")
698                .body_validator(request::action)
699                .require_headers(true),
700            AuthRoute::post("/device/deny", "deviceDeny")
701                .body_validator(request::action)
702                .require_headers(true),
703        ]
704    }
705
706    async fn on_request(
707        &self,
708        req: &AuthRequest,
709        ctx: &AuthContext<S>,
710    ) -> AuthResult<Option<AuthResponse>> {
711        use better_auth_core::HttpMethod;
712        let response = match (req.method(), req.path()) {
713            (HttpMethod::Post, "/device/code") => self.handle_device_code(req, ctx).await?,
714            (HttpMethod::Post, "/device/token") => self.handle_device_token(req, ctx).await?,
715            (HttpMethod::Get, "/device") => self.handle_device_verify(req, ctx).await?,
716            (HttpMethod::Post, "/device/approve") => self.handle_device_approve(req, ctx).await?,
717            (HttpMethod::Post, "/device/deny") => self.handle_device_deny(req, ctx).await?,
718            _ => return Ok(None),
719        };
720        Ok(Some(response))
721    }
722
723    async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
724        for (name, length) in [
725            ("deviceCodeLength", self.config.device_code_length),
726            ("userCodeLength", self.config.user_code_length),
727        ] {
728            if !(1..=191).contains(&length) {
729                return Err(AuthError::config(format!(
730                    "{name} must be between 1 and 191"
731                )));
732            }
733        }
734        if let Some(fields) = &self.config.grant_fields {
735            grant::register_fields(ctx, fields)?;
736        }
737        Ok(())
738    }
739    fn rate_limits(&self) -> AuthResult<Vec<better_auth_core::middleware::PluginRateLimit>> {
740        let window = self.config.expires_in.num_seconds() as f64
741            + f64::from(self.config.expires_in.subsec_nanos()) / 1_000_000_000.0;
742        Ok(vec![better_auth_core::middleware::PluginRateLimit::exact(
743            "/device",
744            better_auth_core::middleware::EndpointRateLimit {
745                window,
746                max_requests: 5.0,
747            },
748        )])
749    }
750}
751
752async fn find_device_code_by_user_code(
753    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
754    user_code: &str,
755) -> AuthResult<Option<DeviceCode>> {
756    if let Some(code) = ctx.database.get_device_code_by_user_code(user_code).await?
757        && code.user_code == user_code
758    {
759        return Ok(Some(code));
760    }
761    let normalized: String = user_code
762        .chars()
763        .filter(char::is_ascii_alphanumeric)
764        .map(|character| character.to_ascii_uppercase())
765        .collect();
766    if normalized == user_code
767        || normalized.is_empty()
768        || !normalized
769            .bytes()
770            .all(|byte| DEFAULT_USER_CODE_CHARSET.contains(&byte))
771    {
772        return Ok(None);
773    }
774    Ok(ctx
775        .database
776        .get_device_code_by_user_code(&normalized)
777        .await?
778        .filter(|code| code.user_code == normalized))
779}
780
781fn build_verification_uris(
782    verification_uri: Option<&str>,
783    base_url: &str,
784    user_code: &str,
785) -> AuthResult<(String, String)> {
786    let uri = verification_uri
787        .filter(|uri| !uri.is_empty())
788        .unwrap_or("/device");
789    let verification_url = match Url::parse(uri) {
790        Ok(url) => url,
791        Err(_) => Url::parse(base_url)
792            .map_err(|error| AuthError::config(format!("Invalid base URL: {error}")))?
793            .join(uri)
794            .map_err(|error| {
795                AuthError::bad_request(format!("Invalid verification URI: {error}"))
796            })?,
797    };
798
799    let mut verification_uri_complete = verification_url.clone();
800    {
801        let mut pairs = verification_uri_complete.query_pairs_mut();
802        let _ = pairs.clear();
803        let mut replaced = false;
804        for (name, value) in verification_url.query_pairs() {
805            if name == "user_code" {
806                if !replaced {
807                    let _ = pairs.append_pair(&name, user_code);
808                    replaced = true;
809                }
810            } else {
811                let _ = pairs.append_pair(&name, &value);
812            }
813        }
814        if !replaced {
815            let _ = pairs.append_pair("user_code", user_code);
816        }
817    }
818
819    Ok((
820        verification_url.to_string(),
821        verification_uri_complete.to_string(),
822    ))
823}
824
825fn default_generate_user_code(length: usize) -> String {
826    let mut bytes = vec![0u8; length];
827    rand::rngs::OsRng.fill_bytes(&mut bytes);
828    bytes
829        .into_iter()
830        .map(|byte| {
831            let index = usize::from(byte) % DEFAULT_USER_CODE_CHARSET.len();
832            DEFAULT_USER_CODE_CHARSET
833                .get(index)
834                .copied()
835                .unwrap_or(b'A') as char
836        })
837        .collect()
838}
839
840fn device_error_response(
841    status: u16,
842    error: &str,
843    error_description: &str,
844) -> AuthResult<AuthResponse> {
845    AuthResponse::json(
846        status,
847        &DeviceErrorResponse {
848            error: error.to_string(),
849            error_description: error_description.to_string(),
850        },
851    )
852    .map_err(AuthError::from)
853}