diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index c4eda2f7..cfe5ec34 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -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 = {}; -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 = {}; +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(); }; diff --git a/src/servers/api/auth/signin.ts b/src/servers/api/auth/signin.ts index e6083a47..88b4016d 100755 --- a/src/servers/api/auth/signin.ts +++ b/src/servers/api/auth/signin.ts @@ -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 }; diff --git a/src/servers/super-admin.ts b/src/servers/super-admin.ts new file mode 100644 index 00000000..25de500f --- /dev/null +++ b/src/servers/super-admin.ts @@ -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 { + 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 { + 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 { + if (!payload?.id) return false; + const adminId = await getSuperAdminId(); + return adminId !== null && payload.id === adminId; +}