fabrica

hanna/fabrica

5936 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! API tokens: the revocation-tracking rows behind the stateless JWTs.
6//!
7//! The JWT itself is never stored. This row exists so a token can be listed and
8//! revoked: every API request validates the JWT signature *and* checks that the
9//! `jti` row is present and `revoked_at IS NULL`, which is what makes revocation
10//! real.
11
12use model::ApiToken;
13use sqlx::any::AnyRow;
14use sqlx::{AssertSqlSafe, Row};
15
16use crate::{Store, StoreError, new_id, now_ms};
17
18/// The columns of `api_tokens` in a fixed order, shared by every `SELECT`.
19const TOKEN_COLUMNS: &str =
20 "id, user_id, name, scopes, expires_at, revoked_at, last_used_at, created_at";
21
22/// Fields needed to mint a token row. The store generates the id, which the caller
23/// then embeds in the JWT as its `jti`.
24#[derive(Debug, Clone)]
25pub struct NewToken {
26 /// Owning user id.
27 pub user_id: String,
28 /// Human label.
29 pub name: String,
30 /// Comma-separated scope list.
31 pub scopes: String,
32 /// Expiry, or `None` for a non-expiring token.
33 pub expires_at: Option<i64>,
34}
35
36impl Store {
37 /// Create a token row and return it; the returned `id` is the JWT `jti` the
38 /// caller signs into the token.
39 ///
40 /// # Errors
41 ///
42 /// Returns [`StoreError::Query`] on a database failure.
43 pub async fn create_token(&self, new: NewToken) -> Result<ApiToken, StoreError> {
44 let token = ApiToken {
45 id: new_id(),
46 user_id: new.user_id,
47 name: new.name,
48 scopes: new.scopes,
49 expires_at: new.expires_at,
50 revoked_at: None,
51 last_used_at: None,
52 created_at: now_ms(),
53 };
54 let sql = "INSERT INTO api_tokens \
55 (id, user_id, name, scopes, expires_at, revoked_at, last_used_at, created_at) \
56 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)";
57 sqlx::query(sql)
58 .bind(token.id.clone())
59 .bind(token.user_id.clone())
60 .bind(token.name.clone())
61 .bind(token.scopes.clone())
62 .bind(token.expires_at)
63 .bind(token.revoked_at)
64 .bind(token.last_used_at)
65 .bind(token.created_at)
66 .execute(&self.pool)
67 .await?;
68 Ok(token)
69 }
70
71 /// List a user's tokens, newest last (ordered by creation), so the 1-based list
72 /// position is stable for `token del <user> <index>`.
73 ///
74 /// # Errors
75 ///
76 /// Returns [`StoreError::Query`] on a database failure.
77 pub async fn tokens_by_user(&self, user_id: &str) -> Result<Vec<ApiToken>, StoreError> {
78 let sql = format!(
79 "SELECT {TOKEN_COLUMNS} FROM api_tokens WHERE user_id = $1 ORDER BY created_at ASC, id ASC"
80 );
81 let rows = sqlx::query(AssertSqlSafe(sql))
82 .bind(user_id)
83 .fetch_all(&self.pool)
84 .await?;
85 rows.iter()
86 .map(map_token)
87 .collect::<Result<_, _>>()
88 .map_err(Into::into)
89 }
90
91 /// Fetch a token row by id (`jti`), or `None`. Used by the API to enforce
92 /// revocation on every request.
93 ///
94 /// # Errors
95 ///
96 /// Returns [`StoreError::Query`] on a database failure.
97 pub async fn token_by_id(&self, id: &str) -> Result<Option<ApiToken>, StoreError> {
98 let sql = format!("SELECT {TOKEN_COLUMNS} FROM api_tokens WHERE id = $1");
99 let row = sqlx::query(AssertSqlSafe(sql))
100 .bind(id)
101 .fetch_optional(&self.pool)
102 .await?;
103 Ok(row.as_ref().map(map_token).transpose()?)
104 }
105
106 /// Mark a token revoked (idempotent). Returns `true` if a row was updated.
107 ///
108 /// # Errors
109 ///
110 /// Returns [`StoreError::Query`] on a database failure.
111 pub async fn revoke_token(&self, id: &str) -> Result<bool, StoreError> {
112 let updated = sqlx::query(
113 "UPDATE api_tokens SET revoked_at = $1 WHERE id = $2 AND revoked_at IS NULL",
114 )
115 .bind(now_ms())
116 .bind(id)
117 .execute(&self.pool)
118 .await?
119 .rows_affected();
120 Ok(updated > 0)
121 }
122
123 /// Delete a token row by id. Returns `true` if a row was removed.
124 ///
125 /// # Errors
126 ///
127 /// Returns [`StoreError::Query`] on a database failure.
128 pub async fn delete_token(&self, id: &str) -> Result<bool, StoreError> {
129 let deleted = sqlx::query("DELETE FROM api_tokens WHERE id = $1")
130 .bind(id)
131 .execute(&self.pool)
132 .await?
133 .rows_affected();
134 Ok(deleted > 0)
135 }
136
137 /// Record that a token was just used, at most once a minute per token (the
138 /// `last_used_at` throttle the spec calls for).
139 ///
140 /// # Errors
141 ///
142 /// Returns [`StoreError::Query`] on a database failure.
143 pub async fn touch_token(&self, id: &str) -> Result<(), StoreError> {
144 let now = now_ms();
145 let minute_ago = now - 60_000;
146 sqlx::query(
147 "UPDATE api_tokens SET last_used_at = $1 \
148 WHERE id = $2 AND (last_used_at IS NULL OR last_used_at < $3)",
149 )
150 .bind(now)
151 .bind(id)
152 .bind(minute_ago)
153 .execute(&self.pool)
154 .await?;
155 Ok(())
156 }
157}
158
159/// Map an `api_tokens` row (selected via [`TOKEN_COLUMNS`]) to an [`ApiToken`].
160fn map_token(row: &AnyRow) -> Result<ApiToken, sqlx::Error> {
161 Ok(ApiToken {
162 id: row.try_get("id")?,
163 user_id: row.try_get("user_id")?,
164 name: row.try_get("name")?,
165 scopes: row.try_get("scopes")?,
166 expires_at: row.try_get("expires_at")?,
167 revoked_at: row.try_get("revoked_at")?,
168 last_used_at: row.try_get("last_used_at")?,
169 created_at: row.try_get("created_at")?,
170 })
171}