auth.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. /**
  2. * Shared-password auth for the admin dashboard.
  3. *
  4. * Exactly two humans use this dashboard, so there is no user table, no auth
  5. * provider and no session store: one password in a secret, and a long-lived
  6. * HMAC-signed cookie so you sign in once per browser.
  7. *
  8. * Properties worth keeping if you touch this file:
  9. * - the password is compared in constant time (over SHA-256 digests, so the
  10. * lengths always match and the comparison leaks nothing about the secret);
  11. * - the cookie is a signed assertion, not a lookup key — nothing is stored
  12. * server-side, and a tampered payload fails the HMAC check;
  13. * - the session is bound to a fingerprint of the password, so rotating
  14. * ADMIN_PASSWORD invalidates every cookie already out there.
  15. */
  16. const COOKIE_NAME = 'cg_admin_session';
  17. /** ~1 year. Long-lived on purpose: two users, one password, sign in once. */
  18. const SESSION_TTL_SECONDS = 365 * 24 * 60 * 60;
  19. const SESSION_VERSION = 1;
  20. const encoder = new TextEncoder();
  21. const decoder = new TextDecoder();
  22. interface SessionPayload {
  23. v: number;
  24. iat: number;
  25. exp: number;
  26. /** Fingerprint of the password this session was minted against. */
  27. pw: string;
  28. }
  29. function base64UrlEncode(bytes: Uint8Array): string {
  30. let binary = '';
  31. for (const byte of bytes) binary += String.fromCharCode(byte);
  32. return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  33. }
  34. function base64UrlDecode(text: string): Uint8Array | null {
  35. try {
  36. const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
  37. const bytes = new Uint8Array(binary.length);
  38. for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  39. return bytes;
  40. } catch {
  41. return null;
  42. }
  43. }
  44. // Importing the HMAC key costs a round through WebCrypto; cache it per isolate.
  45. // A secret rotation ships a new deployment, which means new isolates.
  46. let cachedKey: { secret: string; key: CryptoKey } | null = null;
  47. async function hmacKey(secret: string): Promise<CryptoKey> {
  48. if (cachedKey?.secret === secret) return cachedKey.key;
  49. const key = await crypto.subtle.importKey(
  50. 'raw',
  51. encoder.encode(secret),
  52. { name: 'HMAC', hash: 'SHA-256' },
  53. false,
  54. ['sign'],
  55. );
  56. cachedKey = { secret, key };
  57. return key;
  58. }
  59. async function sign(secret: string, payload: string): Promise<Uint8Array> {
  60. const signature = await crypto.subtle.sign('HMAC', await hmacKey(secret), encoder.encode(payload));
  61. return new Uint8Array(signature);
  62. }
  63. /**
  64. * Constant-time string equality. Both sides are hashed first so the digests are
  65. * always the same length — `timingSafeEqual` throws on a length mismatch, and a
  66. * throw would itself leak the length of the secret.
  67. */
  68. async function equalsInConstantTime(a: string, b: string): Promise<boolean> {
  69. const [digestA, digestB] = await Promise.all([
  70. crypto.subtle.digest('SHA-256', encoder.encode(a)),
  71. crypto.subtle.digest('SHA-256', encoder.encode(b)),
  72. ]);
  73. return crypto.subtle.timingSafeEqual(digestA, digestB);
  74. }
  75. /** Short, non-reversible marker of the current password, embedded in the session. */
  76. async function passwordFingerprint(password: string): Promise<string> {
  77. const digest = await crypto.subtle.digest('SHA-256', encoder.encode(`cg-admin-pw�${password}`));
  78. return base64UrlEncode(new Uint8Array(digest).subarray(0, 8));
  79. }
  80. export async function checkPassword(env: Env, submitted: string): Promise<boolean> {
  81. // A misconfigured deployment must not become an open dashboard.
  82. if (!env.ADMIN_PASSWORD || !env.SESSION_SECRET) return false;
  83. return equalsInConstantTime(submitted, env.ADMIN_PASSWORD);
  84. }
  85. export async function issueSession(env: Env): Promise<string> {
  86. const now = Math.floor(Date.now() / 1000);
  87. const payload: SessionPayload = {
  88. v: SESSION_VERSION,
  89. iat: now,
  90. exp: now + SESSION_TTL_SECONDS,
  91. pw: await passwordFingerprint(env.ADMIN_PASSWORD),
  92. };
  93. const encoded = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
  94. return `${encoded}.${base64UrlEncode(await sign(env.SESSION_SECRET, encoded))}`;
  95. }
  96. export async function hasValidSession(env: Env, request: Request): Promise<boolean> {
  97. if (!env.ADMIN_PASSWORD || !env.SESSION_SECRET) return false;
  98. const token = readSessionCookie(request);
  99. if (!token) return false;
  100. const dot = token.indexOf('.');
  101. if (dot <= 0 || dot === token.length - 1) return false;
  102. const encoded = token.slice(0, dot);
  103. const provided = base64UrlDecode(token.slice(dot + 1));
  104. if (!provided) return false;
  105. const expected = await sign(env.SESSION_SECRET, encoded);
  106. // Length is checked first: timingSafeEqual throws on mismatched lengths, and
  107. // the length of an HMAC-SHA256 tag is public anyway.
  108. if (provided.byteLength !== expected.byteLength) return false;
  109. if (!crypto.subtle.timingSafeEqual(provided, expected)) return false;
  110. const raw = base64UrlDecode(encoded);
  111. if (!raw) return false;
  112. let payload: SessionPayload;
  113. try {
  114. payload = JSON.parse(decoder.decode(raw)) as SessionPayload;
  115. } catch {
  116. return false;
  117. }
  118. if (payload?.v !== SESSION_VERSION) return false;
  119. if (typeof payload.exp !== 'number' || payload.exp <= Math.floor(Date.now() / 1000)) return false;
  120. // Rotating ADMIN_PASSWORD signs everyone out.
  121. return payload.pw === (await passwordFingerprint(env.ADMIN_PASSWORD));
  122. }
  123. export function readSessionCookie(request: Request): string | null {
  124. const header = request.headers.get('cookie');
  125. if (!header) return null;
  126. for (const part of header.split(';')) {
  127. const cookie = part.trim();
  128. if (cookie.startsWith(`${COOKIE_NAME}=`)) return cookie.slice(COOKIE_NAME.length + 1);
  129. }
  130. return null;
  131. }
  132. export function sessionCookie(token: string): string {
  133. // Secure is accepted on http://localhost too, so `wrangler dev` still works.
  134. return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_SECONDS}`;
  135. }
  136. export function clearedSessionCookie(): string {
  137. return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
  138. }
  139. /**
  140. * Rejects a cross-site form post. SameSite=Lax already blocks the cookie on a
  141. * cross-site POST; this is the belt to that pair of braces.
  142. */
  143. export function isSameOriginPost(request: Request): boolean {
  144. const origin = request.headers.get('origin');
  145. if (!origin) return true; // Absent on some legitimate non-browser clients (curl).
  146. try {
  147. return new URL(origin).host === new URL(request.url).host;
  148. } catch {
  149. return false;
  150. }
  151. }