the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.
so a credential now narrows two ways, and both only subtract:
permissions an ALLOW list. exact, a snapshot. right for a credential
with one job.
excludedKinds kinds it may never reach, resolved against the LIVE
registry on every request. right for a credential whose
job is open-ended.
a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.
verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.
the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
142 lines
6.1 KiB
TypeScript
142 lines
6.1 KiB
TypeScript
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 `permissionGateMiddleware`, 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 permission 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;
|
|
/**
|
|
* How this credential is narrowed, or null/absent for one carrying its holder's full authority — which
|
|
* is every session JWT and every key a human has minted.
|
|
*
|
|
* Carried on the identity rather than looked up again by the gate, because the key row was already read
|
|
* to answer "whose is this" — and because a second lookup is a second chance for the two doors to
|
|
* disagree about the same credential, which is the failure this whole file exists to prevent.
|
|
*/
|
|
scope?: CredentialScope | null;
|
|
};
|
|
|
|
/**
|
|
* A narrowing, in the two shapes that are actually useful.
|
|
*
|
|
* `allow` is exact and a snapshot; `excludeKinds` is a rule resolved against the live registry. A
|
|
* commander wants the second: bounded away from administration forever, and still able to reach a plugin
|
|
* installed long after it was created. See `api-keys/schema.ts` for the full argument.
|
|
*/
|
|
export type CredentialScope = {
|
|
allow?: string[] | null;
|
|
excludeKinds?: string[] | null;
|
|
};
|
|
|
|
/** Does this narrowing actually narrow anything? Null and two empty rules are the same thing: no. */
|
|
export const isNarrowed = (scope: CredentialScope | null | undefined): scope is CredentialScope =>
|
|
!!scope && (Array.isArray(scope.allow) || !!scope.excludeKinds?.length);
|
|
|
|
/**
|
|
* 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(() => {});
|
|
|
|
const scope: CredentialScope = { allow: row.permissions, excludeKinds: row.excludedKinds };
|
|
return {
|
|
id: row.userId,
|
|
email: row.email,
|
|
username: row.username,
|
|
via: 'api-key',
|
|
scope: isNarrowed(scope) ? scope : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|