Skip to main content

better_auth_api/plugins/device_authorization/
redemption.rs

1use super::{
2    ACCESS_DENIED, AUTHORIZATION_PENDING, DEVICE_STATUS_APPROVED, DEVICE_STATUS_DENIED,
3    DEVICE_STATUS_PENDING, EXPIRED_DEVICE_CODE, INVALID_DEVICE_CODE, INVALID_DEVICE_CODE_STATUS,
4    POLLING_TOO_FREQUENTLY, USER_NOT_FOUND, device_error_response,
5};
6use crate::plugins::endpoint_context::EndpointContext;
7use better_auth_core::{
8    AuthResult, AuthSchema, DeviceCode, DeviceCodeOwnership, UpdateDeviceCode,
9    store::DeviceCodeStore, wire::UserView,
10};
11use chrono::Utc;
12use std::{future::Future, pin::Pin};
13
14/// A redemption callback that borrows the device code and active endpoint.
15pub type DeviceRedemptionFuture<'a, T> = Pin<Box<dyn Future<Output = AuthResult<T>> + Send + 'a>>;
16
17/// The issuer's ownership predicate and state required before token issuance.
18pub struct DeviceCodeRedemptionAuthorization<A> {
19    /// Additional ownership condition enforced by atomic consumption.
20    pub ownership: DeviceCodeOwnership,
21    /// Issuer state passed to preparation and returned after consumption.
22    pub context: A,
23}
24
25/// A consumed device code and the state needed by its token issuer.
26pub struct DeviceCodeRedemptionResult<A, R> {
27    /// The actual consumed row, including its bound user identifier.
28    pub claimed_device_code: DeviceCode,
29    /// The state returned by the authorization callback.
30    pub authorization_context: A,
31    /// The state returned by the preparation callback.
32    pub redemption_context: R,
33    /// The user projected through the active schema before consumption, including hidden fields.
34    pub user: UserView,
35}
36
37/// Authorize, prepare, and atomically consume an approved device code.
38///
39/// Callbacks cannot bypass polling, expiry, denial, or consumption guards.
40/// Every database operation uses `endpoint.transaction` when present.
41/// Callback errors propagate unchanged; the caller issues credentials only after success.
42/// If the caller uses a transaction, issue credentials only after that transaction commits.
43pub async fn redeem_device_code<S, A, R, Authorize, Prepare>(
44    endpoint: &EndpointContext<'_, S>,
45    code: &str,
46    authorize: Authorize,
47    prepare: Prepare,
48) -> AuthResult<DeviceCodeRedemptionResult<A, R>>
49where
50    S: AuthSchema,
51    A: Send + Sync,
52    R: Send,
53    Authorize: for<'a> FnOnce(
54            &'a DeviceCode,
55            &'a EndpointContext<'_, S>,
56        ) -> DeviceRedemptionFuture<'a, DeviceCodeRedemptionAuthorization<A>>
57        + Send,
58    Prepare: for<'a> FnOnce(
59            &'a DeviceCode,
60            &'a A,
61            &'a EndpointContext<'_, S>,
62        ) -> DeviceRedemptionFuture<'a, R>
63        + Send,
64{
65    let store: &dyn DeviceCodeStore = match endpoint.transaction {
66        Some(transaction) => transaction,
67        None => endpoint.auth.database.as_ref(),
68    };
69    let Some(device_code) = store.get_device_code_by_device_code(code).await? else {
70        return Err(device_error_response(400, "invalid_grant", INVALID_DEVICE_CODE)?.into());
71    };
72    let authorization = authorize(&device_code, endpoint).await?;
73    if let (Some(last_polled_at), Some(polling_interval)) = (
74        device_code.last_polled_at.as_ref(),
75        device_code
76            .polling_interval
77            .filter(|interval| better_auth_core::FieldValue::Number(*interval).is_truthy()),
78    ) {
79        let elapsed = Utc::now().timestamp_millis() as f64 - last_polled_at.milliseconds();
80        if elapsed < polling_interval {
81            return Err(device_error_response(400, "slow_down", POLLING_TOO_FREQUENTLY)?.into());
82        }
83    }
84    let _ = store
85        .update_device_code(
86            &device_code.id,
87            UpdateDeviceCode {
88                last_polled_at: Some(Some(Utc::now().into())),
89                ..Default::default()
90            },
91        )
92        .await?;
93    if device_code.expires_at.milliseconds() < Utc::now().timestamp_millis() as f64 {
94        store.delete_device_code(&device_code.id).await?;
95        return Err(device_error_response(400, "expired_token", EXPIRED_DEVICE_CODE)?.into());
96    }
97    if device_code.status == DEVICE_STATUS_PENDING {
98        return Err(
99            device_error_response(400, "authorization_pending", AUTHORIZATION_PENDING)?.into(),
100        );
101    }
102    if device_code.status == DEVICE_STATUS_DENIED {
103        store.delete_device_code(&device_code.id).await?;
104        return Err(device_error_response(400, "access_denied", ACCESS_DENIED)?.into());
105    }
106    if device_code.status != DEVICE_STATUS_APPROVED {
107        return Err(device_error_response(500, "server_error", INVALID_DEVICE_CODE_STATUS)?.into());
108    }
109    let Some(user_id) = device_code.user_id.as_deref().filter(|id| !id.is_empty()) else {
110        return Err(device_error_response(500, "server_error", INVALID_DEVICE_CODE_STATUS)?.into());
111    };
112    let redemption_context = prepare(&device_code, &authorization.context, endpoint).await?;
113    let user = match endpoint.transaction {
114        Some(transaction) => transaction.get_user_by_id(user_id).await?,
115        None => endpoint.auth.database.get_user_by_id(user_id).await?,
116    };
117    let Some(user) = user else {
118        return Err(device_error_response(500, "server_error", USER_NOT_FOUND)?.into());
119    };
120    let user = endpoint.auth.internal_user_view(&user).await?;
121    let claimed = store
122        .consume_device_code(&device_code, &authorization.ownership)
123        .await?;
124    let Some(claimed_device_code) = claimed.filter(|row| {
125        row.user_id
126            .as_deref()
127            .is_some_and(|user_id| !user_id.is_empty())
128    }) else {
129        return Err(device_error_response(400, "invalid_grant", INVALID_DEVICE_CODE)?.into());
130    };
131    Ok(DeviceCodeRedemptionResult {
132        claimed_device_code,
133        authorization_context: authorization.context,
134        redemption_context,
135        user,
136    })
137}