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