Skip to main content

renox_core/auth/
device.rs

1//! API tokens owned by a device (a store's kiosk, a till, a sensor) instead
2//! of a user, so it needs no placeholder `users` row.
3//!
4//! The app names its devices with a key of its own (`"kiosk:3"`). A token
5//! made for that key is sent like any API token, as
6//! `Authorization: Bearer <plain>`; the request then carries a [`Device`]
7//! (and no user). Device tokens are stored in `device_tokens` (the `Auth`
8//! module's migration) and have the same abilities and expiry as a user's.
9
10use std::ops::Deref;
11use std::sync::Arc;
12
13use axum::extract::{FromRequestParts, OptionalFromRequestParts};
14use axum::http::StatusCode;
15use axum::http::request::Parts;
16use axum::response::{IntoResponse, Response};
17use serde::Serialize;
18
19use super::tokens::sha256_hex;
20use crate::Result;
21use crate::crypto::{constant_time_eq, random_token};
22use crate::db::{DateTime, Db, now};
23
24/// A stored API token of a device. Only a SHA-256 hash of the secret is stored.
25#[derive(Debug, Clone, Serialize)]
26#[non_exhaustive]
27pub struct DeviceToken {
28    /// The `device_tokens` row id, also what follows the `d` in the plain token.
29    pub id: i64,
30    /// The app's key for the device that owns the token, e.g. `kiosk:3`.
31    pub device: String,
32    /// A label chosen at creation.
33    pub name: String,
34    /// What the token may do ([`Device::can`]); `None` for everything.
35    pub abilities: Option<Vec<String>>,
36    /// When a request last authenticated with it; `None` if never used.
37    pub last_used_at: Option<DateTime>,
38    /// When it stops working; `None` for never.
39    pub expires_at: Option<DateTime>,
40    /// When it was created.
41    pub created_at: Option<DateTime>,
42}
43
44/// A freshly created device token. `plain` is shown once; the device sends
45/// it as `Authorization: Bearer <plain>`.
46#[derive(Debug, Clone, Serialize)]
47#[non_exhaustive]
48pub struct NewDeviceToken {
49    /// The stored token.
50    pub token: DeviceToken,
51    /// The secret as `d<id>|<random>`; not stored, so it can't be shown again.
52    pub plain: String,
53}
54
55const COLUMNS: &str = "id, device, name, abilities, last_used_at, expires_at, created_at";
56
57fn from_row(row: &crate::db::Row) -> std::result::Result<DeviceToken, crate::db::DbError> {
58    let abilities: Option<String> = row.try_get("abilities")?;
59    Ok(DeviceToken {
60        id: row.try_get("id")?,
61        device: row.try_get("device")?,
62        name: row.try_get("name")?,
63        abilities: abilities.and_then(|json| serde_json::from_str(&json).ok()),
64        last_used_at: row.try_get("last_used_at")?,
65        expires_at: row.try_get("expires_at")?,
66        created_at: row.try_get("created_at")?,
67    })
68}
69
70impl DeviceToken {
71    /// Creates a token for `device` (the app's key, e.g. `"kiosk:3"`).
72    /// `abilities` limits it (`Some(&["sales:create"])`, `"*"` allows
73    /// everything); `None` allows everything. It stops working at `expires_at`.
74    ///
75    /// ```
76    /// # use renox::prelude::*;
77    /// # async fn demo(db: Db) -> Result {
78    /// use renox::auth::DeviceToken;
79    /// let made = DeviceToken::create(&db, "kiosk:3", "front desk", Some(&["sales:create"]), None).await?;
80    /// println!("give this to the kiosk once: {}", made.plain);
81    /// # Ok(()) }
82    /// ```
83    pub async fn create(
84        db: &Db,
85        device: &str,
86        name: &str,
87        abilities: Option<&[&str]>,
88        expires_at: Option<DateTime>,
89    ) -> Result<NewDeviceToken> {
90        let secret = random_token();
91        let created = now();
92        let abilities = abilities.map(|list| serde_json::json!(list).to_string());
93        let row = crate::db::sql(format!(
94            "INSERT INTO device_tokens (device, name, abilities, token, expires_at, created_at, updated_at) \
95             VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING {COLUMNS}"
96        ))
97        .bind(device)
98        .bind(name)
99        .bind(abilities)
100        .bind(sha256_hex(&secret))
101        .bind(expires_at)
102        .bind(created)
103        .bind(created)
104        .fetch_one(db)
105        .await?;
106        let token = from_row(&row)?;
107        Ok(NewDeviceToken {
108            plain: format!("d{}|{secret}", token.id),
109            token,
110        })
111    }
112
113    /// The tokens of `device`, newest first.
114    pub async fn for_device(db: &Db, device: &str) -> Result<Vec<DeviceToken>> {
115        let rows = crate::db::sql(format!(
116            "SELECT {COLUMNS} FROM device_tokens WHERE device = ? ORDER BY id DESC"
117        ))
118        .bind(device)
119        .fetch_all(db)
120        .await?;
121        Ok(rows
122            .iter()
123            .map(from_row)
124            .collect::<std::result::Result<_, _>>()?)
125    }
126
127    /// Revokes one token of `device`; returns whether it existed.
128    pub async fn revoke(db: &Db, device: &str, token_id: i64) -> Result<bool> {
129        let done = crate::db::sql("DELETE FROM device_tokens WHERE id = ? AND device = ?")
130            .bind(token_id)
131            .bind(device)
132            .execute(db)
133            .await?;
134        Ok(done > 0)
135    }
136
137    /// Revokes every token of `device` (when it is retired or lost);
138    /// returns how many.
139    pub async fn revoke_all(db: &Db, device: &str) -> Result<u64> {
140        Ok(crate::db::sql("DELETE FROM device_tokens WHERE device = ?")
141            .bind(device)
142            .execute(db)
143            .await?)
144    }
145
146    /// Deletes device tokens that expired more than `grace` ago; returns
147    /// how many. `tokens:prune` (from the `Auth` module) runs it too.
148    pub async fn prune_expired(db: &Db, grace: std::time::Duration) -> Result<u64> {
149        let before = now() - chrono::Duration::from_std(grace).unwrap_or_default();
150        Ok(
151            crate::db::sql("DELETE FROM device_tokens WHERE expires_at < ?")
152                .bind(before)
153                .execute(db)
154                .await?,
155        )
156    }
157}
158
159/// The device a request authenticated as, with `Authorization: Bearer` and a
160/// [`DeviceToken`]. Requests without one get 401; use `Option<Device>`
161/// where a device is optional. A device request has no user, so
162/// [`AuthUser`](crate::AuthUser) answers 401 on it.
163///
164/// ```
165/// # use renox::prelude::*;
166/// use renox::auth::Device;
167/// async fn sale(device: Device) -> Result<String> {
168///     if !device.can("sales:create") {
169///         return Err(Error::Forbidden);
170///     }
171///     Ok(format!("sale at {}", device.key()))
172/// }
173/// ```
174#[derive(Debug, Clone)]
175#[non_exhaustive]
176pub struct Device {
177    key: String,
178    token_id: i64,
179    name: String,
180    abilities: Option<Arc<Vec<String>>>,
181}
182
183impl Device {
184    /// The app's key of the device that owns the token, e.g. `kiosk:3`.
185    pub fn key(&self) -> &str {
186        &self.key
187    }
188
189    /// The id of the token this request used (`DeviceToken::revoke`).
190    pub fn token_id(&self) -> i64 {
191        self.token_id
192    }
193
194    /// The token's label.
195    pub fn name(&self) -> &str {
196        &self.name
197    }
198
199    /// Whether the token may do `ability`; tokens made without a list, and
200    /// `"*"`, may do everything.
201    pub fn can(&self, ability: &str) -> bool {
202        self.abilities
203            .as_ref()
204            .is_none_or(|list| list.iter().any(|a| a == ability || a == "*"))
205    }
206
207    /// The `id` part of a key like `kiosk:3` (`device.id_of("kiosk")` is
208    /// `Some("3")`); `None` for another kind.
209    pub fn id_of(&self, kind: &str) -> Option<&str> {
210        self.key.strip_prefix(kind)?.strip_prefix(':')
211    }
212}
213
214impl Deref for Device {
215    type Target = str;
216
217    fn deref(&self) -> &str {
218        &self.key
219    }
220}
221
222fn unauthenticated() -> Response {
223    let body = serde_json::json!({ "message": "Unauthenticated." });
224    (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response()
225}
226
227pub(super) fn current(extensions: &axum::http::Extensions) -> Option<Device> {
228    extensions
229        .get::<super::CurrentUser>()?
230        .device
231        .as_deref()
232        .cloned()
233}
234
235impl<S: Send + Sync> FromRequestParts<S> for Device {
236    type Rejection = Response;
237
238    async fn from_request_parts(parts: &mut Parts, _: &S) -> std::result::Result<Self, Response> {
239        current(&parts.extensions).ok_or_else(unauthenticated)
240    }
241}
242
243impl<S: Send + Sync> OptionalFromRequestParts<S> for Device {
244    type Rejection = std::convert::Infallible;
245
246    async fn from_request_parts(
247        parts: &mut Parts,
248        _: &S,
249    ) -> std::result::Result<Option<Self>, std::convert::Infallible> {
250        Ok(current(&parts.extensions))
251    }
252}
253
254/// The device behind `Authorization: Bearer d<id>|<secret>`, if the token is
255/// valid.
256pub(super) async fn authenticate(db: &Db, bearer: &str) -> Result<Option<Device>> {
257    let Some((id, secret)) = bearer.strip_prefix('d').and_then(|b| b.split_once('|')) else {
258        return Ok(None);
259    };
260    let Ok(id) = id.parse::<i64>() else {
261        return Ok(None);
262    };
263    let Some(row) = crate::db::sql(
264        "SELECT device, name, token, abilities, expires_at FROM device_tokens WHERE id = ?",
265    )
266    .bind(id)
267    .fetch_optional(db)
268    .await?
269    else {
270        return Ok(None);
271    };
272    let hash: String = row.try_get("token")?;
273    let expires_at: Option<DateTime> = row.try_get("expires_at")?;
274    if !constant_time_eq(&hash, &sha256_hex(secret)) || expires_at.is_some_and(|at| at <= now()) {
275        return Ok(None);
276    }
277    crate::db::sql("UPDATE device_tokens SET last_used_at = ? WHERE id = ?")
278        .bind(now())
279        .bind(id)
280        .execute(db)
281        .await?;
282    let abilities: Option<String> = row.try_get("abilities")?;
283    Ok(Some(Device {
284        key: row.try_get("device")?,
285        token_id: id,
286        name: row.try_get("name")?,
287        abilities: abilities
288            .and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
289            .map(Arc::new),
290    }))
291}
292
293/// Route guard: only requests that authenticated as a device, and, with
294/// `ability`, whose token may do it. See `Routes::require_device`.
295pub(crate) async fn require(
296    ability: Option<Arc<String>>,
297    req: axum::extract::Request,
298    next: axum::middleware::Next,
299) -> Response {
300    let Some(device) = current(req.extensions()) else {
301        return unauthenticated();
302    };
303    if ability.is_some_and(|a| !device.can(&a)) {
304        return crate::Error::Forbidden.into_response();
305    }
306    next.run(req).await
307}