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:
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user