ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag defaulted to ON, so none of it ran on a real install — what comes out is documented defence in depth that was already switched off. The file said so itself: "Both flags and their call sites come out once the tailnet is the perimeter." Origin was never authentication here in any case. An app's `officer://<hex>` origin is chosen by the client, forgeable outside a browser, and extractable from a shipped binary. Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt, originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which existed only to pin them. CORS now echoes whatever Origin it is given, which is what every install already did. What SURVIVES is the reason this needed care. origin-validation.ts held two unrelated things, and the second was the global authorization gate — a valid non-owner token reaches only what its role grants, deliberately NOT under the flag because it is account-based rather than origin-based. Its own comment called it "the airtight half". Deleting the file wholesale would have deleted authorization. So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with the name matching what it does: nothing in it reads an Origin header any more. hono.ts mounts it in the same position, ahead of every router. origin-middleware.ts stays and is untouched — it extracts the Origin for six auth handlers that log it, and for passkeys. Extraction, not validation. Also updates every claim that rested on the old model: CLAUDE.md's security section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five messages in machine-setup's Tailscale section which told the owner to set ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to follow, and the honest version is different: with no tailnet the token is the whole lock, so put a proxy in front and restrict who can reach it. Not typechecked (empty node_modules, frozen installs). Every changed file parses; the setup section was run and writes four variables now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
76 lines
3.5 KiB
TypeScript
76 lines
3.5 KiB
TypeScript
import type { MiddlewareHandler } from 'hono';
|
|
import { resolveAuthToken } from '@@/auth-token';
|
|
import * as errors from '@@/custom-errors';
|
|
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
|
import { getUserById, isTokenBlacklisted } from 'officerdb';
|
|
|
|
export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
|
// Duress lockdown: reject every authenticated request, cutting off all existing sessions.
|
|
if (isLockdown()) {
|
|
noteBlocked(`${ctx.req.method} ${ctx.req.path}`);
|
|
throw errors.UNAUTHORIZED();
|
|
}
|
|
|
|
const { authorization } = ctx.req.header();
|
|
|
|
// Support token in query param for media elements (<audio>, <video>, <img>)
|
|
// that cannot send Authorization headers
|
|
let token: string | undefined;
|
|
if (authorization) {
|
|
[, token] = authorization.split(' ');
|
|
} else {
|
|
token = ctx.req.query('token') || undefined;
|
|
}
|
|
if (!token) throw errors.UNAUTHORIZED();
|
|
|
|
// There was an Origin check here until 2026-08-13. It is gone with the rest of origin validation —
|
|
// it had defaulted to off, so it ran on no real install. A valid token is required below, and the
|
|
// capability gate in hono.ts confines a non-owner to what their role grants.
|
|
|
|
try {
|
|
// 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.via === 'jwt') {
|
|
// Check if token is blacklisted (explicit signout)
|
|
if (user.jti) {
|
|
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
|
|
}
|
|
|
|
// The account still has to exist, and still has to be allowed in.
|
|
//
|
|
// This lookup used to happen only for the password-change comparison below, and its result was read
|
|
// as `dbUser?.passwordChangedAt` — so a DELETED account fell straight through the optional chain and
|
|
// kept working on a token that is still cryptographically valid, for up to the full 30 days. Observed
|
|
// 2026-08-11: an account deleted from the dashboard survived a page refresh in another window.
|
|
//
|
|
// `status` is the same shape of hole. signin.ts refuses anything that is not 'Active', but nothing
|
|
// re-checked it afterwards, so marking someone Blocked or Banned did not end the session they already
|
|
// had — which is precisely when you would be doing it.
|
|
//
|
|
// Re-read per request rather than trusted as a claim, for the reason the role is not a claim either:
|
|
// a revocation has to take effect on the next request, not at next sign-in.
|
|
if (user.id) {
|
|
const dbUser = await getUserById(user.id);
|
|
if (!dbUser) throw errors.UNAUTHORIZED();
|
|
if (dbUser.status !== 'Active') throw errors.UNAUTHORIZED();
|
|
|
|
// Token issued before the password changed → refuse. iat is seconds, passwordChangedAt is a Date.
|
|
if (user.iat && dbUser.passwordChangedAt && user.iat * 1000 < dbUser.passwordChangedAt.getTime()) {
|
|
throw errors.UNAUTHORIZED();
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.set('user', user);
|
|
return next();
|
|
} catch (ex) {
|
|
if (ex instanceof errors.CustomError) throw ex;
|
|
throw errors.UNAUTHORIZED();
|
|
}
|
|
};
|