auth: confine non-owner accounts to the music app (account + origin gates)

Adds a Super Admin ("owner") identity — SUPER_ADMIN_EMAIL, else the
bootstrap/first user (super-admin.ts) — and closes the hole where a music
account could sign into the full platform:

- Rename EXPO_PUBLIC_CLIENT_ORIGIN -> OFFICER_APP_ORIGIN.
- PUBLIC_URL + OFFICER_APP_ORIGIN are owner-only origins; MUSIC_APP_ORIGIN
  stays path-scoped to /api/auth + /api/music.
- Account backstop (origin-independent): a valid non-owner token may reach
  only /api/auth + /api/music regardless of Origin — airtight even if the
  header is omitted/forged.
- signin rejects a non-owner logging in from an owner-only origin.

Owner keeps full access (verified); music users are confined to the music app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:38:29 +00:00
co-authored by Claude Opus 4.8
parent b40464632c
commit 1d441e3aa1
3 changed files with 116 additions and 20 deletions
+70 -20
View File
@@ -1,8 +1,10 @@
import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { IS_DEV_BUILD } from '../build-env';
import { verify } from '../jwt';
import { isSuperAdmin } from '../super-admin';
const { PUBLIC_URL, EXPO_PUBLIC_CLIENT_ORIGIN, MUSIC_APP_ORIGIN } = process.env;
const { PUBLIC_URL, OFFICER_APP_ORIGIN, MUSIC_APP_ORIGIN } = process.env;
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
// not a hardcoded domain.
@@ -26,19 +28,37 @@ const CHROME_EXTENSIONS: string[] = [
];
const APP_ORIGINS: string[] = [
// Expo mobile app — an officer:// custom-scheme origin (with an embedded token), set via env.
EXPO_PUBLIC_CLIENT_ORIGIN,
// Officer mobile app — an officer:// custom-scheme origin (with an embedded token), set via env.
OFFICER_APP_ORIGIN,
// Standalone officer-music app — its own custom-scheme origin. Allowlisted so it can authenticate
// and stream; SCOPED_ORIGINS below restricts it to /api/auth + /api/music only.
MUSIC_APP_ORIGIN,
].filter((o): o is string => Boolean(o));
// Origins restricted to a subset of the API. A request whose Origin is a key here may reach ONLY the
// listed path prefixes; anything else is 403 (enforced by originScopeMiddleware, mounted globally in
// hono.ts). Origins not listed here (the main web origin) keep full access. No-ops while the env is
// unset, so it's safe to ship before the app side is configured.
const SCOPED_ORIGINS: Record<string, string[]> = {};
if (MUSIC_APP_ORIGIN) SCOPED_ORIGINS[MUSIC_APP_ORIGIN] = ['/api/auth', '/api/music'];
// The only path prefixes a non-owner account (and the music app) may reach.
const NON_OWNER_PATHS = ['/api/auth', '/api/music'];
function pathAllowed(path: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
// Per-origin access rules, enforced globally by originScopeMiddleware (mounted in hono.ts):
// - superAdminOnly: authenticated requests carrying this Origin must be the platform owner
// (see super-admin.ts). Unauthenticated requests (e.g. signin) pass through — userMiddleware
// still guards protected routes. Stops a non-owner from using the web / mobile platform, even by
// replaying a token minted through the music app.
// - paths: this Origin may reach ONLY these path prefixes; anything else is 403.
// Origins with no rule keep full access. Rules no-op for env values that are unset.
type OriginRule = { superAdminOnly?: boolean; paths?: string[] };
const ORIGIN_RULES: Record<string, OriginRule> = {};
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = { superAdminOnly: true };
if (OFFICER_APP_ORIGIN) ORIGIN_RULES[OFFICER_APP_ORIGIN] = { superAdminOnly: true };
if (MUSIC_APP_ORIGIN) ORIGIN_RULES[MUSIC_APP_ORIGIN] = { paths: NON_OWNER_PATHS };
// True when an Origin is reserved for the platform owner (used at signin to reject a non-owner login).
export function isSuperAdminOnlyOrigin(origin: string | undefined): boolean {
return !!origin && ORIGIN_RULES[origin]?.superAdminOnly === true;
}
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
if (IS_DEV_BUILD) return true;
@@ -83,19 +103,49 @@ function resolveOrigin(headerOrigin: string | undefined, referer: string | undef
return undefined;
}
// Global gate: a scoped app origin (e.g. the standalone music app) may only reach its allowed path
// prefixes; every other path is 403. Reads the Origin header directly (not ctx 'origin') so it applies
// across the whole /api tree — including the public /api/auth and the protected /api/music — regardless
// of which routers mount originMiddleware. Requests from unscoped origins pass straight through.
export const originScopeMiddleware: MiddlewareHandler = function (ctx, next) {
// Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not
// ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music
// alike — regardless of which routers mount originMiddleware. Two layers:
// 1. Account backstop (origin-INDEPENDENT): a valid NON-owner token may reach only /api/auth +
// /api/music, no matter the origin. This is the airtight rule — it holds even if a client omits
// or forges the Origin header — and is what confines the music accounts to the music app.
// 2. Per-origin rules (ORIGIN_RULES): path scoping (music app) and super-admin-only origins (web +
// mobile). Redundant with the backstop for the account dimension, but keeps owner-only origins
// fully off-limits to non-owners (incl. /api/music) and blocks unknown-path access there.
// A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on
// protected routes). Only a VALID non-owner token is constrained.
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
const path = ctx.req.path;
const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));
if (origin) {
const allowed = SCOPED_ORIGINS[origin];
if (allowed) {
const path = ctx.req.path;
const permitted = allowed.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
if (!permitted) throw errors.FORBIDDEN('Origin not permitted for this resource');
// Verify the caller's token once (if any); a missing/invalid token stays null.
const authorization = ctx.req.header('authorization');
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
let payload: { id?: number } | null = null;
if (token) {
try {
payload = await verify(token);
} catch {
payload = null;
}
}
const isOwner = payload ? await isSuperAdmin(payload) : false;
// 1. Account backstop — non-owner accounts are confined to /api/auth + /api/music everywhere.
if (payload && !isOwner && !pathAllowed(path, NON_OWNER_PATHS)) {
throw errors.FORBIDDEN('This account is limited to the music app');
}
// 2. Per-origin rules.
const rule = origin ? ORIGIN_RULES[origin] : undefined;
if (rule) {
if (rule.paths && !pathAllowed(path, rule.paths)) {
throw errors.FORBIDDEN('Origin not permitted for this resource');
}
if (rule.superAdminOnly && payload && !isOwner) {
throw errors.FORBIDDEN('This origin is restricted to the platform owner');
}
}
return next();
};
+8
View File
@@ -3,6 +3,8 @@ import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
import { sign } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { isSuperAdminOnlyOrigin } from '@@/_middlewares';
import { isSuperAdmin } from '@@/super-admin';
import { isLockdown, noteBlocked } from './panic';
const TEST_USERS: number[] = [];
@@ -28,6 +30,12 @@ export const signinHandler: Handler = async function (ctx) {
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
if (!isValidPassword) throw errors.UNAUTHORIZED();
// A non-owner account may authenticate only through an app origin (e.g. the music app), never the
// Super-Admin-only web/mobile origins — so its credentials can't open the full platform in a browser.
if (isSuperAdminOnlyOrigin(origin) && !(await isSuperAdmin({ id: dbUser.id }))) {
throw errors.FORBIDDEN('This account can only sign in through its app.');
}
const { id, name, username } = dbUser;
const tokenUser = { id, email, name, username, passkeys: passkeys.length };
+38
View File
@@ -0,0 +1,38 @@
import { getUsers, getUserByEmail } from 'officerdb';
// Identifies the platform owner ("Super Admin"). Two mechanisms, both supported:
// (b) explicit — SUPER_ADMIN_EMAIL in .env designates the owner by email.
// (a) automatic — if that's unset, the bootstrap/first account (lowest user id) is the owner.
// Resolved once and cached: the owner never changes at runtime (bootstrap is closed after user #1).
const { SUPER_ADMIN_EMAIL } = process.env;
let cachedId: number | null = null;
let resolved = false;
async function resolveSuperAdminId(): Promise<number | null> {
if (resolved) return cachedId;
if (SUPER_ADMIN_EMAIL) {
const user = await getUserByEmail(SUPER_ADMIN_EMAIL);
cachedId = user?.id ?? null;
} else {
const users = await getUsers();
cachedId = users.length ? users.reduce((min, u) => (u.id < min ? u.id : min), users[0]!.id) : null;
}
resolved = true;
return cachedId;
}
export async function getSuperAdminId(): Promise<number | null> {
try {
return await resolveSuperAdminId();
} catch {
// Transient DB error before the id is cached: stay unresolved (deny) and retry next call.
return null;
}
}
export async function isSuperAdmin(payload: { id?: number } | null | undefined): Promise<boolean> {
if (!payload?.id) return false;
const adminId = await getSuperAdminId();
return adminId !== null && payload.id === adminId;
}