← back to Letsbegin
lib/dw-central-session.ts
132 lines
/**
* dw-central-session.ts — the ONE shared, signed DW Central SSO cookie contract.
*
* SECURITY (TK-11776 finding #4): the previous `verifyDWCentralSession` accepted ANY
* cookie that base64-decoded to `username:timestamp` within 24h, with NO signature.
* So `base64('admin:'+Date.now())` was a valid session on every gated route — a live,
* confirmed auth bypass on an endpoint that mints Shopify products. This module closes
* it by requiring an HMAC-SHA256 signature that only a holder of DW_SESSION_SECRET can
* produce.
*
* LOCKSTEP: the ISSUER (DW Central @ dw.greendomainbrokers.com) and EVERY verifier
* (this app, Letsbegin, room-setting-app) MUST use THIS exact logic with the SAME
* DW_SESSION_SECRET, or real logins break. The signer + verifier live together here on
* purpose so both sides are provably identical — the issuer copies/imports this file.
*
* SIGNED TOKEN FORMAT (a strict superset of the old payload, minimal issuer diff):
* token = <payloadB64> + "." + <sigB64>
* payload = utf8("<username>:<timestampMillis>") // unchanged from the old format
* sig = HMAC-SHA256( ascii(payloadB64), DW_SESSION_SECRET )
* base64 is standard (its alphabet has no ".", so lastIndexOf('.') splits cleanly).
*
* There is NO legacy-unsigned acceptance path. A token without a valid signature —
* including every old forgeable cookie — is REJECTED. Fails CLOSED when the secret is
* unset (rejects; never accept-all).
*
* Runtime-agnostic on purpose: uses Web Crypto (globalThis.crypto.subtle) + btoa/atob,
* which exist in the Next.js Edge runtime (where middleware runs), in Node 18+, and in
* jest's node test env. `crypto.subtle.verify('HMAC', ...)` is itself constant-time, so
* we do NOT hand-roll a compare (and node:crypto.timingSafeEqual is unavailable on Edge).
*/
const MAX_AGE_MS = 86_400_000; // 24 hours (unchanged)
// --- base64 helpers (standard alphabet), runtime-agnostic --------------------------
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function utf8ToBase64(str: string): string {
return bytesToBase64(new TextEncoder().encode(str));
}
function base64ToUtf8(b64: string): string {
return new TextDecoder().decode(base64ToBytes(b64));
}
// Uint8Array -> BufferSource: a runtime no-op cast. TS 5.7+ types encode()/atob bytes as
// Uint8Array<ArrayBufferLike>, which some app tsconfigs reject where crypto.subtle wants
// BufferSource. This keeps the module byte-identical + compiling across all consumers.
function bs(u: Uint8Array): BufferSource {
return u as unknown as BufferSource;
}
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
bs(new TextEncoder().encode(secret)),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
);
}
/**
* Issue a signed DW Central session token. Used by DW Central at login (and by tests).
* Throws if the secret is missing — an unsigned token must never be minted.
*/
export async function signDWCentralSession(
username: string,
secret: string | undefined,
now: number = Date.now(),
): Promise<string> {
if (!secret) throw new Error('DW_SESSION_SECRET is required to sign a DW Central session');
if (!username) throw new Error('username is required to sign a DW Central session');
const payloadB64 = utf8ToBase64(`${username}:${now}`);
const key = await hmacKey(secret);
const sig = new Uint8Array(
await crypto.subtle.sign('HMAC', key, bs(new TextEncoder().encode(payloadB64))),
);
return `${payloadB64}.${bytesToBase64(sig)}`;
}
/**
* Verify a DW Central session token. Returns true ONLY when the signature is valid for
* the configured secret AND the payload is within the 24h window. Fails CLOSED on a
* missing secret, a missing/short signature, a bad signature, or any decode error.
*/
export async function verifyDWCentralSession(
token: string | undefined | null,
secret: string | undefined,
): Promise<boolean> {
try {
if (!secret) return false; // fail closed — never accept-all
if (!token || typeof token !== 'string') return false;
const dot = token.lastIndexOf('.');
if (dot <= 0 || dot >= token.length - 1) return false; // rejects ALL legacy unsigned cookies
const payloadB64 = token.slice(0, dot);
const sigB64 = token.slice(dot + 1);
if (!payloadB64 || !sigB64) return false;
const key = await hmacKey(secret);
const sigBytes = base64ToBytes(sigB64);
const ok = await crypto.subtle.verify( // constant-time HMAC verification
'HMAC',
key,
bs(sigBytes),
bs(new TextEncoder().encode(payloadB64)),
);
if (!ok) return false;
// Signature is valid → the payload is authentic. Now enforce the 24h age.
const decoded = base64ToUtf8(payloadB64);
const idx = decoded.indexOf(':');
if (idx <= 0) return false;
const username = decoded.slice(0, idx);
const timestamp = decoded.slice(idx + 1);
if (!username || !timestamp) return false;
const sessionTime = parseInt(timestamp, 10);
if (!Number.isFinite(sessionTime)) return false;
return Date.now() - sessionTime < MAX_AGE_MS;
} catch {
return false;
}
}