344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
165 lines
9.6 KiB
TypeScript
165 lines
9.6 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
const COOKIE_NAME = "tc_session";
|
|
|
|
function base64urlToBytes(b64url: string): Uint8Array {
|
|
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(b64url.length / 4) * 4, "=");
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
async function verify(raw: string | undefined, secret: string): Promise<boolean> {
|
|
try { return await verifyInner(raw, secret); } catch { return false; }
|
|
}
|
|
async function verifyInner(raw: string | undefined, secret: string): Promise<boolean> {
|
|
if (!raw) return false;
|
|
const [payload, sig] = raw.split(".");
|
|
if (!payload || !sig) return false;
|
|
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
const ok = await crypto.subtle.verify("HMAC", key, base64urlToBytes(sig).buffer as ArrayBuffer, new TextEncoder().encode(payload));
|
|
if (!ok) return false;
|
|
try {
|
|
const data = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload)));
|
|
return !!data.uid && data.exp > Date.now();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const OPS_COOKIE_NAME = "tc_ops";
|
|
|
|
/* The operator token, verified the way the coordinator one is above, with two differences that
|
|
* are the whole point: the key is derived from OPS_SESSION_SECRET through the domain string
|
|
* lib/ops/session.ts uses, and the claim it requires is `oid`, not `uid`. A coordinator or staff
|
|
* cookie presented here fails the signature; and if it somehow didn't, it has no `oid`. */
|
|
async function verifyOps(raw: string | undefined, secret: string): Promise<boolean> {
|
|
try {
|
|
if (!raw) return false;
|
|
const [payload, sig] = raw.split(".");
|
|
if (!payload || !sig) return false;
|
|
const keyBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode("threadcount:ops:v1:" + secret));
|
|
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
const ok = await crypto.subtle.verify("HMAC", key, base64urlToBytes(sig).buffer as ArrayBuffer, new TextEncoder().encode(payload));
|
|
if (!ok) return false;
|
|
const data = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload)));
|
|
return !!data.oid && data.exp > Date.now();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/* The operations console's hostname.
|
|
*
|
|
* Compared as an exact, lower-cased, port-stripped string. Never `includes`, and never Next's
|
|
* own `has: [{ type: "host" }]` matcher: that reads `req.headers.host` and FAILS OPEN when the
|
|
* header is absent, and it compiles its value as an unescaped regular expression, so
|
|
* "ops.threadcount.tech" would also match "opsXthreadcountYtech". A security control that
|
|
* silently does nothing when a reverse proxy omits a directive is the failure shape this
|
|
* codebase has spent effort eliminating elsewhere. */
|
|
const OPS_HOST = "ops.threadcount.tech";
|
|
|
|
export async function proxy(req: NextRequest) {
|
|
const { pathname } = req.nextUrl;
|
|
/* Which site is being asked for.
|
|
*
|
|
* nginx passes the real Host through (`proxy_set_header Host $host` in both vhosts), and Next
|
|
* backfills x-forwarded-host FROM host and never the reverse — so preferring x-forwarded-host
|
|
* with host as the fallback is right. An unrecognised host is treated as the product, which is
|
|
* the safe default: the console is opt-in by exact match, and /ops is refused unless it hits. */
|
|
const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(":")[0].toLowerCase();
|
|
// The console exists only where its own secret does. A Community instance that happens to be
|
|
// reached as ops.threadcount.tech (it cannot be, but the check is cheap) is the product.
|
|
const isOps = host === OPS_HOST && !!process.env.OPS_SESSION_SECRET;
|
|
|
|
/* The ops hostname is reachable and fronted by Cloudflare Access, and for now it serves
|
|
* nothing at all. The console is built behind that door in later phases.
|
|
*
|
|
* Two paths make this more than tidiness. /.well-known/assetlinks.json is served
|
|
* unconditionally, and on this hostname it would declare both Android apps authorised for
|
|
* `get_login_creds` — so a tapped ops link could open inside the staff app and password
|
|
* managers would offer ops credentials there. And /api/auth/demo mints a coordinator session
|
|
* and redirects to /app, which would hand anyone reaching this host a working product.
|
|
*
|
|
* The fence runs the other way too: /ops must not exist on the product hostname. */
|
|
if (isOps) {
|
|
if (pathname === "/") { const u = req.nextUrl.clone(); u.pathname = "/ops"; return NextResponse.redirect(u); }
|
|
// Public on this host: the sign-in page and the routes that create or end a session. They
|
|
// carry their own throttles and origin checks.
|
|
if (pathname === "/ops/login" || pathname.startsWith("/api/ops/auth/")) return NextResponse.next();
|
|
const opsPath = pathname === "/ops" || pathname.startsWith("/ops/") || pathname.startsWith("/api/ops/");
|
|
if (!opsPath) return new NextResponse(null, { status: 404 });
|
|
// Console paths need an operator session. Verified at the edge with the ops key — its own
|
|
// secret, its own domain string, its own claim name — and re-read from the database by the
|
|
// console layout, so a deactivated operator or a changed password still ends it there.
|
|
const osecret = process.env.OPS_SESSION_SECRET;
|
|
const ok = osecret ? await verifyOps(req.cookies.get(OPS_COOKIE_NAME)?.value, osecret) : false;
|
|
if (ok) return NextResponse.next();
|
|
if (pathname.startsWith("/api")) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
|
const url = req.nextUrl.clone();
|
|
url.pathname = "/ops/login"; url.search = "";
|
|
const res = NextResponse.redirect(url);
|
|
if (req.cookies.get(OPS_COOKIE_NAME)) res.cookies.set(OPS_COOKIE_NAME, "", { maxAge: 0, path: "/" });
|
|
return res;
|
|
}
|
|
if (pathname === "/ops" || pathname.startsWith("/ops/") || pathname.startsWith("/api/ops/")) return new NextResponse(null, { status: 404 });
|
|
|
|
// /api/contact is the public marketing form and /api/subscribe the newsletter sign-up. Both have
|
|
// their own same-origin gate, Turnstile check and rate limits, and by definition the people using
|
|
// them don't have an account yet.
|
|
// Everything under /api/staff belongs to the staff app, which has its own session in its own
|
|
// cookie — so a coordinator session can't be the precondition for reaching it. Each of these
|
|
// routes carries its own authorisation: activate/login/logout are how a wearer gets a session at
|
|
// all, /decide is authorised by a signed single-use token from a manager's email, and everything
|
|
// else calls currentStaff() first. scripts/e2e-staffapp.sh walks the routes and asserts each one
|
|
// refuses an anonymous caller, so this is a tested invariant rather than a convention to
|
|
// remember when adding the next one.
|
|
const publicStaff = pathname.startsWith("/api/staff/");
|
|
// /api/health is watched by an uptime monitor that has no account and never will. It returns a
|
|
// status code and nothing else — no facility, no schema, no error text.
|
|
// /api/rev answers "has anything changed here" for a coordinator OR a wearer, so it cannot sit
|
|
// behind the coordinator-only gate — it checks both session kinds itself and 401s on neither.
|
|
const publicApi = pathname.startsWith("/api/auth") || pathname === "/api/contact" || pathname === "/api/subscribe" || pathname === "/api/health" || pathname === "/api/rev" || publicStaff;
|
|
// /api/2fa manages your own account and checks the session itself; it is not public.
|
|
// Sign in and create account sit under /m but are how someone gets a session in the first place.
|
|
const publicM = pathname === "/m/login" || pathname === "/m/signup";
|
|
const needsAuth = pathname.startsWith("/app") || (pathname.startsWith("/m") && !publicM) || pathname.startsWith("/print") || (pathname.startsWith("/api") && !publicApi);
|
|
if (!needsAuth) return NextResponse.next();
|
|
|
|
const secret = process.env.SESSION_SECRET;
|
|
const ok = secret ? await verify(req.cookies.get(COOKIE_NAME)?.value, secret) : false;
|
|
if (ok) return NextResponse.next();
|
|
|
|
if (pathname.startsWith("/api")) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
|
const url = req.nextUrl.clone();
|
|
if (pathname.startsWith("/m")) {
|
|
url.pathname = "/m/login";
|
|
url.searchParams.set("next", pathname);
|
|
} else {
|
|
url.pathname = "/auth";
|
|
url.searchParams.set("next", pathname);
|
|
}
|
|
const res = NextResponse.redirect(url);
|
|
if (req.cookies.get(COOKIE_NAME)) res.cookies.set(COOKIE_NAME, "", { maxAge: 0, path: "/" }); // drop a stale/invalid cookie
|
|
return res;
|
|
}
|
|
|
|
export const config = {
|
|
/* ⛔ NEVER widen this to a catch-all.
|
|
*
|
|
* The handler tests `pathname.startsWith("/m")`, and "/my" starts with "/m". The staff app is
|
|
* safe today only because no pattern here matches /my — add one and every wearer, every ward
|
|
* manager and the staff Android app are redirected to /m/login, the COORDINATOR sign-in, with
|
|
* no session they could ever obtain. scripts/e2e-ops.sh asserts /my/signin still answers.
|
|
*
|
|
* The last five entries exist so `proxy()` runs at all on the ops hostname. Marketing routes
|
|
* are deliberately not listed: they are public content, they sit entirely behind Cloudflare
|
|
* Access on that host, and enumerating twenty of them would rot. What is listed is what would
|
|
* be harmful there — the root, every API route, and .well-known. */
|
|
matcher: [
|
|
"/app/:path*", "/m", "/m/:path*", "/print/:path*", "/api/:path*",
|
|
"/", "/ops", "/ops/:path*", "/robots.txt", "/sitemap.xml", "/.well-known/:path*",
|
|
],
|
|
};
|