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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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(() => {});
|
||||
|
||||
@@ -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',
|
||||
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'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user