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:
@@ -608,4 +608,4 @@ a separate job.
|
|||||||
`border-box`, percentage heights resolve against the content box, so there is no overflow. I did not
|
`border-box`, percentage heights resolve against the content box, so there is no overflow. I did not
|
||||||
change it.
|
change it.
|
||||||
- **Mobile edit-in-invisible-panel** (dashboards) is unrelated to this work and still open; it is
|
- **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`.
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ export {
|
|||||||
cleanupExpiredTokens,
|
cleanupExpiredTokens,
|
||||||
} from './queries/auth';
|
} 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 { readServerSettings, writeServerSettings, readConfigValue, writeConfigValue } from './queries/server-config';
|
||||||
|
|
||||||
export {
|
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 './agent-panels';
|
||||||
|
export * from './api-keys';
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './capabilities';
|
export * from './capabilities';
|
||||||
export * from './chat-events';
|
export * from './chat-events';
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
|
|||||||
|
|
||||||
// ── Saved Sessions ──
|
// ── Saved Sessions ──
|
||||||
|
|
||||||
|
|
||||||
// ── Dashboards ──
|
// ── Dashboards ──
|
||||||
|
|
||||||
export type DashboardSelect = typeof Schema.dashboards.$inferSelect;
|
export type DashboardSelect = typeof Schema.dashboards.$inferSelect;
|
||||||
@@ -77,3 +76,8 @@ export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert;
|
|||||||
// Notifications
|
// Notifications
|
||||||
export type PushDeviceSelect = typeof Schema.pushDevices.$inferSelect;
|
export type PushDeviceSelect = typeof Schema.pushDevices.$inferSelect;
|
||||||
export type PushDeviceInsert = typeof Schema.pushDevices.$inferInsert;
|
export type PushDeviceInsert = typeof Schema.pushDevices.$inferInsert;
|
||||||
|
|
||||||
|
// ── API keys ──
|
||||||
|
|
||||||
|
export type ApiKeySelect = typeof Schema.apiKeys.$inferSelect;
|
||||||
|
export type ApiKeyInsert = typeof Schema.apiKeys.$inferInsert;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MiddlewareHandler } from 'hono';
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import * as errors from '../custom-errors';
|
import * as errors from '../custom-errors';
|
||||||
import { IS_DEV_BUILD } from '../build-env';
|
import { IS_DEV_BUILD } from '../build-env';
|
||||||
import { verify } from '../jwt';
|
import { resolveAuthToken } from '../auth-token';
|
||||||
import { isSuperAdmin } from '../super-admin';
|
import { isSuperAdmin } from '../super-admin';
|
||||||
import { isApiRequestAllowed } from '../capabilities/authorize';
|
import { isApiRequestAllowed } from '../capabilities/authorize';
|
||||||
import { isExemptApiPath } from '../capabilities/totality';
|
import { isExemptApiPath } from '../capabilities/totality';
|
||||||
@@ -185,17 +185,12 @@ export const originScopeMiddleware: MiddlewareHandler = async function (ctx, nex
|
|||||||
const path = ctx.req.path;
|
const path = ctx.req.path;
|
||||||
const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));
|
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 authorization = ctx.req.header('authorization');
|
||||||
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
|
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
|
||||||
let payload: { id?: number } | null = null;
|
const payload = token ? await resolveAuthToken(token) : null;
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
payload = await verify(token);
|
|
||||||
} catch {
|
|
||||||
payload = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isOwner = payload ? await isSuperAdmin(payload) : false;
|
const isOwner = payload ? await isSuperAdmin(payload) : false;
|
||||||
|
|
||||||
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
|
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from 'hono';
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import { verify } from '@@/jwt';
|
import { resolveAuthToken } from '@@/auth-token';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
|
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
|
||||||
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
||||||
@@ -36,9 +36,14 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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();
|
if (!user) throw errors.UNAUTHORIZED();
|
||||||
|
|
||||||
|
if (user.via === 'jwt') {
|
||||||
// Check if token is blacklisted (explicit signout)
|
// Check if token is blacklisted (explicit signout)
|
||||||
if (user.jti) {
|
if (user.jti) {
|
||||||
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
|
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
|
||||||
@@ -55,6 +60,7 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.set('user', user);
|
ctx.set('user', user);
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
|
import type { AuthIdentity } from '@@/auth-token';
|
||||||
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
|
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
|
||||||
|
|
||||||
export const signoutHandler: Handler = async (ctx) => {
|
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).
|
// Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless).
|
||||||
clearVaultTokens(user.id).catch(() => {});
|
clearVaultTokens(user.id).catch(() => {});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,9 +86,13 @@ export const CAPABILITIES: Capability[] = [
|
|||||||
{
|
{
|
||||||
key: 'account',
|
key: 'account',
|
||||||
label: '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',
|
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'],
|
routes: ['/settings/profile'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { serverSettingsRouter } from './api/server-settings/server-settings';
|
|||||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||||
import { usersRouter } from './api/users/users-router';
|
import { usersRouter } from './api/users/users-router';
|
||||||
|
import { apiKeysRouter } from './api/api-keys/router';
|
||||||
import { plansRouter } from './api/plans/plans';
|
import { plansRouter } from './api/plans/plans';
|
||||||
import { skillsRouter } from './api/skills/skills';
|
import { skillsRouter } from './api/skills/skills';
|
||||||
import { tasksRouter } from './api/tasks/tasks';
|
import { tasksRouter } from './api/tasks/tasks';
|
||||||
@@ -176,6 +177,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
|||||||
['/scrape', scrapeRouter],
|
['/scrape', scrapeRouter],
|
||||||
['/upload', uploadRouter],
|
['/upload', uploadRouter],
|
||||||
['/user', settingsRouter],
|
['/user', settingsRouter],
|
||||||
|
['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
|
||||||
['/dashboards', dashboardsRouter],
|
['/dashboards', dashboardsRouter],
|
||||||
['/task-logs', taskLogsRouter],
|
['/task-logs', taskLogsRouter],
|
||||||
['/file-browser', fileBrowserRouter],
|
['/file-browser', fileBrowserRouter],
|
||||||
|
|||||||
Reference in New Issue
Block a user