Files
platform/src/servers/_middlewares/user-middleware.ts
T
pastilhasandClaude Opus 5 0281ca62d2 a deleted or blocked account loses its session on the next request
Reported from two browser windows: an account deleted from the dashboard survived a
page refresh in the other one. Two independent halves.

Server: userMiddleware looked the account up, then read the result as
`dbUser?.passwordChangedAt` — so a DELETED account fell through the optional chain and
the request proceeded on a token that is still cryptographically valid, for up to the
full 30 days. `status` was the same hole from the other direction: signin refuses
anything that is not Active, but nothing rechecked it afterwards, so marking someone
Blocked did not end the session they already had, which is exactly when you would be
doing it. Now the account must exist and be Active on every request.

Client: nothing reacted to a 401 at all. onError fed the bug-report form and stopped
there, so the window kept rendering off cached React Query data. A 401 now clears every
storage key createClient reads and returns to the sign-in screen. /auth/ is exempt
because a wrong password is also a 401 and reloading the form would look like a crash.

window.officerBearerToken was declared non-optional, which made "there is no token"
unspeakable. It has always been one of five sources, any of which may be absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:38:02 +00:00

84 lines
3.9 KiB
TypeScript

import type { MiddlewareHandler } from 'hono';
import { resolveAuthToken } from '@@/auth-token';
import * as errors from '@@/custom-errors';
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
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();
// Validate origin for authenticated routes.
// TEMPORARY, opt-in: ALLOW_ANY_ORIGIN_MUSIC=true drops the Origin check for /api/music only, so a
// client that can't present the app's custom-scheme origin can still reach the music API. Auth is
// untouched — a valid token is still required, and the non-owner account backstop in
// originScopeMiddleware still applies. Delete this and the env var once the tailnet is the perimeter.
const origin = ctx.get('origin') as string | undefined;
const host = ctx.req.header('host');
if (!isMusicOriginExempt(ctx.req.path) && !isOriginAllowed(origin, host)) {
throw errors.FORBIDDEN('Invalid origin');
}
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();
}
};