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