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
163pub struct DeviceAuthorizationPlugin {
165 config: DeviceAuthorizationConfig,
166}
167
168impl Default for DeviceAuthorizationPlugin {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174impl DeviceAuthorizationPlugin {
175 pub fn new() -> Self {
177 Self {
178 config: DeviceAuthorizationConfig::default(),
179 }
180 }
181
182 pub fn request_fields(mut self, fields: DeviceRequestFields) -> Self {
184 self.config.request_fields = Some(fields);
185 self
186 }
187
188 pub fn expires_in(mut self, duration: Duration) -> Self {
190 self.config.expires_in = duration;
191 self
192 }
193
194 pub fn interval(mut self, duration: Duration) -> Self {
196 self.config.interval = duration;
197 self
198 }
199
200 pub fn device_code_length(mut self, length: usize) -> Self {
202 self.config.device_code_length = length;
203 self
204 }
205
206 pub fn user_code_length(mut self, length: usize) -> Self {
208 self.config.user_code_length = length;
209 self
210 }
211
212 pub fn verification_uri(mut self, uri: impl Into<String>) -> Self {
214 self.config.verification_uri = Some(uri.into());
215 self
216 }
217
218 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 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 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 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 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}