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
135pub struct DeviceAuthorizationPlugin {
137 config: DeviceAuthorizationConfig,
138}
139
140impl Default for DeviceAuthorizationPlugin {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146impl DeviceAuthorizationPlugin {
147 pub fn new() -> Self {
149 Self {
150 config: DeviceAuthorizationConfig::default(),
151 }
152 }
153
154 pub fn expires_in(mut self, duration: Duration) -> Self {
156 self.config.expires_in = duration;
157 self
158 }
159
160 pub fn interval(mut self, duration: Duration) -> Self {
162 self.config.interval = duration;
163 self
164 }
165
166 pub fn device_code_length(mut self, length: usize) -> Self {
168 self.config.device_code_length = length;
169 self
170 }
171
172 pub fn user_code_length(mut self, length: usize) -> Self {
174 self.config.user_code_length = length;
175 self
176 }
177
178 pub fn verification_uri(mut self, uri: impl Into<String>) -> Self {
180 self.config.verification_uri = Some(uri.into());
181 self
182 }
183
184 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 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 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 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 if device_code.user_id.is_none() && device_code.status == DEVICE_STATUS_PENDING {
434 match ctx.require_session(req).await {
435 Ok((user, _)) => {
436 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 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}