← 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;
  }
}