auth: origin-scope the music app to /api/auth + /api/music

Add MUSIC_APP_ORIGIN to the origin allowlist and a global
originScopeMiddleware that restricts scoped app origins (the standalone
officer-music client) to their permitted path prefixes — /api/auth and
/api/music — and 403s everything else. The main web origin is unaffected,
and the gate no-ops while MUSIC_APP_ORIGIN is unset.

Lets extra sign-in-only users authenticate through the music app and reach
only music + auth, without reintroducing any per-user permission scheme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:14:08 +00:00
co-authored by Claude Opus 4.8
parent f4054193ae
commit b40464632c
2 changed files with 45 additions and 2 deletions
+40 -1
View File
@@ -2,7 +2,7 @@ import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { IS_DEV_BUILD } from '../build-env';
const { PUBLIC_URL, EXPO_PUBLIC_CLIENT_ORIGIN } = process.env;
const { PUBLIC_URL, EXPO_PUBLIC_CLIENT_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.
@@ -28,8 +28,18 @@ 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,
// 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'];
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
if (IS_DEV_BUILD) return true;
@@ -60,3 +70,32 @@ export const originValidationMiddleware: MiddlewareHandler = function (ctx, next
}
return next();
};
function resolveOrigin(headerOrigin: string | undefined, referer: string | undefined): string | undefined {
if (headerOrigin) return headerOrigin;
if (referer) {
try {
return new URL(referer).origin;
} catch {
return undefined;
}
}
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) {
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');
}
}
return next();
};
+5 -1
View File
@@ -35,7 +35,7 @@ import { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { broadcastPanelRefresh } from './api/terminal/websocket';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, isOriginAllowed } from './_middlewares';
import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares';
export { Hono };
export { createRouter };
@@ -54,6 +54,10 @@ honoServer.use(
}),
);
// Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes
// (/api/auth + /api/music). No-ops for the main web origin and while MUSIC_APP_ORIGIN is unset.
honoServer.use(originScopeMiddleware);
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
honoServer.route('/api/auth', authRouter);
honoServer.route('/api/landing-page-data', landingPageDataRouter);