API tokens a person makes for scripts and agents to act as them (#123)

The API took a session cookie, a proxy's word or the shared admin token, so a script or an
agent working for one person had to sign in with their password and carry the cookie, or be
given the admin token. Settings now makes named tokens, ipx_ and 256 random bits, sent as
Authorization: Bearer. A token is its owner and no more. Only its SHA-256 is kept, in the
new api_tokens table, with when it was made and last used; it is shown once and revoked from
the same list. An unknown or revoked one gets a 401 rather than falling through to a cookie.

Cloudflare Access still stands in front of the tunnel, so from outside a token needs an
Access service token beside it; docs/sso.md says how.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 00:15:17 +00:00
parent cc17b1ddab
commit 00bd58ac9d
11 changed files with 259 additions and 3 deletions

View File

@@ -2,7 +2,7 @@
//! per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result};
use crate::entity::{blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
use crate::entity::{api_tokens, blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
use sea_orm::sea_query::{Expr, Func};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
@@ -59,6 +59,7 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(settings::Entity),
schema.create_table_from_entity(blocklists::Entity),
schema.create_table_from_entity(hidden::Entity),
schema.create_table_from_entity(api_tokens::Entity),
] {
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
}
@@ -1685,6 +1686,7 @@ impl Db {
/// The foreign key would take them anyway; this does not rely on it being switched on.
pub async fn delete_user(&self, id: i64) -> Result<()> {
sessions::Entity::delete_many().filter(sessions::Column::UserId.eq(id)).exec(&self.orm).await?;
api_tokens::Entity::delete_many().filter(api_tokens::Column::UserId.eq(id)).exec(&self.orm).await?;
users::Entity::delete_by_id(id).exec(&self.orm).await?;
Ok(())
}
@@ -1737,6 +1739,56 @@ impl Db {
Ok(found.map(User::from))
}
/// Keeps a new API token's hash under its owner; the token itself is shown once and not kept.
pub async fn create_api_token(&self, user_id: i64, name: &str, hash: &str) -> Result<i64> {
let made = api_tokens::ActiveModel {
user_id: Set(user_id),
name: Set(name.to_owned()),
hash: Set(hash.to_owned()),
created: Set(now()),
last_used: Set(None),
..Default::default()
}
.insert(&self.orm)
.await?;
Ok(made.id)
}
/// One person's tokens, newest first: names and dates, as the tokens themselves are not kept.
pub async fn api_tokens(&self, user_id: i64) -> Result<Vec<api_tokens::Model>> {
Ok(api_tokens::Entity::find()
.filter(api_tokens::Column::UserId.eq(user_id))
.order_by_desc(api_tokens::Column::Id)
.all(&self.orm)
.await?)
}
/// Revokes one of this person's tokens; someone else's id does nothing. Whether it was theirs.
pub async fn delete_api_token(&self, user_id: i64, id: i64) -> Result<bool> {
let gone = api_tokens::Entity::delete_many()
.filter(api_tokens::Column::Id.eq(id))
.filter(api_tokens::Column::UserId.eq(user_id))
.exec(&self.orm)
.await?;
Ok(gone.rows_affected > 0)
}
/// The person an API token acts as, noting when it was used, as a session notes `seen`.
pub async fn api_token_user(&self, hash: &str) -> Result<Option<User>> {
let found = api_tokens::Entity::find()
.filter(api_tokens::Column::Hash.eq(hash))
.find_also_related(users::Entity)
.one(&self.orm)
.await?;
let Some((token, Some(user))) = found else { return Ok(None) };
api_tokens::Entity::update_many()
.col_expr(api_tokens::Column::LastUsed, Expr::val(now()).into())
.filter(api_tokens::Column::Id.eq(token.id))
.exec(&self.orm)
.await?;
Ok(Some(User::from(user)))
}
pub async fn delete_session(&self, token: &str) -> Result<()> {
sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
Ok(())