Skip to main content

better_auth_api/plugins/device_authorization/
mod.rs

1use chrono::{Duration, Utc};
2use rand::RngCore;
3use rand::distributions::{Alphanumeric, DistString};
4use std::fmt;
5use std::future::Future;
6use std::pin::Pin;
7use std::sync::Arc;
8use url::Url;
9
10use crate::plugins::helpers::{SessionIssueError, issue_user_session};
11use better_auth_core::entity::{AuthSession, AuthUser};
12use better_auth_core::{
13    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, CreateDeviceCode, RequestMeta,
14    UpdateDeviceCode,
15};
16
17pub(super) mod types;
18
19#[cfg(test)]
20mod tests;
21
22use types::{
23    DeviceActionRequest, DeviceActionResponse, DeviceCodeRequest, DeviceCodeResponse,
24    DeviceErrorResponse, DeviceTokenRequest, DeviceTokenResponse, DeviceVerifyResponse,
25};
26
27const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
28const DEVICE_STATUS_PENDING: &str = "pending";
29const DEVICE_STATUS_APPROVED: &str = "approved";
30const DEVICE_STATUS_DENIED: &str = "denied";
31const DEFAULT_USER_CODE_CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
32
33const INVALID_DEVICE_CODE: &str = "Invalid device code";
34const EXPIRED_DEVICE_CODE: &str = "Device code has expired";
35const EXPIRED_USER_CODE: &str = "User code has expired";
36const AUTHORIZATION_PENDING: &str = "Authorization pending";
37const ACCESS_DENIED: &str = "Access denied";
38const INVALID_USER_CODE: &str = "Invalid user code";
39const DEVICE_CODE_ALREADY_PROCESSED: &str = "Device code already processed";
40const POLLING_TOO_FREQUENTLY: &str = "Polling too frequently";
41const USER_NOT_FOUND: &str = "User not found";
42const FAILED_TO_CREATE_SESSION: &str = "Failed to create session";
43const INVALID_DEVICE_CODE_STATUS: &str = "Invalid device code status";
44const AUTHENTICATION_REQUIRED: &str = "Authentication required";
45const INVALID_CLIENT_ID: &str = "Invalid client ID";
46const CLIENT_ID_MISMATCH: &str = "Client ID mismatch";
47const INVALID_REQUEST: &str = "Invalid request";
48
49type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
50type ValidateClientCallback = dyn Fn(String) -> BoxFuture<AuthResult<bool>> + Send + Sync;
51type DeviceAuthRequestCallback =
52    dyn Fn(String, Option<String>) -> BoxFuture<AuthResult<()>> + Send + Sync;
53type CodeGenerator = dyn Fn() -> String + Send + Sync;
54
55#[derive(Clone)]
56struct DeviceAuthorizationConfig {
57    expires_in: Duration,
58    interval: Duration,
59    device_code_length: usize,
60    user_code_length: usize,
61    generate_device_code: Option<Arc<CodeGenerator>>,
62    generate_user_code: Option<Arc<CodeGenerator>>,
63    validate_client: Option<Arc<ValidateClientCallback>>,
64    on_device_auth_request: Option<Arc<DeviceAuthRequestCallback>>,
65    verification_uri: Option<String>,
66}
67
68impl Default for DeviceAuthorizationConfig {
69    fn default() -> Self {
70        Self {
71            expires_in: Duration::minutes(30),
72            interval: Duration::seconds(5),
73            device_code_length: 40,
74            user_code_length: 8,
75            generate_device_code: None,
76            generate_user_code: None,
77            validate_client: None,
78            on_device_auth_request: None,
79            verification_uri: None,
80        }
81    }
82}
83
84impl fmt::Debug for DeviceAuthorizationConfig {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("DeviceAuthorizationConfig")
87            .field("expires_in", &self.expires_in)
88            .field("interval", &self.interval)
89            .field("device_code_length", &self.device_code_length)
90            .field("user_code_length", &self.user_code_length)
91            .field(
92                "generate_device_code",
93                &self.generate_device_code.as_ref().map(|_| "custom"),
94            )
95            .field(
96                "generate_user_code",
97                &self.generate_user_code.as_ref().map(|_| "custom"),
98            )
99            .field(
100                "validate_client",
101                &self.validate_client.as_ref().map(|_| "custom"),
102            )
103            .field(
104                "on_device_auth_request",
105                &self.on_device_auth_request.as_ref().map(|_| "custom"),
106            )
107            .field("verification_uri", &self.verification_uri)
108            .finish()
109    }
110}
111
112#[derive(Clone, Copy)]
113enum DeviceDecision {
114    Approve,
115    Deny,
116}
117
118impl DeviceDecision {
119    fn status(self) -> &'static str {
120        match self {
121            Self::Approve => DEVICE_STATUS_APPROVED,
122            Self::Deny => DEVICE_STATUS_DENIED,
123        }
124    }
125
126    fn forbidden_message(self) -> &'static str {
127        match self {
128            Self::Approve => "You are not authorized to approve this device authorization",
129            Self::Deny => "You are not authorized to deny this device authorization",
130        }
131    }
132}
133
134/// OAuth 2.0 device authorization grant plugin.
135pub struct DeviceAuthorizationPlugin {
136    config: DeviceAuthorizationConfig,
137}
138
139impl Default for DeviceAuthorizationPlugin {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl DeviceAuthorizationPlugin {
146    /// Create the plugin with TS-aligned defaults.
147    pub fn new() -> Self {
148        Self {
149            config: DeviceAuthorizationConfig::default(),
150        }
151    }
152
153    /// Override the device-code expiration window.
154    pub fn expires_in(mut self, duration: Duration) -> Self {
155        self.config.expires_in = duration;
156        self
157    }
158
159    /// Override the minimum polling interval enforced by `/device/token`.
160    pub fn interval(mut self, duration: Duration) -> Self {
161        self.config.interval = duration;
162        self
163    }
164
165    /// Override the generated device-code length.
166    pub fn device_code_length(mut self, length: usize) -> Self {
167        self.config.device_code_length = length;
168        self
169    }
170
171    /// Override the generated user-code length.
172    pub fn user_code_length(mut self, length: usize) -> Self {
173        self.config.user_code_length = length;
174        self
175    }
176
177    /// Override the verification page URI returned to devices.
178    pub fn verification_uri(mut self, uri: impl Into<String>) -> Self {
179        self.config.verification_uri = Some(uri.into());
180        self
181    }
182
183    /// Use a custom device-code generator.
184    pub fn generate_device_code_with<F>(mut self, generator: F) -> Self
185    where
186        F: Fn() -> String + Send + Sync + 'static,
187    {
188        self.config.generate_device_code = Some(Arc::new(generator));
189        self
190    }
191
192    /// Use a custom user-code generator.
193    pub fn generate_user_code_with<F>(mut self, generator: F) -> Self
194    where
195        F: Fn() -> String + Send + Sync + 'static,
196    {
197        self.config.generate_user_code = Some(Arc::new(generator));
198        self
199    }
200
201    /// Validate the OAuth client identifier before issuing or redeeming codes.
202    pub fn validate_client<F, Fut>(mut self, callback: F) -> Self
203    where
204        F: Fn(String) -> Fut + Send + Sync + 'static,
205        Fut: Future<Output = AuthResult<bool>> + Send + 'static,
206    {
207        self.config.validate_client =
208            Some(Arc::new(move |client_id| Box::pin(callback(client_id))));
209        self
210    }
211
212    /// Run a hook when a device authorization request is created.
213    pub fn on_device_auth_request<F, Fut>(mut self, callback: F) -> Self
214    where
215        F: Fn(String, Option<String>) -> Fut + Send + Sync + 'static,
216        Fut: Future<Output = AuthResult<()>> + Send + 'static,
217    {
218        self.config.on_device_auth_request = Some(Arc::new(move |client_id, scope| {
219            Box::pin(callback(client_id, scope))
220        }));
221        self
222    }
223
224    async fn handle_device_code(
225        &self,
226        req: &AuthRequest,
227        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
228    ) -> AuthResult<AuthResponse> {
229        let body: DeviceCodeRequest = match better_auth_core::validate_request_body(req) {
230            Ok(value) => value,
231            Err(response) => return Ok(response),
232        };
233
234        if !self.validate_client_id(&body.client_id).await? {
235            return device_error_response(400, "invalid_client", INVALID_CLIENT_ID);
236        }
237
238        if let Some(callback) = &self.config.on_device_auth_request {
239            callback(body.client_id.clone(), body.scope.clone()).await?;
240        }
241
242        let device_code = self.generate_device_code();
243        let user_code = self.generate_user_code();
244        let expires_at = Utc::now() + self.config.expires_in;
245        let polling_interval = self.config.interval.num_milliseconds();
246
247        let _ = ctx
248            .database
249            .create_device_code(CreateDeviceCode {
250                device_code: device_code.clone(),
251                user_code: user_code.clone(),
252                user_id: None,
253                expires_at,
254                status: DEVICE_STATUS_PENDING.to_string(),
255                last_polled_at: None,
256                polling_interval: Some(polling_interval),
257                client_id: Some(body.client_id.clone()),
258                scope: body.scope.clone(),
259            })
260            .await?;
261
262        let (verification_uri, verification_uri_complete) = build_verification_uris(
263            self.config.verification_uri.as_deref(),
264            &ctx.config.base_url,
265            &user_code,
266        )?;
267
268        Ok(AuthResponse::json(
269            200,
270            &DeviceCodeResponse {
271                device_code,
272                user_code,
273                verification_uri,
274                verification_uri_complete,
275                expires_in: self.config.expires_in.num_seconds(),
276                interval: self.config.interval.num_seconds(),
277            },
278        )?
279        .with_header("Cache-Control", "no-store"))
280    }
281
282    async fn handle_device_token(
283        &self,
284        req: &AuthRequest,
285        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
286    ) -> AuthResult<AuthResponse> {
287        let body: DeviceTokenRequest = match better_auth_core::validate_request_body(req) {
288            Ok(value) => value,
289            Err(response) => return Ok(response),
290        };
291
292        if body.grant_type != DEVICE_GRANT_TYPE {
293            return device_error_response(400, "invalid_request", INVALID_REQUEST);
294        }
295
296        if !self.validate_client_id(&body.client_id).await? {
297            return device_error_response(400, "invalid_grant", INVALID_CLIENT_ID);
298        }
299
300        let Some(device_code) = ctx
301            .database
302            .get_device_code_by_device_code(&body.device_code)
303            .await?
304        else {
305            return device_error_response(400, "invalid_grant", INVALID_DEVICE_CODE);
306        };
307
308        if let Some(client_id) = device_code.client_id.as_deref()
309            && client_id != body.client_id
310        {
311            return device_error_response(400, "invalid_grant", CLIENT_ID_MISMATCH);
312        }
313
314        let now = Utc::now();
315        if let (Some(last_polled_at), Some(polling_interval)) =
316            (device_code.last_polled_at, device_code.polling_interval)
317        {
318            let elapsed = now.signed_duration_since(last_polled_at).num_milliseconds();
319            if elapsed < polling_interval {
320                return device_error_response(400, "slow_down", POLLING_TOO_FREQUENTLY);
321            }
322        }
323
324        let _ = ctx
325            .database
326            .update_device_code(
327                &device_code.id,
328                UpdateDeviceCode {
329                    last_polled_at: Some(Some(now)),
330                    ..Default::default()
331                },
332            )
333            .await?;
334
335        if device_code.expires_at < now {
336            ctx.database.delete_device_code(&device_code.id).await?;
337            return device_error_response(400, "expired_token", EXPIRED_DEVICE_CODE);
338        }
339
340        if device_code.status == DEVICE_STATUS_PENDING {
341            return device_error_response(400, "authorization_pending", AUTHORIZATION_PENDING);
342        }
343
344        if device_code.status == DEVICE_STATUS_DENIED {
345            ctx.database.delete_device_code(&device_code.id).await?;
346            return device_error_response(400, "access_denied", ACCESS_DENIED);
347        }
348
349        if device_code.status == DEVICE_STATUS_APPROVED {
350            let Some(user_id) = device_code.user_id.as_deref() else {
351                return device_error_response(500, "server_error", INVALID_DEVICE_CODE_STATUS);
352            };
353
354            let Some(user) = ctx.database.get_user_by_id(user_id).await? else {
355                return device_error_response(500, "server_error", USER_NOT_FOUND);
356            };
357
358            if !ctx
359                .database
360                .delete_device_code_if_status(&device_code.id, DEVICE_STATUS_APPROVED)
361                .await?
362            {
363                return device_error_response(400, "invalid_grant", INVALID_DEVICE_CODE);
364            }
365
366            let meta = RequestMeta::from_request(req);
367            let session =
368                match issue_user_session(ctx, &user.id(), meta.ip_address, meta.user_agent)
369                    .await
370                    .map_err(SessionIssueError::into_auth_error)
371                {
372                    Ok(issued) => issued.session,
373                    Err(error) => {
374                        tracing::error!(
375                            error = %error,
376                            device_code_id = %device_code.id,
377                            user_id,
378                            "failed to create session after device code redemption"
379                        );
380                        return device_error_response(
381                            500,
382                            "server_error",
383                            FAILED_TO_CREATE_SESSION,
384                        );
385                    }
386                };
387
388            return Ok(AuthResponse::json(
389                200,
390                &DeviceTokenResponse {
391                    access_token: session.token().to_string(),
392                    token_type: "Bearer",
393                    expires_in: (session.expires_at().timestamp_millis()
394                        - Utc::now().timestamp_millis())
395                    .div_euclid(1000)
396                    .max(0),
397                    scope: device_code.scope.unwrap_or_default(),
398                },
399            )?
400            .with_header("Cache-Control", "no-store")
401            .with_header("Pragma", "no-cache"));
402        }
403
404        device_error_response(500, "server_error", INVALID_DEVICE_CODE_STATUS)
405    }
406
407    async fn handle_device_verify(
408        &self,
409        req: &AuthRequest,
410        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
411    ) -> AuthResult<AuthResponse> {
412        let Some(user_code) = req.query.get("user_code").cloned() else {
413            return device_error_response(400, "invalid_request", INVALID_REQUEST);
414        };
415
416        let clean_user_code = user_code.replace('-', "");
417        let Some(device_code) = ctx
418            .database
419            .get_device_code_by_user_code(&clean_user_code)
420            .await?
421        else {
422            return device_error_response(400, "invalid_request", INVALID_USER_CODE);
423        };
424
425        if device_code.expires_at < Utc::now() {
426            return device_error_response(400, "expired_token", EXPIRED_USER_CODE);
427        }
428
429        AuthResponse::json(
430            200,
431            &DeviceVerifyResponse {
432                user_code,
433                status: device_code.status,
434            },
435        )
436        .map_err(AuthError::from)
437    }
438
439    async fn handle_device_approve(
440        &self,
441        req: &AuthRequest,
442        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
443    ) -> AuthResult<AuthResponse> {
444        self.handle_device_decision(req, ctx, DeviceDecision::Approve)
445            .await
446    }
447
448    async fn handle_device_deny(
449        &self,
450        req: &AuthRequest,
451        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
452    ) -> AuthResult<AuthResponse> {
453        self.handle_device_decision(req, ctx, DeviceDecision::Deny)
454            .await
455    }
456
457    async fn handle_device_decision(
458        &self,
459        req: &AuthRequest,
460        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
461        decision: DeviceDecision,
462    ) -> AuthResult<AuthResponse> {
463        let user = match ctx.require_session(req).await {
464            Ok((user, _session)) => user,
465            Err(AuthError::Unauthenticated) | Err(AuthError::SessionNotFound) => {
466                return device_error_response(401, "unauthorized", AUTHENTICATION_REQUIRED);
467            }
468            Err(error) => return Err(error),
469        };
470
471        let current_user_id = user.id().into_owned();
472        let body: DeviceActionRequest = match better_auth_core::validate_request_body(req) {
473            Ok(value) => value,
474            Err(response) => return Ok(response),
475        };
476
477        let clean_user_code = body.user_code.replace('-', "");
478        let Some(device_code) = ctx
479            .database
480            .get_device_code_by_user_code(&clean_user_code)
481            .await?
482        else {
483            return device_error_response(400, "invalid_request", INVALID_USER_CODE);
484        };
485
486        if device_code.expires_at < Utc::now() {
487            return device_error_response(400, "expired_token", EXPIRED_USER_CODE);
488        }
489
490        if device_code.status != DEVICE_STATUS_PENDING {
491            return device_error_response(400, "invalid_request", DEVICE_CODE_ALREADY_PROCESSED);
492        }
493
494        if let Some(user_id) = device_code.user_id.as_deref()
495            && user_id != current_user_id
496        {
497            return device_error_response(403, "access_denied", decision.forbidden_message());
498        }
499
500        let updated_user_id = match decision {
501            DeviceDecision::Approve => current_user_id.clone(),
502            DeviceDecision::Deny => device_code
503                .user_id
504                .clone()
505                .unwrap_or_else(|| current_user_id.clone()),
506        };
507
508        let updated = ctx
509            .database
510            .update_device_code_if_status(
511                &device_code.id,
512                DEVICE_STATUS_PENDING,
513                UpdateDeviceCode {
514                    status: Some(decision.status().to_string()),
515                    user_id: Some(Some(updated_user_id)),
516                    ..Default::default()
517                },
518            )
519            .await?;
520
521        if !updated {
522            return device_error_response(400, "invalid_request", DEVICE_CODE_ALREADY_PROCESSED);
523        }
524
525        AuthResponse::json(200, &DeviceActionResponse { success: true }).map_err(AuthError::from)
526    }
527
528    async fn validate_client_id(&self, client_id: &str) -> AuthResult<bool> {
529        match &self.config.validate_client {
530            Some(callback) => callback(client_id.to_string()).await,
531            None => Ok(true),
532        }
533    }
534
535    fn generate_device_code(&self) -> String {
536        self.config
537            .generate_device_code
538            .as_ref()
539            .map(|generator| generator())
540            .unwrap_or_else(|| {
541                Alphanumeric.sample_string(&mut rand::rngs::OsRng, self.config.device_code_length)
542            })
543    }
544
545    fn generate_user_code(&self) -> String {
546        self.config
547            .generate_user_code
548            .as_ref()
549            .map(|generator| generator())
550            .unwrap_or_else(|| default_generate_user_code(self.config.user_code_length))
551    }
552}
553
554better_auth_core::impl_auth_plugin! {
555    DeviceAuthorizationPlugin, "device-authorization";
556    routes {
557        post "/device/code" => handle_device_code, "device_code";
558        post "/device/token" => handle_device_token, "device_token";
559        get "/device" => handle_device_verify, "device_verify";
560        post "/device/approve" => handle_device_approve, "device_approve";
561        post "/device/deny" => handle_device_deny, "device_deny";
562    }
563}
564
565fn build_verification_uris(
566    verification_uri: Option<&str>,
567    base_url: &str,
568    user_code: &str,
569) -> AuthResult<(String, String)> {
570    let uri = verification_uri.unwrap_or("/device");
571    let verification_url = match Url::parse(uri) {
572        Ok(url) => url,
573        Err(_) => Url::parse(base_url)
574            .map_err(|error| AuthError::config(format!("Invalid base URL: {error}")))?
575            .join(uri)
576            .map_err(|error| {
577                AuthError::bad_request(format!("Invalid verification URI: {error}"))
578            })?,
579    };
580
581    let mut verification_uri_complete = verification_url.clone();
582    let _ = verification_uri_complete
583        .query_pairs_mut()
584        .append_pair("user_code", user_code);
585
586    Ok((
587        verification_url.to_string(),
588        verification_uri_complete.to_string(),
589    ))
590}
591
592fn default_generate_user_code(length: usize) -> String {
593    let mut bytes = vec![0u8; length];
594    rand::rngs::OsRng.fill_bytes(&mut bytes);
595    bytes
596        .into_iter()
597        .map(|byte| {
598            let index = usize::from(byte) % DEFAULT_USER_CODE_CHARSET.len();
599            DEFAULT_USER_CODE_CHARSET
600                .get(index)
601                .copied()
602                .unwrap_or(b'A') as char
603        })
604        .collect()
605}
606
607fn device_error_response(
608    status: u16,
609    error: &str,
610    error_description: &str,
611) -> AuthResult<AuthResponse> {
612    AuthResponse::json(
613        status,
614        &DeviceErrorResponse {
615            error: error.to_string(),
616            error_description: error_description.to_string(),
617        },
618    )
619    .map_err(AuthError::from)
620}