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