diff --git a/.changeset/fast-plums-peel.md b/.changeset/fast-plums-peel.md new file mode 100644 index 0000000000..a533cd425f --- /dev/null +++ b/.changeset/fast-plums-peel.md @@ -0,0 +1,22 @@ +--- +"@react-router/architect": major +"@react-router/cloudflare": major +"@react-router/node": major +"react-router": major +--- + +For Remix consumers migrating to React Router, the `crypto` global from the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is now required when using cookie and session APIs. This means that the following APIs are provided from `react-router` rather than platform-specific packages: + +- `createCookie` +- `createCookieSessionStorage` +- `createMemorySessionStorage` +- `createSessionStorage` + +For consumers running older versions of Node, the `installGlobals` function from `@remix-run/node` has been updated to define `globalThis.crypto`, using [Node's `require('node:crypto').webcrypto` implementation.](https://nodejs.org/api/webcrypto.html) + +Since platform-specific packages no longer need to implement this API, the following low-level APIs have been removed: + +- `createCookieFactory` +- `createSessionStorageFactory` +- `createCookieSessionStorageFactory` +- `createMemorySessionStorageFactory` diff --git a/integration/set-cookie-revalidation-test.ts b/integration/set-cookie-revalidation-test.ts index ef324a8c06..c6a0f74925 100644 --- a/integration/set-cookie-revalidation-test.ts +++ b/integration/set-cookie-revalidation-test.ts @@ -18,7 +18,7 @@ test.describe("set-cookie revalidation", () => { fixture = await createFixture({ files: { "app/session.server.ts": js` - import { createCookieSessionStorage } from "@react-router/node"; + import { createCookieSessionStorage } from "react-router"; export let MESSAGE_KEY = "message"; @@ -33,8 +33,8 @@ test.describe("set-cookie revalidation", () => { `, "app/root.tsx": js` - import { json } from "react-router"; import { + json, Links, Meta, Outlet, diff --git a/packages/react-router-architect/sessions/arcTableSessionStorage.ts b/packages/react-router-architect/sessions/arcTableSessionStorage.ts index 40988a4b2f..135a758d2e 100644 --- a/packages/react-router-architect/sessions/arcTableSessionStorage.ts +++ b/packages/react-router-architect/sessions/arcTableSessionStorage.ts @@ -1,10 +1,9 @@ -import * as crypto from "node:crypto"; import type { SessionData, SessionStorage, SessionIdStorageStrategy, } from "react-router"; -import { createSessionStorage } from "@react-router/node"; +import { createSessionStorage } from "react-router"; import arc from "@architect/functions"; import type { ArcTable } from "@architect/functions/types/tables"; @@ -64,7 +63,7 @@ export function createArcTableSessionStorage< async createData(data, expires) { let table = await getTable(); while (true) { - let randomBytes = crypto.randomBytes(8); + let randomBytes = crypto.getRandomValues(new Uint8Array(8)); // This storage manages an id space of 2^64 ids, which is far greater // than the maximum number of files allowed on an NTFS or ext4 volume // (2^32). However, the larger id space should help to avoid collisions diff --git a/packages/react-router-cloudflare/crypto.ts b/packages/react-router-cloudflare/crypto.ts deleted file mode 100644 index 2306ec0714..0000000000 --- a/packages/react-router-cloudflare/crypto.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { SignFunction, UnsignFunction } from "react-router"; - -const encoder = new TextEncoder(); - -export const sign: SignFunction = async (value, secret) => { - let key = await createKey(secret, ["sign"]); - let data = encoder.encode(value); - let signature = await crypto.subtle.sign("HMAC", key, data); - let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace( - /=+$/, - "" - ); - - return value + "." + hash; -}; - -export const unsign: UnsignFunction = async (signed, secret) => { - let index = signed.lastIndexOf("."); - let value = signed.slice(0, index); - let hash = signed.slice(index + 1); - - let key = await createKey(secret, ["verify"]); - let data = encoder.encode(value); - let signature = byteStringToUint8Array(atob(hash)); - let valid = await crypto.subtle.verify("HMAC", key, signature, data); - - return valid ? value : false; -}; - -async function createKey( - secret: string, - usages: CryptoKey["usages"] -): Promise { - let key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - usages - ); - - return key; -} - -function byteStringToUint8Array(byteString: string): Uint8Array { - let array = new Uint8Array(byteString.length); - - for (let i = 0; i < byteString.length; i++) { - array[i] = byteString.charCodeAt(i); - } - - return array; -} diff --git a/packages/react-router-cloudflare/implementations.ts b/packages/react-router-cloudflare/implementations.ts deleted file mode 100644 index facc534702..0000000000 --- a/packages/react-router-cloudflare/implementations.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - createCookieFactory, - createCookieSessionStorageFactory, - createMemorySessionStorageFactory, - createSessionStorageFactory, -} from "react-router"; - -import { sign, unsign } from "./crypto"; - -export const createCookie = createCookieFactory({ sign, unsign }); -export const createCookieSessionStorage = - createCookieSessionStorageFactory(createCookie); -export const createSessionStorage = createSessionStorageFactory(createCookie); -export const createMemorySessionStorage = - createMemorySessionStorageFactory(createSessionStorage); diff --git a/packages/react-router-cloudflare/index.ts b/packages/react-router-cloudflare/index.ts index 8e7f367b81..5be9585e15 100644 --- a/packages/react-router-cloudflare/index.ts +++ b/packages/react-router-cloudflare/index.ts @@ -1,12 +1,5 @@ export { createWorkersKVSessionStorage } from "./sessions/workersKVStorage"; -export { - createCookie, - createCookieSessionStorage, - createMemorySessionStorage, - createSessionStorage, -} from "./implementations"; - export type { createPagesFunctionHandlerParams, GetLoadContextFunction, diff --git a/packages/react-router-cloudflare/sessions/workersKVStorage.ts b/packages/react-router-cloudflare/sessions/workersKVStorage.ts index 6aa2f75f3c..512839fad2 100644 --- a/packages/react-router-cloudflare/sessions/workersKVStorage.ts +++ b/packages/react-router-cloudflare/sessions/workersKVStorage.ts @@ -3,8 +3,7 @@ import type { SessionIdStorageStrategy, SessionData, } from "react-router"; - -import { createSessionStorage } from "../implementations"; +import { createSessionStorage } from "react-router"; interface WorkersKVSessionStorageOptions { /** @@ -36,8 +35,7 @@ export function createWorkersKVSessionStorage< cookie, async createData(data, expires) { while (true) { - let randomBytes = new Uint8Array(8); - crypto.getRandomValues(randomBytes); + let randomBytes = crypto.getRandomValues(new Uint8Array(8)); // This storage manages an id space of 2^64 ids, which is far greater // than the maximum number of files allowed on an NTFS or ext4 volume // (2^32). However, the larger id space should help to avoid collisions diff --git a/packages/react-router-node/__tests__/sessions-test.ts b/packages/react-router-node/__tests__/sessions-test.ts index 5774e16b8b..de3fac30a7 100644 --- a/packages/react-router-node/__tests__/sessions-test.ts +++ b/packages/react-router-node/__tests__/sessions-test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + import path from "node:path"; import { promises as fsp } from "node:fs"; import os from "node:os"; diff --git a/packages/react-router-node/crypto.ts b/packages/react-router-node/crypto.ts deleted file mode 100644 index eea6db264e..0000000000 --- a/packages/react-router-node/crypto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import cookieSignature from "cookie-signature"; -import type { SignFunction, UnsignFunction } from "react-router"; - -export const sign: SignFunction = async (value, secret) => { - return cookieSignature.sign(value, secret); -}; - -export const unsign: UnsignFunction = async ( - signed: string, - secret: string -) => { - return cookieSignature.unsign(signed, secret); -}; diff --git a/packages/react-router-node/globals.ts b/packages/react-router-node/globals.ts index 3f148915df..cbd188c312 100644 --- a/packages/react-router-node/globals.ts +++ b/packages/react-router-node/globals.ts @@ -6,6 +6,7 @@ import { Request as NodeRequest, Response as NodeResponse, } from "undici"; +import { webcrypto as nodeWebCrypto } from "node:crypto"; declare global { namespace NodeJS { @@ -24,6 +25,8 @@ declare global { ReadableStream: typeof ReadableStream; WritableStream: typeof WritableStream; + + crypto: typeof nodeWebCrypto; } } @@ -44,4 +47,9 @@ export function installGlobals() { global.fetch = nodeFetch; // @ts-expect-error - overriding globals global.FormData = NodeFormData; + + if (!global.crypto) { + // @ts-expect-error - overriding globals + global.crypto = nodeWebCrypto; + } } diff --git a/packages/react-router-node/implementations.ts b/packages/react-router-node/implementations.ts deleted file mode 100644 index facc534702..0000000000 --- a/packages/react-router-node/implementations.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - createCookieFactory, - createCookieSessionStorageFactory, - createMemorySessionStorageFactory, - createSessionStorageFactory, -} from "react-router"; - -import { sign, unsign } from "./crypto"; - -export const createCookie = createCookieFactory({ sign, unsign }); -export const createCookieSessionStorage = - createCookieSessionStorageFactory(createCookie); -export const createSessionStorage = createSessionStorageFactory(createCookie); -export const createMemorySessionStorage = - createMemorySessionStorageFactory(createSessionStorage); diff --git a/packages/react-router-node/index.ts b/packages/react-router-node/index.ts index f0b9527593..eeb97a1830 100644 --- a/packages/react-router-node/index.ts +++ b/packages/react-router-node/index.ts @@ -7,13 +7,6 @@ export { NodeOnDiskFile, } from "./upload/fileUploadHandler"; -export { - createCookie, - createCookieSessionStorage, - createMemorySessionStorage, - createSessionStorage, -} from "./implementations"; - export { createReadableStreamFromReadable, readableStreamToString, diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index 4f467ec40c..c6d5ee06aa 100644 --- a/packages/react-router-node/package.json +++ b/packages/react-router-node/package.json @@ -35,13 +35,11 @@ }, "dependencies": { "@web3-storage/multipart-parser": "^1.0.0", - "cookie-signature": "^1.1.0", "source-map-support": "^0.5.21", "stream-slice": "^0.1.2", "undici": "^6.19.2" }, "devDependencies": { - "@types/cookie-signature": "^1.0.3", "@types/source-map-support": "^0.5.4", "react-router": "workspace:*", "typescript": "^5.1.6" diff --git a/packages/react-router-node/sessions/fileStorage.ts b/packages/react-router-node/sessions/fileStorage.ts index 76bd82f5f7..0df4bacb4d 100644 --- a/packages/react-router-node/sessions/fileStorage.ts +++ b/packages/react-router-node/sessions/fileStorage.ts @@ -1,4 +1,3 @@ -import * as crypto from "node:crypto"; import { promises as fsp } from "node:fs"; import * as path from "node:path"; import type { @@ -6,8 +5,7 @@ import type { SessionIdStorageStrategy, SessionData, } from "react-router"; - -import { createSessionStorage } from "../implementations"; +import { createSessionStorage } from "react-router"; interface FileSessionStorageOptions { /** @@ -40,9 +38,7 @@ export function createFileSessionStorage({ let content = JSON.stringify({ data, expires }); while (true) { - // TODO: Once Node v19 is supported we should use the globally provided - // Web Crypto API's crypto.getRandomValues() function here instead. - let randomBytes = crypto.webcrypto.getRandomValues(new Uint8Array(8)); + let randomBytes = crypto.getRandomValues(new Uint8Array(8)); // This storage manages an id space of 2^64 ids, which is far greater // than the maximum number of files allowed on an NTFS or ext4 volume // (2^32). However, the larger id space should help to avoid collisions diff --git a/packages/react-router/__tests__/server-runtime/cookies-test.ts b/packages/react-router/__tests__/server-runtime/cookies-test.ts index 0cf15ecf27..6f45cad5d5 100644 --- a/packages/react-router/__tests__/server-runtime/cookies-test.ts +++ b/packages/react-router/__tests__/server-runtime/cookies-test.ts @@ -1,25 +1,8 @@ -import { - createCookieFactory, - isCookie, -} from "../../lib/server-runtime/cookies"; -import type { - SignFunction, - UnsignFunction, -} from "../../lib/server-runtime/crypto"; - -const sign: SignFunction = async (value, secret) => { - return JSON.stringify({ value, secret }); -}; -const unsign: UnsignFunction = async (signed, secret) => { - try { - let unsigned = JSON.parse(signed); - if (unsigned.secret !== secret) return false; - return unsigned.value; - } catch (e: unknown) { - return false; - } -}; -const createCookie = createCookieFactory({ sign, unsign }); +/** + * @jest-environment node + */ + +import { createCookie, isCookie } from "../../lib/server-runtime/cookies"; function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0]; diff --git a/packages/react-router/__tests__/server-runtime/sessions-test.ts b/packages/react-router/__tests__/server-runtime/sessions-test.ts index 32e8027006..13e16644a3 100644 --- a/packages/react-router/__tests__/server-runtime/sessions-test.ts +++ b/packages/react-router/__tests__/server-runtime/sessions-test.ts @@ -1,39 +1,15 @@ -import { createCookieFactory } from "../../lib/server-runtime/cookies"; -import type { - SignFunction, - UnsignFunction, -} from "../../lib/server-runtime/crypto"; -import { - createSession, - createSessionStorageFactory, - isSession, -} from "../../lib/server-runtime/sessions"; -import { createCookieSessionStorageFactory } from "../../lib/server-runtime/sessions/cookieStorage"; -import { createMemorySessionStorageFactory } from "../../lib/server-runtime/sessions/memoryStorage"; +/** + * @jest-environment node + */ + +import { createSession, isSession } from "../../lib/server-runtime/sessions"; +import { createCookieSessionStorage } from "../../lib/server-runtime/sessions/cookieStorage"; +import { createMemorySessionStorage } from "../../lib/server-runtime/sessions/memoryStorage"; function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0]; } -const sign: SignFunction = async (value, secret) => { - return JSON.stringify({ value, secret }); -}; -const unsign: UnsignFunction = async (signed, secret) => { - try { - let unsigned = JSON.parse(signed); - if (unsigned.secret !== secret) return false; - return unsigned.value; - } catch (e: unknown) { - return false; - } -}; -const createCookie = createCookieFactory({ sign, unsign }); -const createCookieSessionStorage = - createCookieSessionStorageFactory(createCookie); -const createSessionStorage = createSessionStorageFactory(createCookie); -const createMemorySessionStorage = - createMemorySessionStorageFactory(createSessionStorage); - describe("Session", () => { it("has an empty id by default", () => { expect(createSession().id).toEqual(""); @@ -241,104 +217,6 @@ describe("Cookie session storage", () => { }); }); -describe("Custom cookie-backed session storage", () => { - let memoryBacking = {}; - let createCookieBackedSessionStorage = - createSessionStorageFactory(createCookie); - let implementation = { - createData(data) { - let id = Math.random().toString(36).substring(2, 10); - memoryBacking[id] = data; - return Promise.resolve(id); - }, - readData(id) { - return Promise.resolve(memoryBacking[id] || null); - }, - updateData(id, data) { - memoryBacking[id] = data; - return Promise.resolve(); - }, - deleteData(id) { - memoryBacking[id] = null; - return Promise.resolve(memoryBacking[id]); - }, - }; - - it("persists session data across requests", async () => { - let { getSession, commitSession } = createCookieBackedSessionStorage({ - ...implementation, - cookie: createCookie("test", { secrets: ["test"] }), - }); - let session = await getSession(); - session.set("user", "mjackson"); - let setCookie = await commitSession(session); - session = await getSession(getCookieFromSetCookie(setCookie)); - - expect(session.get("user")).toEqual("mjackson"); - }); - - it("returns an empty session for cookies that are not signed properly", async () => { - let { getSession, commitSession } = createCookieBackedSessionStorage({ - ...implementation, - cookie: createCookie("test", { secrets: ["test"] }), - }); - let session = await getSession(); - session.set("user", "mjackson"); - - expect(session.get("user")).toEqual("mjackson"); - - let setCookie = await commitSession(session); - session = await getSession( - // Tamper with the session cookie... - getCookieFromSetCookie(setCookie).slice(0, -1) - ); - - expect(session.get("user")).toBeUndefined(); - }); - - it('"makes the default path of cookies to be /', async () => { - let { getSession, commitSession } = createCookieBackedSessionStorage({ - ...implementation, - cookie: createCookie("test", { secrets: ["test"] }), - }); - let session = await getSession(); - session.set("user", "mjackson"); - let setCookie = await commitSession(session); - expect(setCookie).toContain("Path=/"); - }); - - it("destroys sessions using a past date", async () => { - let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); - let { getSession, destroySession } = createCookieBackedSessionStorage({ - ...implementation, - cookie: createCookie("test", { secrets: ["test"] }), - }); - let session = await getSession(); - let setCookie = await destroySession(session); - expect(setCookie).toMatchInlineSnapshot( - `"test=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` - ); - spy.mockRestore(); - }); - - it("destroys sessions that leverage maxAge", async () => { - let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); - let { getSession, destroySession } = createCookieBackedSessionStorage({ - ...implementation, - cookie: createCookie("test", { - maxAge: 60 * 60, // 1 hour - secrets: ["test"], - }), - }); - let session = await getSession(); - let setCookie = await destroySession(session); - expect(setCookie).toMatchInlineSnapshot( - `"test=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` - ); - spy.mockRestore(); - }); -}); - function spyConsole() { // https://github.com/facebook/react/issues/7047 let spy: any = {}; diff --git a/packages/react-router/__tests__/setup.ts b/packages/react-router/__tests__/setup.ts index f2b70a364a..357b5924bc 100644 --- a/packages/react-router/__tests__/setup.ts +++ b/packages/react-router/__tests__/setup.ts @@ -45,3 +45,8 @@ if (!globalThis.File) { const { File } = require("undici"); globalThis.File = File; } + +if (!globalThis.crypto) { + const { webcrypto } = require("node:crypto"); + globalThis.crypto = webcrypto; +} diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index cb5e2dc842..22a44815f8 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -217,7 +217,7 @@ export type { RoutesTestStubProps } from "./lib/dom/ssr/routes-test-stub"; export { createRoutesStub } from "./lib/dom/ssr/routes-test-stub"; // Expose old @remix-run/server-runtime API, minus duplicate APIs -export { createCookieFactory, isCookie } from "./lib/server-runtime/cookies"; +export { createCookie, isCookie } from "./lib/server-runtime/cookies"; export { composeUploadHandlers as unstable_composeUploadHandlers, parseMultipartFormData as unstable_parseMultipartFormData, @@ -231,32 +231,23 @@ export { export { createRequestHandler } from "./lib/server-runtime/server"; export { createSession, - createSessionStorageFactory, + createSessionStorage, isSession, } from "./lib/server-runtime/sessions"; -export { createCookieSessionStorageFactory } from "./lib/server-runtime/sessions/cookieStorage"; -export { createMemorySessionStorageFactory } from "./lib/server-runtime/sessions/memoryStorage"; +export { createCookieSessionStorage } from "./lib/server-runtime/sessions/cookieStorage"; +export { createMemorySessionStorage } from "./lib/server-runtime/sessions/memoryStorage"; export { createMemoryUploadHandler as unstable_createMemoryUploadHandler } from "./lib/server-runtime/upload/memoryUploadHandler"; export { MaxPartSizeExceededError } from "./lib/server-runtime/upload/errors"; export { setDevServerHooks as unstable_setDevServerHooks } from "./lib/server-runtime/dev"; -export type { - CreateCookieFunction, - IsCookieFunction, -} from "./lib/server-runtime/cookies"; +export type { IsCookieFunction } from "./lib/server-runtime/cookies"; // TODO: (v7) Clean up code paths for these exports // export type { // JsonFunction, // RedirectFunction, // } from "./lib/server-runtime/responses"; export type { CreateRequestHandlerFunction } from "./lib/server-runtime/server"; -export type { - CreateSessionFunction, - CreateSessionStorageFunction, - IsSessionFunction, -} from "./lib/server-runtime/sessions"; -export type { CreateCookieSessionStorageFunction } from "./lib/server-runtime/sessions/cookieStorage"; -export type { CreateMemorySessionStorageFunction } from "./lib/server-runtime/sessions/memoryStorage"; +export type { IsSessionFunction } from "./lib/server-runtime/sessions"; export type { HandleDataRequestFunction, @@ -283,8 +274,6 @@ export type { CookieSignatureOptions, } from "./lib/server-runtime/cookies"; -export type { SignFunction, UnsignFunction } from "./lib/server-runtime/crypto"; - export type { AppLoadContext } from "./lib/server-runtime/data"; export type { diff --git a/packages/react-router/lib/server-runtime/cookies.ts b/packages/react-router/lib/server-runtime/cookies.ts index 80884fa8fc..796437f438 100644 --- a/packages/react-router/lib/server-runtime/cookies.ts +++ b/packages/react-router/lib/server-runtime/cookies.ts @@ -1,7 +1,7 @@ import type { CookieParseOptions, CookieSerializeOptions } from "cookie"; import { parse, serialize } from "cookie"; -import type { SignFunction, UnsignFunction } from "./crypto"; +import { sign, unsign } from "./crypto"; import { warnOnce } from "./warnings"; export type { CookieParseOptions, CookieSerializeOptions }; @@ -67,67 +67,55 @@ export interface Cookie { serialize(value: any, options?: CookieSerializeOptions): Promise; } -export type CreateCookieFunction = ( - name: string, - cookieOptions?: CookieOptions -) => Cookie; - /** * Creates a logical container for managing a browser cookie from the server. - * - * @see https://remix.run/utils/cookies#createcookie */ -export const createCookieFactory = - ({ - sign, - unsign, - }: { - sign: SignFunction; - unsign: UnsignFunction; - }): CreateCookieFunction => - (name, cookieOptions = {}) => { - let { secrets = [], ...options } = { - path: "/", - sameSite: "lax" as const, - ...cookieOptions, - }; - - warnOnceAboutExpiresCookie(name, options.expires); +export const createCookie = ( + name: string, + cookieOptions: CookieOptions = {} +): Cookie => { + let { secrets = [], ...options } = { + path: "/", + sameSite: "lax" as const, + ...cookieOptions, + }; - return { - get name() { - return name; - }, - get isSigned() { - return secrets.length > 0; - }, - get expires() { - // Max-Age takes precedence over Expires - return typeof options.maxAge !== "undefined" - ? new Date(Date.now() + options.maxAge * 1000) - : options.expires; - }, - async parse(cookieHeader, parseOptions) { - if (!cookieHeader) return null; - let cookies = parse(cookieHeader, { ...options, ...parseOptions }); - return name in cookies - ? cookies[name] === "" - ? "" - : await decodeCookieValue(unsign, cookies[name], secrets) - : null; - }, - async serialize(value, serializeOptions) { - return serialize( - name, - value === "" ? "" : await encodeCookieValue(sign, value, secrets), - { - ...options, - ...serializeOptions, - } - ); - }, - }; + warnOnceAboutExpiresCookie(name, options.expires); + + return { + get name() { + return name; + }, + get isSigned() { + return secrets.length > 0; + }, + get expires() { + // Max-Age takes precedence over Expires + return typeof options.maxAge !== "undefined" + ? new Date(Date.now() + options.maxAge * 1000) + : options.expires; + }, + async parse(cookieHeader, parseOptions) { + if (!cookieHeader) return null; + let cookies = parse(cookieHeader, { ...options, ...parseOptions }); + return name in cookies + ? cookies[name] === "" + ? "" + : await decodeCookieValue(cookies[name], secrets) + : null; + }, + async serialize(value, serializeOptions) { + return serialize( + name, + value === "" ? "" : await encodeCookieValue(value, secrets), + { + ...options, + ...serializeOptions, + } + ); + }, }; +}; export type IsCookieFunction = (object: any) => object is Cookie; @@ -147,7 +135,6 @@ export const isCookie: IsCookieFunction = (object): object is Cookie => { }; async function encodeCookieValue( - sign: SignFunction, value: any, secrets: string[] ): Promise { @@ -161,7 +148,6 @@ async function encodeCookieValue( } async function decodeCookieValue( - unsign: UnsignFunction, value: string, secrets: string[] ): Promise { diff --git a/packages/react-router/lib/server-runtime/crypto.ts b/packages/react-router/lib/server-runtime/crypto.ts index daedea742b..7a09048cd0 100644 --- a/packages/react-router/lib/server-runtime/crypto.ts +++ b/packages/react-router/lib/server-runtime/crypto.ts @@ -1,64 +1,52 @@ -export type SignFunction = (value: string, secret: string) => Promise; +const encoder = new TextEncoder(); -export type UnsignFunction = ( - cookie: string, - secret: string -) => Promise; - -// TODO: Once Node v19 is supported we should use the globally provided -// Web Crypto API's and re-enable this code-path in "./cookies.ts" -// instead of referencing the `sign` and `unsign` globals. - -// const encoder = new TextEncoder(); - -// export const sign: SignFunction = async ( -// value: string, -// secret: string -// ): Promise => { -// let data = encoder.encode(value); -// let key = await createKey(secret, ["sign"]); -// let signature = await crypto.subtle.sign("HMAC", key, data); -// let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace( -// /=+$/, -// "" -// ); - -// return value + "." + hash; -// }; - -// export const unsign: UnsignFunction = async ( -// cookie: string, -// secret: string -// ): Promise => { -// let value = cookie.slice(0, cookie.lastIndexOf(".")); -// let hash = cookie.slice(cookie.lastIndexOf(".") + 1); +export const sign = async (value: string, secret: string): Promise => { + let data = encoder.encode(value); + let key = await createKey(secret, ["sign"]); + let signature = await crypto.subtle.sign("HMAC", key, data); + let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace( + /=+$/, + "" + ); -// let data = encoder.encode(value); -// let key = await createKey(secret, ["verify"]); -// let signature = byteStringToUint8Array(atob(hash)); -// let valid = await crypto.subtle.verify("HMAC", key, signature, data); + return value + "." + hash; +}; -// return valid ? value : false; -// }; - -// const createKey = async ( -// secret: string, -// usages: CryptoKey["usages"] -// ): Promise => -// crypto.subtle.importKey( -// "raw", -// encoder.encode(secret), -// { name: "HMAC", hash: "SHA-256" }, -// false, -// usages -// ); - -// const byteStringToUint8Array = (byteString: string): Uint8Array => { -// let array = new Uint8Array(byteString.length); - -// for (let i = 0; i < byteString.length; i++) { -// array[i] = byteString.charCodeAt(i); -// } - -// return array; -// }; +export const unsign = async ( + cookie: string, + secret: string +): Promise => { + let index = cookie.lastIndexOf("."); + let value = cookie.slice(0, index); + let hash = cookie.slice(index + 1); + + let data = encoder.encode(value); + + let key = await createKey(secret, ["verify"]); + let signature = byteStringToUint8Array(atob(hash)); + let valid = await crypto.subtle.verify("HMAC", key, signature, data); + + return valid ? value : false; +}; + +const createKey = async ( + secret: string, + usages: CryptoKey["usages"] +): Promise => + crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + usages + ); + +function byteStringToUint8Array(byteString: string): Uint8Array { + let array = new Uint8Array(byteString.length); + + for (let i = 0; i < byteString.length; i++) { + array[i] = byteString.charCodeAt(i); + } + + return array; +} diff --git a/packages/react-router/lib/server-runtime/sessions.ts b/packages/react-router/lib/server-runtime/sessions.ts index c00eedb232..79bd5ba396 100644 --- a/packages/react-router/lib/server-runtime/sessions.ts +++ b/packages/react-router/lib/server-runtime/sessions.ts @@ -1,7 +1,7 @@ import type { CookieParseOptions, CookieSerializeOptions } from "cookie"; -import type { Cookie, CookieOptions, CreateCookieFunction } from "./cookies"; -import { isCookie } from "./cookies"; +import type { Cookie, CookieOptions } from "./cookies"; +import { createCookie, isCookie } from "./cookies"; import { warnOnce } from "./warnings"; /** @@ -245,63 +245,58 @@ export interface SessionIdStorageStrategy< deleteData: (id: string) => Promise; } -export type CreateSessionStorageFunction = < - Data = SessionData, - FlashData = Data ->( - strategy: SessionIdStorageStrategy -) => SessionStorage; - /** * Creates a SessionStorage object using a SessionIdStorageStrategy. * * Note: This is a low-level API that should only be used if none of the * existing session storage options meet your requirements. - * - * @see https://remix.run/utils/sessions#createsessionstorage */ -export const createSessionStorageFactory = - (createCookie: CreateCookieFunction): CreateSessionStorageFunction => - ({ cookie: cookieArg, createData, readData, updateData, deleteData }) => { - let cookie = isCookie(cookieArg) - ? cookieArg - : createCookie(cookieArg?.name || "__session", cookieArg); - - warnOnceAboutSigningSessionCookie(cookie); - - return { - async getSession(cookieHeader, options) { - let id = cookieHeader && (await cookie.parse(cookieHeader, options)); - let data = id && (await readData(id)); - return createSession(data || {}, id || ""); - }, - async commitSession(session, options) { - let { id, data } = session; - let expires = - options?.maxAge != null - ? new Date(Date.now() + options.maxAge * 1000) - : options?.expires != null - ? options.expires - : cookie.expires; +export function createSessionStorage({ + cookie: cookieArg, + createData, + readData, + updateData, + deleteData, +}: SessionIdStorageStrategy): SessionStorage { + let cookie = isCookie(cookieArg) + ? cookieArg + : createCookie(cookieArg?.name || "__session", cookieArg); + + warnOnceAboutSigningSessionCookie(cookie); - if (id) { - await updateData(id, data, expires); - } else { - id = await createData(data, expires); - } + return { + async getSession(cookieHeader, options) { + let id = cookieHeader && (await cookie.parse(cookieHeader, options)); + let data = id && (await readData(id)); + return createSession(data || {}, id || ""); + }, + async commitSession(session, options) { + let { id, data } = session; + let expires = + options?.maxAge != null + ? new Date(Date.now() + options.maxAge * 1000) + : options?.expires != null + ? options.expires + : cookie.expires; + + if (id) { + await updateData(id, data, expires); + } else { + id = await createData(data, expires); + } - return cookie.serialize(id, options); - }, - async destroySession(session, options) { - await deleteData(session.id); - return cookie.serialize("", { - ...options, - maxAge: undefined, - expires: new Date(0), - }); - }, - }; + return cookie.serialize(id, options); + }, + async destroySession(session, options) { + await deleteData(session.id); + return cookie.serialize("", { + ...options, + maxAge: undefined, + expires: new Date(0), + }); + }, }; +} export function warnOnceAboutSigningSessionCookie(cookie: Cookie) { warnOnce( diff --git a/packages/react-router/lib/server-runtime/sessions/cookieStorage.ts b/packages/react-router/lib/server-runtime/sessions/cookieStorage.ts index 09a2e76568..fbfe5ad046 100644 --- a/packages/react-router/lib/server-runtime/sessions/cookieStorage.ts +++ b/packages/react-router/lib/server-runtime/sessions/cookieStorage.ts @@ -1,5 +1,4 @@ -import type { CreateCookieFunction } from "../cookies"; -import { isCookie } from "../cookies"; +import { createCookie, isCookie } from "../cookies"; import type { SessionStorage, SessionIdStorageStrategy, @@ -15,13 +14,6 @@ interface CookieSessionStorageOptions { cookie?: SessionIdStorageStrategy["cookie"]; } -export type CreateCookieSessionStorageFunction = < - Data = SessionData, - FlashData = Data ->( - options?: CookieSessionStorageOptions -) => SessionStorage; - /** * Creates and returns a SessionStorage object that stores all session data * directly in the session cookie itself. @@ -30,40 +22,42 @@ export type CreateCookieSessionStorageFunction = < * needed, and can help to simplify some load-balanced scenarios. However, it * also has the limitation that serialized session data may not exceed the * browser's maximum cookie size. Trade-offs! - * - * @see https://remix.run/utils/sessions#createcookiesessionstorage */ -export const createCookieSessionStorageFactory = - (createCookie: CreateCookieFunction): CreateCookieSessionStorageFunction => - ({ cookie: cookieArg } = {}) => { - let cookie = isCookie(cookieArg) - ? cookieArg - : createCookie(cookieArg?.name || "__session", cookieArg); +export function createCookieSessionStorage< + Data = SessionData, + FlashData = Data +>({ cookie: cookieArg }: CookieSessionStorageOptions = {}): SessionStorage< + Data, + FlashData +> { + let cookie = isCookie(cookieArg) + ? cookieArg + : createCookie(cookieArg?.name || "__session", cookieArg); - warnOnceAboutSigningSessionCookie(cookie); + warnOnceAboutSigningSessionCookie(cookie); - return { - async getSession(cookieHeader, options) { - return createSession( - (cookieHeader && (await cookie.parse(cookieHeader, options))) || {} + return { + async getSession(cookieHeader, options) { + return createSession( + (cookieHeader && (await cookie.parse(cookieHeader, options))) || {} + ); + }, + async commitSession(session, options) { + let serializedCookie = await cookie.serialize(session.data, options); + if (serializedCookie.length > 4096) { + throw new Error( + "Cookie length will exceed browser maximum. Length: " + + serializedCookie.length ); - }, - async commitSession(session, options) { - let serializedCookie = await cookie.serialize(session.data, options); - if (serializedCookie.length > 4096) { - throw new Error( - "Cookie length will exceed browser maximum. Length: " + - serializedCookie.length - ); - } - return serializedCookie; - }, - async destroySession(_session, options) { - return cookie.serialize("", { - ...options, - maxAge: undefined, - expires: new Date(0), - }); - }, - }; + } + return serializedCookie; + }, + async destroySession(_session, options) { + return cookie.serialize("", { + ...options, + maxAge: undefined, + expires: new Date(0), + }); + }, }; +} diff --git a/packages/react-router/lib/server-runtime/sessions/memoryStorage.ts b/packages/react-router/lib/server-runtime/sessions/memoryStorage.ts index 9ad13d7f7c..b1f48fbfeb 100644 --- a/packages/react-router/lib/server-runtime/sessions/memoryStorage.ts +++ b/packages/react-router/lib/server-runtime/sessions/memoryStorage.ts @@ -2,9 +2,9 @@ import type { SessionData, SessionStorage, SessionIdStorageStrategy, - CreateSessionStorageFunction, FlashSessionData, } from "../sessions"; +import { createSessionStorage } from "../sessions"; interface MemorySessionStorageOptions { /** @@ -14,60 +14,51 @@ interface MemorySessionStorageOptions { cookie?: SessionIdStorageStrategy["cookie"]; } -export type CreateMemorySessionStorageFunction = < - Data = SessionData, - FlashData = Data ->( - options?: MemorySessionStorageOptions -) => SessionStorage; - /** * Creates and returns a simple in-memory SessionStorage object, mostly useful * for testing and as a reference implementation. * * Note: This storage does not scale beyond a single process, so it is not * suitable for most production scenarios. - * - * @see https://remix.run/utils/sessions#creatememorysessionstorage */ -export const createMemorySessionStorageFactory = - ( - createSessionStorage: CreateSessionStorageFunction - ): CreateMemorySessionStorageFunction => - ({ - cookie, - }: MemorySessionStorageOptions = {}): SessionStorage => { - let map = new Map< - string, - { data: FlashSessionData; expires?: Date } - >(); - - return createSessionStorage({ - cookie, - async createData(data, expires) { - let id = Math.random().toString(36).substring(2, 10); - map.set(id, { data, expires }); - return id; - }, - async readData(id) { - if (map.has(id)) { - let { data, expires } = map.get(id)!; +export function createMemorySessionStorage< + Data = SessionData, + FlashData = Data +>({ cookie }: MemorySessionStorageOptions = {}): SessionStorage< + Data, + FlashData +> { + let map = new Map< + string, + { data: FlashSessionData; expires?: Date } + >(); - if (!expires || expires > new Date()) { - return data; - } + return createSessionStorage({ + cookie, + async createData(data, expires) { + let id = Math.random().toString(36).substring(2, 10); + map.set(id, { data, expires }); + return id; + }, + async readData(id) { + if (map.has(id)) { + let { data, expires } = map.get(id)!; - // Remove expired session data. - if (expires) map.delete(id); + if (!expires || expires > new Date()) { + return data; } - return null; - }, - async updateData(id, data, expires) { - map.set(id, { data, expires }); - }, - async deleteData(id) { - map.delete(id); - }, - }); - }; + // Remove expired session data. + if (expires) map.delete(id); + } + + return null; + }, + async updateData(id, data, expires) { + map.set(id, { data, expires }); + }, + async deleteData(id) { + map.delete(id); + }, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 600cc5fed3..657ab500bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -822,9 +822,6 @@ importers: '@web3-storage/multipart-parser': specifier: ^1.0.0 version: 1.0.0 - cookie-signature: - specifier: ^1.1.0 - version: 1.2.1 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -835,9 +832,6 @@ importers: specifier: ^6.19.2 version: 6.19.2 devDependencies: - '@types/cookie-signature': - specifier: ^1.0.3 - version: 1.1.2 '@types/source-map-support': specifier: ^0.5.4 version: 0.5.10 @@ -5464,12 +5458,6 @@ packages: dependencies: '@types/node': 18.19.26 - /@types/cookie-signature@1.1.2: - resolution: {integrity: sha512-2OhrZV2LVnUAXklUFwuYUTokalh/dUb8rqt70OW6ByMSxYpauPZ+kfNLknX3aJyjY5iu8i3cUyoLZP9Fn37tTg==} - dependencies: - '@types/node': 18.19.26 - dev: true - /@types/cookie@0.6.0: resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} dev: false