per-user api keys, resolved at both identity doors

a user can mint a long-lived key for an app or a device instead of
carrying a 30-day session, so multiple logins on the mobile apps are
per-device revocable rather than one shared token.

identity was being decided independently in userMiddleware and
originScopeMiddleware, each verifying the token itself. teaching only
one of them a new credential format is how those two stop agreeing, so
both now call resolveAuthToken and neither knows what a bearer string
is. verified: a member's key returns the same status as their jwt on
every route tried, 403s included.

a key carries its holder's full authority — not an escalation, it
equals what the password could already do. scoping wants a scopes
column, not a change here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 10:10:14 +00:00
co-authored by Claude Opus 5
parent b077671f19
commit 00997f5ff1
13 changed files with 391 additions and 30 deletions
+62
View File
@@ -0,0 +1,62 @@
import { createRouter } from '../../create-router';
import { createApiKey, listApiKeys, revokeApiKey } from 'officerdb';
import { mintApiKey } from '../../auth-token';
import * as errors from '../../custom-errors';
// A user's own API keys: mint, list, revoke. Every route acts on `ctx.get('user').id` and nothing accepts
// a userId from the caller, so there is no shape of request here that reaches another account's keys.
//
// The plaintext secret exists for exactly one response. There is deliberately no "show it to me again"
// endpoint — if a key is lost, revoke it and mint another. Same reasoning as the DAV app passwords beside
// this, and the same reason the column holds a hash.
//
// A key currently carries its holder's FULL authority: it resolves to the user, and authorization then
// asks the same questions of that user it would have asked of a browser session. That is not an
// escalation — it can do exactly what the password could already do — but it does mean a leaked key is a
// leaked account, which is why revocation is one call and last-used is recorded. Narrowing a key to a
// subset of its holder's capabilities is the next step and wants a `scopes` column, not a change here.
export const apiKeysRouter = createRouter();
const MAX_NAME_LENGTH = 100;
apiKeysRouter.get('/', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ keys: await listApiKeys(user.id) });
});
apiKeysRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { name?: string; expiresInDays?: number } | undefined;
const name = body?.name?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
if (name.length > MAX_NAME_LENGTH) throw errors.BAD_REQUEST(`name must be ${MAX_NAME_LENGTH} characters or fewer`);
// Absent means "does not expire" — the common case is a key living in a phone the owner keeps. An
// expiry, when asked for, is enforced in SQL during lookup rather than by anything that has to run.
let expiresAt: Date | null = null;
const days = body?.expiresInDays;
if (days !== undefined && days !== null) {
if (!Number.isInteger(days) || days < 1) throw errors.BAD_REQUEST('expiresInDays must be a positive integer');
expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
const { secret, keyHash, prefix } = mintApiKey();
const entry = await createApiKey({ userId: user.id, name, keyHash, prefix, expiresAt });
const { keyHash: _hash, ...safe } = entry;
// `key` is returned here and nowhere else. Nothing in the system can produce it again.
return ctx.json({ entry: safe, key: secret });
});
apiKeysRouter.delete('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
// Scoped by user inside the statement: naming someone else's key id revokes nothing and is answered
// with the same 404 as an id that never existed.
if (!(await revokeApiKey(id, user.id))) throw errors.NOT_FOUND('no such api key');
return ctx.json({ ok: true });
});