Files
platform/src/servers/_middlewares/user-middleware.ts
T
pastilhasandClaude Opus 5 00997f5ff1 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>
2026-08-08 10:10:14 +00:00

72 lines
2.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();
}
// 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();
}
}
}
}
ctx.set('user', user);
return next();
} catch (ex) {
if (ex instanceof errors.CustomError) throw ex;
throw errors.UNAUTHORIZED();
}
};