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

@@ -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]);