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
134pub struct DeviceAuthorizationPlugin {
136 config: DeviceAuthorizationConfig,
137}
138
139impl Default for DeviceAuthorizationPlugin {
140 fn default() -> Self {
141 Self::new()
142 }
143}
144
145impl DeviceAuthorizationPlugin {
146 pub fn new() -> Self {
148 Self {
149 config: DeviceAuthorizationConfig::default(),
150 }
151 }
152
153 pub fn expires_in(mut self, duration: Duration) -> Self {
155 self.config.expires_in = duration;
156 self
157 }
158
159 pub fn interval(mut self, duration: Duration) -> Self {
161 self.config.interval = duration;
162 self
163 }
164
165 pub fn device_code_length(mut self, length: usize) -> Self {
167 self.config.device_code_length = length;
168 self
169 }
170
171 pub fn user_code_length(mut self, length: usize) -> Self {
173 self.config.user_code_length = length;
174 self
175 }
176
177 pub fn verification_uri(mut self, uri: impl Into<String>) -> Self {
179 self.config.verification_uri = Some(uri.into());
180 self
181 }
182
183 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 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 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 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}