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
+1 -1
View File
@@ -608,4 +608,4 @@ a separate job.
`border-box`, percentage heights resolve against the content box, so there is no overflow. I did not
change it.
- **Mobile edit-in-invisible-panel** (dashboards) is unrelated to this work and still open; it is
recorded in `nav-test-checklist.md` in the workspace root.
recorded under "Known and deliberately unfixed" in the workspace-root `CLAUDE.md`.
+9
View File
@@ -19,6 +19,15 @@ export {
cleanupExpiredTokens,
} from './queries/auth';
export {
findLiveApiKeyByHash,
createApiKey,
listApiKeys,
revokeApiKey,
touchApiKey,
type ApiKeyIdentity,
} from './queries/api-keys';
export { readServerSettings, writeServerSettings, readConfigValue, writeConfigValue } from './queries/server-config';
export {
@@ -0,0 +1,113 @@
import { eq, and, isNull, sql } from 'drizzle-orm';
import { db } from '../db';
import { apiKeys, users } from '../schema';
import type { ApiKeySelect } from '../types';
// Every read here is scoped by userId except `findLiveApiKeyByHash`, which cannot be: authentication is
// the one call that does not yet know who is asking. That is what the unique index on key_hash is for.
/** What the resolver needs to turn a key into a caller. Joined so authentication is a single round trip. */
export type ApiKeyIdentity = {
keyId: number;
userId: number;
email: string;
username: string | null;
lastUsedAt: Date | null;
};
/**
* Look a key up by the hash of its secret, for authentication.
*
* Filters revoked and expired rows in SQL rather than returning them for the caller to check — a
* forgotten check at a call site is an accepted credential, so there is deliberately no way to ask this
* function for a dead key.
*/
export async function findLiveApiKeyByHash(keyHash: string): Promise<ApiKeyIdentity | null> {
const [row] = await db
.select({
keyId: apiKeys.id,
userId: apiKeys.userId,
email: users.email,
username: users.username,
lastUsedAt: apiKeys.lastUsedAt,
})
.from(apiKeys)
.innerJoin(users, eq(users.id, apiKeys.userId))
.where(
and(
eq(apiKeys.keyHash, keyHash),
isNull(apiKeys.revokedAt),
sql`(${apiKeys.expiresAt} IS NULL OR ${apiKeys.expiresAt} > now())`,
),
)
.limit(1);
return row ?? null;
}
type CreateApiKeyParams = {
userId: number;
name: string;
keyHash: string;
prefix: string;
expiresAt?: Date | null;
};
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
const [row] = await db
.insert(apiKeys)
.values({
userId: params.userId,
name: params.name,
keyHash: params.keyHash,
prefix: params.prefix,
expiresAt: params.expiresAt ?? null,
})
.returning();
return row!;
}
/** The caller's own keys, newest first. Never returns key_hash — nothing outside authentication needs it. */
export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'keyHash'>[]> {
return db
.select({
id: apiKeys.id,
userId: apiKeys.userId,
name: apiKeys.name,
prefix: apiKeys.prefix,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
revokedAt: apiKeys.revokedAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId))
.orderBy(sql`${apiKeys.createdAt} desc`);
}
/**
* Revoke one of the caller's own keys. Scoped by userId in the same statement as the id, so a caller
* naming someone else's key id revokes nothing and is told the same "not found" as for an id that never
* existed — the row count, not a separate ownership read that could drift from this one.
*/
export async function revokeApiKey(id: number, userId: number): Promise<boolean> {
const rows = await db
.update(apiKeys)
.set({ revokedAt: new Date() })
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt)))
.returning({ id: apiKeys.id });
return rows.length > 0;
}
/** How stale last_used_at is allowed to get. A write per request is not worth this column's value. */
const TOUCH_DEBOUNCE_MS = 60_000;
/**
* Record that a key was used, at most once a minute per key.
*
* Callers pass the `lastUsedAt` they already read during authentication, so the common case costs no
* extra query — and this deliberately does not await anything the request depends on.
*/
export async function touchApiKey(id: number, lastUsedAt: Date | null): Promise<void> {
if (lastUsedAt && Date.now() - lastUsedAt.getTime() < TOUCH_DEBOUNCE_MS) return;
await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, id));
}
@@ -0,0 +1,53 @@
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
// Long-lived credentials a user mints for themselves, so a native app can hold one instead of a password.
//
// The problem they solve: the mobile apps sign in and get a 30-day JWT, which means every app on every
// device is the same credential with the same expiry, and signing out of one is signing out of all of
// them. A key is per-app and per-device, revocable on its own, and does not expire underneath someone.
//
// ── What is stored ──
//
// `keyHash` is a SHA-256 of the secret and the secret itself is never written down — it is returned once,
// at creation, and is unrecoverable afterwards. SHA-256 rather than argon2 (which every password here
// uses) because the input is 32 bytes from a CSPRNG, not something a human chose: there is no dictionary
// to attack and nothing for a slow hash to buy, while a slow hash WOULD force a full table scan on every
// request, since we look a key up by its hash rather than by knowing whose it is. Same reasoning as
// GitHub's and Stripe's.
//
// `prefix` is the first few characters of the secret, kept in the clear purely so the UI can show which
// key is which. It is not enough to authenticate with.
//
// ── Authority ──
//
// A key currently carries its owner's full authority — deliberately, as the first cut. That is not a
// hole: it is exactly what the password that made it could already do, so a key is never an escalation.
// A `scopes` column is the intended next step, and the resolver is written so adding one touches the
// resolver and this table, nothing else.
export const apiKeys = pgTable(
'api_keys',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the user called it — "iPhone", "Music app on the iPad". Theirs to choose, shown back in lists. */
name: text('name').notNull(),
/** SHA-256 of the secret, hex. The lookup key: unique, so a collision is a constraint error not a mix-up. */
keyHash: text('key_hash').notNull(),
/** First characters of the secret, in the clear, for display only. */
prefix: text('prefix').notNull(),
/**
* Updated on use, but at most once a minute — see touchApiKey. Without the debounce every
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".
*/
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
/** Null means it does not expire. Set at creation and never edited. */
expiresAt: timestamp('expires_at', { withTimezone: true }),
/** Set instead of deleting the row, so a revoked key's name and last use survive for the audit. */
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_api_keys_key_hash').on(table.keyHash), index('idx_api_keys_user').on(table.userId)],
);
@@ -1,4 +1,5 @@
export * from './agent-panels';
export * from './api-keys';
export * from './auth';
export * from './capabilities';
export * from './chat-events';
+5 -1
View File
@@ -36,7 +36,6 @@ export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
// ── Saved Sessions ──
// ── Dashboards ──
export type DashboardSelect = typeof Schema.dashboards.$inferSelect;
@@ -77,3 +76,8 @@ export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert;
// Notifications
export type PushDeviceSelect = typeof Schema.pushDevices.$inferSelect;
export type PushDeviceInsert = typeof Schema.pushDevices.$inferInsert;
// ── API keys ──
export type ApiKeySelect = typeof Schema.apiKeys.$inferSelect;
export type ApiKeyInsert = typeof Schema.apiKeys.$inferInsert;
+5 -10
View File
@@ -1,7 +1,7 @@
import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { IS_DEV_BUILD } from '../build-env';
import { verify } from '../jwt';
import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin';
import { isApiRequestAllowed } from '../capabilities/authorize';
import { isExemptApiPath } from '../capabilities/totality';
@@ -185,17 +185,12 @@ export const originScopeMiddleware: MiddlewareHandler = async function (ctx, nex
const path = ctx.req.path;
const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));
// Verify the caller's token once (if any); a missing/invalid token stays null.
// Resolve the caller once (if any); a missing/invalid/revoked credential stays null. This goes through
// the shared resolver rather than verifying a JWT here, so an API key is the same caller at this door as
// it is at userMiddleware — the two must never disagree about who someone is. See auth-token.ts.
const authorization = ctx.req.header('authorization');
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
let payload: { id?: number } | null = null;
if (token) {
try {
payload = await verify(token);
} catch {
payload = null;
}
}
const payload = token ? await resolveAuthToken(token) : null;
const isOwner = payload ? await isSuperAdmin(payload) : false;
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
+20 -14
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from 'hono';
import { verify } from '@@/jwt';
import { resolveAuthToken } from '@@/auth-token';
import * as errors from '@@/custom-errors';
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
import { isLockdown, noteBlocked } from '../api/auth/panic';
@@ -36,22 +36,28 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
}
try {
const user = await verify(token);
// Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only:
// an API key's revocation is a column checked during that lookup, so there is no blacklist to consult
// and no issued-at to compare. A password change deliberately does NOT kill a user's API keys; they
// are the credential you rotate independently, which is the whole reason they exist.
const user = await resolveAuthToken(token);
if (!user) throw errors.UNAUTHORIZED();
// Check if token is blacklisted (explicit signout)
if (user.jti) {
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
}
if (user.via === 'jwt') {
// Check if token is blacklisted (explicit signout)
if (user.jti) {
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
}
// Check if token was issued before password change
if (user.iat && user.id) {
const dbUser = await getUserById(user.id);
if (dbUser?.passwordChangedAt) {
// iat is in seconds, passwordChangedAt is a Date
const tokenIssuedAt = user.iat * 1000;
if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) {
throw errors.UNAUTHORIZED();
// Check if token was issued before password change
if (user.iat && user.id) {
const dbUser = await getUserById(user.id);
if (dbUser?.passwordChangedAt) {
// iat is in seconds, passwordChangedAt is a Date
const tokenIssuedAt = user.iat * 1000;
if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) {
throw errors.UNAUTHORIZED();
}
}
}
}
+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 });
});
+5 -2
View File
@@ -1,10 +1,13 @@
import type { Handler } from 'hono';
import type { AuthIdentity } from '@@/auth-token';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
export const signoutHandler: Handler = async (ctx) => {
const user = ctx.get('user') as { id: number; jti: string; exp: number };
const user = ctx.get('user') as AuthIdentity;
await blacklistToken(user.jti, user.exp);
// Only a session has something to blacklist. An API key cannot be signed out — it is revoked by id in
// the keys API, which is the point of holding one instead of a session.
if (user.jti && user.exp) await blacklistToken(user.jti, user.exp);
// Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless).
clearVaultTokens(user.id).catch(() => {});
+109
View File
@@ -0,0 +1,109 @@
import { createHash, randomBytes } from 'node:crypto';
import { findLiveApiKeyByHash, touchApiKey } from 'officerdb';
import { verify } from './jwt';
// How a bearer string becomes a caller. There are two credentials — the 30-day session JWT the web app
// signs in with, and an API key a user mints for an app or a device — and exactly one place that tells
// them apart.
//
// ── One resolver, two doors ──
//
// Identity is decided in TWO independent middlewares: `userMiddleware`, which every protected router
// mounts, and `originScopeMiddleware`, which runs globally in hono.ts and re-verifies the token itself
// because it must also cover routes that never mount `userMiddleware`. They have to agree about who a
// caller is, and the way they stop agreeing is somebody teaching one of them a credential format the
// other has never heard of — the second door would then see an unrecognisable token, resolve nobody, and
// wave the request through its account backstop.
//
// That is not a hypothetical failure here: the capability totality check exists because a Member was
// 403ing on GET /api/tasks and opening /api/tasks/pipeline/ws in the same minute. So `resolveAuthToken`
// is the only function that turns a bearer string into a caller, and both doors call it.
/**
* Marks a bearer string as one of ours rather than a JWT. Not a secret and not a checksum — its job is to
* let the resolver pick a path without JWT-verifying something that was never a JWT, and to make a leaked
* key greppable in a log or a repository.
*/
export const API_KEY_PREFIX = 'ofk_';
/** Characters of the secret kept in the clear for display. Enough to tell two keys apart, far too few to use. */
const PREFIX_DISPLAY_LENGTH = API_KEY_PREFIX.length + 6;
/** 32 bytes from the CSPRNG. base64url so the whole key is copy-pasteable and safe in a query string. */
const SECRET_BYTES = 32;
export const hashApiKey = (secret: string): string => createHash('sha256').update(secret).digest('hex');
export const looksLikeApiKey = (token: string): boolean => token.startsWith(API_KEY_PREFIX);
export type MintedApiKey = {
/** The full credential. Shown to the user exactly once and never stored. */
secret: string;
keyHash: string;
prefix: string;
};
export function mintApiKey(): MintedApiKey {
const secret = `${API_KEY_PREFIX}${randomBytes(SECRET_BYTES).toString('base64url')}`;
return { secret, keyHash: hashApiKey(secret), prefix: secret.slice(0, PREFIX_DISPLAY_LENGTH) };
}
/**
* Who a caller is, however they proved it.
*
* Shaped to match the JWT payload the app already puts in `ctx.get('user')` — handlers read `.id` and
* `.email` and must not care which credential arrived. `via` exists for the two checks that are only
* meaningful for a session: the signout blacklist and `passwordChangedAt`. A key's revocation is a
* column, not a blacklist row.
*/
export type AuthIdentity = {
id: number;
email: string;
username?: string | null;
name?: string | null;
via: 'jwt' | 'api-key';
/** JWT only. */
jti?: string;
/** JWT only, seconds since the epoch. */
iat?: number;
/** JWT only, seconds since the epoch. */
exp?: number;
};
/**
* Turn an API key into a caller, or null.
*
* Lookup is by SHA-256 of the whole secret against a unique index, so it is one index hit and an attacker
* cannot narrow the search by guessing at a prefix. SHA-256 rather than argon2 deliberately: the secret is
* 32 CSPRNG bytes with no human pattern to grind, and a slow hash would have to be recomputed per
* candidate row, turning every authenticated request into a table scan.
*/
export async function resolveApiKey(secret: string): Promise<AuthIdentity | null> {
const row = await findLiveApiKeyByHash(hashApiKey(secret));
if (!row) return null;
// Fire and forget. "When was this last used" must never fail a request or add latency to one; the write
// is debounced to at most once a minute per key inside touchApiKey.
void touchApiKey(row.keyId, row.lastUsedAt).catch(() => {});
return { id: row.userId, email: row.email, username: row.username, via: 'api-key' };
}
/**
* The single entry point both identity doors use.
*
* Returns null for anything it cannot vouch for — an unsigned or expired JWT, an unknown key, a revoked
* one. It answers "is this credential live, and whose", and nothing else: the blacklist and
* `passwordChangedAt` checks stay in `userMiddleware`, which is the door that owns session invalidation.
*/
export async function resolveAuthToken(token: string): Promise<AuthIdentity | null> {
if (looksLikeApiKey(token)) return resolveApiKey(token);
try {
const payload = await verify(token);
if (!payload || typeof payload.id !== 'number') return null;
return { ...payload, via: 'jwt' };
} catch {
return null;
}
}
+6 -2
View File
@@ -86,9 +86,13 @@ export const CAPABILITIES: Capability[] = [
{
key: 'account',
label: 'Account',
description: 'Sign in, your own profile, password and preferences',
description: 'Sign in, your own profile, password, preferences and API keys',
kind: 'core',
api: ['/user', '/dock'],
// `/api-keys` is core rather than app or admin because a key is not new authority — it is a second way
// to present the authority the account already has, so denying it would only force the holder to keep
// using a password in places a password should not go. What a key can then DO is decided by the same
// capability checks as any other request from that user; nothing here widens them.
api: ['/user', '/dock', '/api-keys'],
routes: ['/settings/profile'],
},
{
+2
View File
@@ -7,6 +7,7 @@ import { serverSettingsRouter } from './api/server-settings/server-settings';
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { apiKeysRouter } from './api/api-keys/router';
import { plansRouter } from './api/plans/plans';
import { skillsRouter } from './api/skills/skills';
import { tasksRouter } from './api/tasks/tasks';
@@ -176,6 +177,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/scrape', scrapeRouter],
['/upload', uploadRouter],
['/user', settingsRouter],
['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
['/dashboards', dashboardsRouter],
['/task-logs', taskLogsRouter],
['/file-browser', fileBrowserRouter],