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>
72 lines
2.9 KiB
TypeScript
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();
|
|
}
|
|
};
|