// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! API tokens: the revocation-tracking rows behind the stateless JWTs. //! //! The JWT itself is never stored. This row exists so a token can be listed and //! revoked: every API request validates the JWT signature *and* checks that the //! `jti` row is present and `revoked_at IS NULL`, which is what makes revocation //! real. use model::ApiToken; use sqlx::any::AnyRow; use sqlx::{AssertSqlSafe, Row}; use crate::{Store, StoreError, new_id, now_ms}; /// The columns of `api_tokens` in a fixed order, shared by every `SELECT`. const TOKEN_COLUMNS: &str = "id, user_id, name, scopes, expires_at, revoked_at, last_used_at, created_at"; /// Fields needed to mint a token row. The store generates the id, which the caller /// then embeds in the JWT as its `jti`. #[derive(Debug, Clone)] pub struct NewToken { /// Owning user id. pub user_id: String, /// Human label. pub name: String, /// Comma-separated scope list. pub scopes: String, /// Expiry, or `None` for a non-expiring token. pub expires_at: Option, } impl Store { /// Create a token row and return it; the returned `id` is the JWT `jti` the /// caller signs into the token. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn create_token(&self, new: NewToken) -> Result { let token = ApiToken { id: new_id(), user_id: new.user_id, name: new.name, scopes: new.scopes, expires_at: new.expires_at, revoked_at: None, last_used_at: None, created_at: now_ms(), }; let sql = "INSERT INTO api_tokens \ (id, user_id, name, scopes, expires_at, revoked_at, last_used_at, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"; sqlx::query(sql) .bind(token.id.clone()) .bind(token.user_id.clone()) .bind(token.name.clone()) .bind(token.scopes.clone()) .bind(token.expires_at) .bind(token.revoked_at) .bind(token.last_used_at) .bind(token.created_at) .execute(&self.pool) .await?; Ok(token) } /// List a user's tokens, newest last (ordered by creation), so the 1-based list /// position is stable for `token del `. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn tokens_by_user(&self, user_id: &str) -> Result, StoreError> { let sql = format!( "SELECT {TOKEN_COLUMNS} FROM api_tokens WHERE user_id = $1 ORDER BY created_at ASC, id ASC" ); let rows = sqlx::query(AssertSqlSafe(sql)) .bind(user_id) .fetch_all(&self.pool) .await?; rows.iter() .map(map_token) .collect::>() .map_err(Into::into) } /// Fetch a token row by id (`jti`), or `None`. Used by the API to enforce /// revocation on every request. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn token_by_id(&self, id: &str) -> Result, StoreError> { let sql = format!("SELECT {TOKEN_COLUMNS} FROM api_tokens WHERE id = $1"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(id) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(map_token).transpose()?) } /// Mark a token revoked (idempotent). Returns `true` if a row was updated. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn revoke_token(&self, id: &str) -> Result { let updated = sqlx::query( "UPDATE api_tokens SET revoked_at = $1 WHERE id = $2 AND revoked_at IS NULL", ) .bind(now_ms()) .bind(id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Delete a token row by id. Returns `true` if a row was removed. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_token(&self, id: &str) -> Result { let deleted = sqlx::query("DELETE FROM api_tokens WHERE id = $1") .bind(id) .execute(&self.pool) .await? .rows_affected(); Ok(deleted > 0) } /// Record that a token was just used, at most once a minute per token (the /// `last_used_at` throttle the spec calls for). /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn touch_token(&self, id: &str) -> Result<(), StoreError> { let now = now_ms(); let minute_ago = now - 60_000; sqlx::query( "UPDATE api_tokens SET last_used_at = $1 \ WHERE id = $2 AND (last_used_at IS NULL OR last_used_at < $3)", ) .bind(now) .bind(id) .bind(minute_ago) .execute(&self.pool) .await?; Ok(()) } } /// Map an `api_tokens` row (selected via [`TOKEN_COLUMNS`]) to an [`ApiToken`]. fn map_token(row: &AnyRow) -> Result { Ok(ApiToken { id: row.try_get("id")?, user_id: row.try_get("user_id")?, name: row.try_get("name")?, scopes: row.try_get("scopes")?, expires_at: row.try_get("expires_at")?, revoked_at: row.try_get("revoked_at")?, last_used_at: row.try_get("last_used_at")?, created_at: row.try_get("created_at")?, }) }