ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
/* Cloudflare Access application-JWT verification for the operations console.
|
||||
*
|
||||
* ops.threadcount.tech sits behind a Cloudflare Access application whose login methods are the
|
||||
* ThreadCount brand of Authentik (OIDC) and a one-time-PIN break-glass. Access puts a signed
|
||||
* assertion on every request it lets through (`Cf-Access-Jwt-Assertion`). Trusting that assertion
|
||||
* is what makes the console single-sign-on: the app mints an operator session from the verified
|
||||
* email instead of asking for a second password.
|
||||
*
|
||||
* Hand-rolled on node:crypto, the way lib/session.ts and lib/totp.ts are, and RS256-ONLY: the
|
||||
* only operation ever performed is an RSA verify, so the "alg: none" / HS256-with-a-public-key
|
||||
* confusion cannot apply. Every check fails CLOSED — a malformed token, wrong issuer or audience,
|
||||
* expired claim or bad signature returns null and the caller falls back to the password door. It
|
||||
* never throws. Active only when both variables are set; without them the console simply has no
|
||||
* SSO, and the fire escape is the whole door. */
|
||||
|
||||
const TEAM = process.env.CF_ACCESS_TEAM_DOMAIN; // e.g. example.cloudflareaccess.com
|
||||
const AUD = process.env.CF_ACCESS_AUD; // the Access application's AUD tag
|
||||
const ISSUER = TEAM ? `https://${TEAM}` : null;
|
||||
const CERTS_URL = TEAM ? `https://${TEAM}/cdn-cgi/access/certs` : null;
|
||||
|
||||
export function accessSsoConfigured(): boolean {
|
||||
return Boolean(TEAM && AUD);
|
||||
}
|
||||
|
||||
/** Where to land after single sign-on: only a path under /ops, never the sign-in page (a loop),
|
||||
* never a scheme or a protocol-relative host (an open redirect). Anything else → /ops. */
|
||||
export function safeOpsNext(raw: string | null | undefined): string {
|
||||
if (!raw || (raw !== "/ops" && !raw.startsWith("/ops/")) || raw.startsWith("/ops/login")) return "/ops";
|
||||
if (/[\\\s]/.test(raw) || raw.includes("://")) return "/ops";
|
||||
return raw;
|
||||
}
|
||||
|
||||
type Jwk = { kid?: string; kty?: string; n?: string; e?: string };
|
||||
let jwksCache: { keys: Jwk[]; fetchedAt: number } | null = null;
|
||||
const JWKS_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
async function getKeys(force = false): Promise<Jwk[]> {
|
||||
if (!force && jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_TTL_MS) return jwksCache.keys;
|
||||
const r = await fetch(CERTS_URL!, { cache: "no-store" });
|
||||
if (!r.ok) throw new Error("access certs fetch failed");
|
||||
const j = (await r.json()) as { keys?: Jwk[] };
|
||||
const keys = Array.isArray(j.keys) ? j.keys : [];
|
||||
jwksCache = { keys, fetchedAt: Date.now() };
|
||||
return keys;
|
||||
}
|
||||
|
||||
function decodeSeg(seg: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(seg, "base64url").toString("utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The verified, lower-cased email, or null on any failure whatsoever. */
|
||||
export async function verifyAccessJwt(token: string | null | undefined): Promise<string | null> {
|
||||
if (!token || !TEAM || !AUD || !ISSUER || !CERTS_URL) return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [h, p, s] = parts;
|
||||
const header = decodeSeg(h);
|
||||
const payload = decodeSeg(p);
|
||||
if (!header || !payload) return null;
|
||||
if (header.alg !== "RS256" || typeof header.kid !== "string") return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = payload.exp;
|
||||
const iat = payload.iat;
|
||||
if (typeof exp !== "number" || exp < now - 5) return null; // expired (small skew)
|
||||
if (typeof iat === "number" && iat > now + 60) return null; // issued in the future
|
||||
if (payload.iss !== ISSUER) return null;
|
||||
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||
if (!auds.includes(AUD)) return null;
|
||||
const rawEmail = payload.email;
|
||||
const email = typeof rawEmail === "string" ? rawEmail.trim().toLowerCase() : null;
|
||||
if (!email) return null;
|
||||
|
||||
let keys: Jwk[];
|
||||
try {
|
||||
keys = await getKeys();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let jwk = keys.find((k) => k.kid === header.kid);
|
||||
if (!jwk) {
|
||||
// Unknown kid — Access rotates keys; refresh once before giving up.
|
||||
try {
|
||||
keys = await getKeys(true);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
jwk = keys.find((k) => k.kid === header.kid);
|
||||
}
|
||||
if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e) return null;
|
||||
|
||||
let pub: crypto.KeyObject;
|
||||
try {
|
||||
pub = crypto.createPublicKey({ key: jwk as crypto.JsonWebKey, format: "jwk" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let ok = false;
|
||||
try {
|
||||
ok = crypto.verify("RSA-SHA256", Buffer.from(`${h}.${p}`), pub, Buffer.from(s, "base64url"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return ok ? email : null;
|
||||
}
|
||||
Reference in New Issue
Block a user