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
+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;