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

@@ -40,6 +40,19 @@ pub fn new_session_token() -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// A new API token: `ipx_` and a session's 256 random bits, the prefix so one found in a log
/// or a file says what it opens (#123).
pub fn new_api_token() -> String {
format!("ipx_{}", new_session_token())
}
/// What is kept of an API token. A fast hash is enough: the token is 256 random bits, not a
/// password, so there is nothing to guess from a stolen hash.
pub fn api_token_hash(token: &str) -> String {
use sha2::Digest;
sha2::Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
use std::io::Read;
std::fs::File::open("/dev/urandom")?.read_exact(buf)
@@ -74,6 +87,21 @@ mod tests {
assert!(hash_password("short").is_err());
}
#[test]
fn api_tokens_are_prefixed_and_hash_to_hex() {
let t = new_api_token();
assert!(t.starts_with("ipx_") && t.len() == 68);
let h = api_token_hash(&t);
assert_eq!(h.len(), 64);
assert_eq!(h, api_token_hash(&t), "the same token, the same hash");
assert_ne!(h, api_token_hash(&new_api_token()));
assert_eq!(
api_token_hash("abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"SHA-256"
);
}
#[test]
fn session_tokens_are_long_and_distinct() {
let a = new_session_token();

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(())

View File

@@ -287,6 +287,29 @@ pub mod sessions {
owned_by_user!();
}
/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
pub mod api_tokens {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_tokens")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
/// What its owner called it, to tell one from another when revoking.
#[sea_orm(column_type = "Text")]
pub name: String,
#[sea_orm(unique, column_type = "Text")]
pub hash: String,
pub created: i64,
pub last_used: Option<i64>,
}
owned_by_user!();
}
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
pub mod catalogue {

View File

@@ -38,6 +38,8 @@ pub fn router(state: WebState) -> Router {
Router::new()
.route("/", get(index))
.route("/api/me", get(me).patch(patch_me))
.route("/api/tokens", get(list_tokens).post(add_token))
.route("/api/tokens/{id}", axum::routing::delete(remove_token))
.route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
@@ -100,8 +102,8 @@ pub async fn serve(state: WebState, bind: &str) -> Result<()> {
.context("serving the web ui")
}
/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name,
/// a session cookie, then the shared token (which is the admin).
/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name, an
/// API token, a session cookie, then the shared token (which is the admin).
///
/// Any of them has to survive being put in a cookie: an `<audio src>` request is issued by
/// the browser, and there is no way to attach a header to it.
@@ -144,6 +146,26 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
}
let by_proxy = user.is_some();
// An API token, made by its owner in Settings, for a script or an agent acting as them
// (#123). One that is not known is turned away rather than tried as anything else: a
// script sent with a revoked token should hear so, not fall through to a cookie.
if user.is_none()
&& let Some(bearer) = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
match state.ctx.db.api_token_user(&crate::auth::api_token_hash(bearer.trim())).await {
Ok(Some(u)) => user = Some(u),
Ok(None) => return (StatusCode::UNAUTHORIZED, "unknown or revoked API token").into_response(),
Err(e) => {
tracing::error!(error = %e, "looking up an API token");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
}
// 2. A session cookie from signing in here.
if user.is_none() {
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
@@ -392,6 +414,54 @@ fn last_admin(users: &[crate::db::User], id: i64) -> bool {
admins == [id]
}
/// Your API tokens: names and dates, never the tokens, which are not kept.
async fn list_tokens(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
let tokens: Vec<_> = state
.ctx
.db
.api_tokens(user.id).await?
.iter()
.map(|t| serde_json::json!({ "id": t.id, "name": t.name, "created": t.created, "last_used": t.last_used }))
.collect();
Ok(Json(serde_json::json!(tokens)))
}
#[derive(Deserialize)]
struct NewToken {
name: String,
}
/// Makes an API token that acts as you, and answers with it: the only time it is shown.
async fn add_token(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<NewToken>,
) -> Result<Json<serde_json::Value>, ApiError> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("give the token a name, to know it by when revoking it"));
}
let token = crate::auth::new_api_token();
let id = state.ctx.db.create_api_token(user.id, name, &crate::auth::api_token_hash(&token)).await?;
tracing::info!(user = %user.name, token = name, "API token made");
Ok(Json(serde_json::json!({ "id": id, "name": name, "token": token })))
}
async fn remove_token(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
if !state.ctx.db.delete_api_token(user.id, id).await? {
return Err(ApiError::bad_request(format!("you have no API token with id {id}")));
}
tracing::info!(user = %user.name, id, "API token revoked");
Ok(StatusCode::NO_CONTENT)
}
async fn list_users(
State(state): State<WebState>,
user: crate::db::User,