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:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user