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

@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The Directory browses as Apple's does: a row of categories, and once one is picked, a row of its subcategories (Tech News under News, Video Games under Leisure), instead of one row mixing both.
- A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin.
- The Directory can be sorted by name, A to Z or Z to A, or by most subscribers. It still opens A to Z.
- API tokens: in Settings, make a named token that lets a script or an agent use iPX as you, sent as `Authorization: Bearer`, and revoke it there. Each shows when it was last used.
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.

1
Cargo.lock generated
View File

@@ -1843,6 +1843,7 @@ dependencies = [
"sea-orm",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"toml",
"tower",

View File

@@ -25,6 +25,7 @@ rss = "2.1.1"
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.10.9"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
toml = "1.1.5"
tower = { version = "0.5.3", features = ["util"] }

View File

@@ -233,5 +233,19 @@ Set `auto_create_users = false` once everyone who should have an account has one
proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
the exact name the header will carry.
## API tokens for scripts and agents
Anyone can make API tokens for their own account in Settings. A token is sent as
`Authorization: Bearer ipx_...` and acts as the person who made it, admin only if they are. Only
its SHA-256 is kept; it is shown once, when made, and revoked there too.
Through the tunnel, Cloudflare Access turns a request with no Access sign-in towards Authentik
before ipx sees it, so a token alone does not get in that way. On the LAN, `http://192.168.1.130:8099`
takes it directly. From outside, make an Access service token, add a Service Auth policy for it to
the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client-Secret` beside the
`Authorization` header: Access lets the request through, vouches for no name, and ipx takes the
API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the
only thing between the internet and the API.
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.

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,

View File

@@ -1181,6 +1181,33 @@ test('keys move through items and places, after Feedly', async ({ page }) => {
await expect(page.locator('#count')).toContainText('All Subscriptions');
});
test('an API token acts as its owner until it is revoked', async ({ page, request }) => {
// Made in Settings, shown once.
await page.locator('#prefs').click();
await page.locator('#tokname').fill('test agent');
await page.locator('#tokadd').click();
const token = await page.locator('#tokval').inputValue();
expect(token).toMatch(/^ipx_[0-9a-f]{64}$/);
await expect(page.locator('.tokrow', { hasText: 'test agent' })).toContainText('last used never');
// Sent as a Bearer header, with no cookie, it is the person who made it.
const as = t => ({ headers: { Authorization: `Bearer ${t}` } });
const me = await (await request.get('/api/me', as(token))).json();
expect(me.name).toBe(await page.evaluate(() => S.me.name));
expect((await request.get('/api/feeds', as(token))).status()).toBe(200);
// A wrong one is turned away, not let through as anyone.
expect((await request.get('/api/me', as('ipx_' + '0'.repeat(64)))).status()).toBe(401);
// Only the hash is kept: the list never carries the token.
const listed = await page.evaluate(() => api('/api/tokens'));
expect(JSON.stringify(listed)).not.toContain(token);
expect(listed.find(t => t.name === 'test agent').last_used).toBeTruthy();
// Revoked, it opens nothing.
await page.locator('.tokrow', { hasText: 'test agent' }).locator('[data-tok]').click();
await expect(page.locator('.tokrow', { hasText: 'test agent' })).toHaveCount(0);
expect((await request.get('/api/me', as(token))).status()).toBe(401);
});
test('an admin can give a blog its Directory category', async ({ page }) => {
const patch = (id, category) => page.evaluate(([id, category]) =>
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);

View File

@@ -952,6 +952,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.inline{display:flex;gap:6px;align-items:stretch}
.inline input{flex:1;min-width:0}
.inline .btn{white-space:nowrap;flex:none}
/* An API token in Settings: its name and dates, and the button that revokes it. */
.tokrow{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
.tokrow .hint{display:block}
.inline select{flex:none;width:auto}
.inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)}

View File

@@ -256,12 +256,35 @@ async function prefsModal(){
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div>
<div class="field"><label for="tokname">API tokens</label>
<div id="tokens"></div>
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
<button class="btn" id="tokadd" title="Make an API token" aria-label="Make an API token">${ICON.plus} Make</button></div>
<span class="hint">A token lets a script or an agent use iPX as you, adding and removing
your feeds and reading your items: it sends <code>Authorization: Bearer</code> and the token.
Anyone holding one is you here, so revoke one you no longer use.</span></div>
<div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal;
drawTokens();
$('#tokadd').onclick=async()=>{
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
try{
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
$('#tokname').value='';
await drawTokens();
// Shown this once: only its hash is kept, so a lost token is revoked and made again.
$('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token,
${esc(t.name)}. Copy it now: it is not shown again.</span>
<div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}">
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy'));
$('#tokval').select();
}catch(err){ toast(err.message,true); }
};
$('#sblock').onchange=async e=>{
const blocked=splitWords(e.target.value);
try{
@@ -272,6 +295,19 @@ async function prefsModal(){
};
}
/// Your API tokens, each with when it was made and last used, and a button to revoke it.
async function drawTokens(){
const box=$('#tokens'); if(!box) return;
const list=await api('/api/tokens').catch(()=>[]);
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
for(const b of $$('[data-tok]',box)) b.onclick=async()=>{
try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); }
catch(err){ toast(err.message,true); }
};
}
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
function settingsModal(f, newUrl?: string){