@remix-run/cookie 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,8 +8,6 @@ HTTP cookies are essential for web applications, from session management and use
8
8
 
9
9
  - **Secure Cookie Signing:** Built-in cryptographic signing using HMAC-SHA256 to prevent cookie tampering, with support for secret rotation without breaking existing cookies.
10
10
  - **Secret Rotation Support:** Seamlessly rotate signing secrets while maintaining backward compatibility with existing cookies.
11
- - **Comprehensive Cookie Attributes:** Full support for all standard cookie attributes including `Path`, `Domain`, `Secure`, `HttpOnly`, `SameSite`, `Max-Age`, and `Expires`.
12
- - **Reusable Cookie Containers:** Create logical cookie containers that can be used to parse and serialize multiple values over time.
13
11
  - **Web Standards Compliant:** Built on Web Crypto API and standard cookie parsing, making it runtime-agnostic (Node.js, Bun, Deno, Cloudflare Workers).
14
12
 
15
13
  ## Installation
@@ -21,9 +19,18 @@ npm install @remix-run/cookie
21
19
  ## Usage
22
20
 
23
21
  ```tsx
24
- import { Cookie } from '@remix-run/cookie'
22
+ import { createCookie } from '@remix-run/cookie'
23
+
24
+ let sessionCookie = createCookie('session', {
25
+ httpOnly: true,
26
+ secrets: ['s3cret1'],
27
+ secure: true,
28
+ })
25
29
 
26
- let sessionCookie = new Cookie('session')
30
+ cookie.name // "session"
31
+ cookie.httpOnly // true
32
+ cookie.secure // true
33
+ cookie.signed // true
27
34
 
28
35
  // Get the value of the "session" cookie from the request's `Cookie` header
29
36
  let value = await sessionCookie.parse(request.headers.get('Cookie'))
@@ -46,7 +53,7 @@ Secret rotation is also supported, so you can easily rotate in new secrets witho
46
53
  import { Cookie } from '@remix-run/cookie'
47
54
 
48
55
  // Start with a single secret
49
- let sessionCookie = new Cookie('session', {
56
+ let sessionCookie = createCookie('session', {
50
57
  secrets: ['secret1'],
51
58
  })
52
59
 
@@ -62,12 +69,19 @@ let response = new Response('Hello, world!', {
62
69
  All cookies sent in this scenario will be signed with the secret `secret1`. Later, when it's time to rotate secrets, add a new secret to the beginning of the array and all existing cookies will still be able to be parsed.
63
70
 
64
71
  ```tsx
65
- let sessionCookie = new Cookie('session', {
72
+ let sessionCookie = createCookie('session', {
66
73
  secrets: ['secret2', 'secret1'],
67
74
  })
68
75
 
69
- // This will still work for cookies signed with the old secret
76
+ // This works for cookies signed with either secret
70
77
  let value = await sessionCookie.parse(request.headers.get('Cookie'))
78
+
79
+ // Newly serialized cookies will be signed with the new secret
80
+ let response = new Response('Hello, world!', {
81
+ headers: {
82
+ 'Set-Cookie': await sessionCookie.serialize(value),
83
+ },
84
+ })
71
85
  ```
72
86
 
73
87
  ### Custom Encoding
@@ -75,7 +89,7 @@ let value = await sessionCookie.parse(request.headers.get('Cookie'))
75
89
  By default, the library will use `encodeURIComponent` and `decodeURIComponent` to encode and decode the cookie value. This is suitable for most use cases, but you can provide your own functions to customize the encoding and decoding of the cookie value.
76
90
 
77
91
  ```tsx
78
- let sessionCookie = new Cookie('session', {
92
+ let sessionCookie = createCookie('session', {
79
93
  encode: (value) => value,
80
94
  decode: (value) => value,
81
95
  })
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { createCookie, type CookieOptions, Cookie } from './lib/cookie.ts';
1
+ export { type Cookie, type CookieOptions, createCookie } from './lib/cookie.ts';
2
2
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAE,KAAK,aAAa,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA"}
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { createCookie, Cookie } from "./lib/cookie.js";
1
+ export { createCookie } from "./lib/cookie.js";
@@ -1,3 +1,3 @@
1
1
  export declare function sign(value: string, secret: string): Promise<string>;
2
2
  export declare function unsign(cookie: string, secret: string): Promise<string | false>;
3
- //# sourceMappingURL=crypto.d.ts.map
3
+ //# sourceMappingURL=cookie-signing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookie-signing.d.ts","sourceRoot":"","sources":["../../src/lib/cookie-signing.ts"],"names":[],"mappings":"AAEA,wBAAsB,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOzE;AAED,wBAAsB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,KAAK,CAAC,CAuBpF"}
@@ -8,12 +8,15 @@ export async function sign(value, secret) {
8
8
  }
9
9
  export async function unsign(cookie, secret) {
10
10
  let index = cookie.lastIndexOf('.');
11
+ if (index === -1) {
12
+ return false;
13
+ }
11
14
  let value = cookie.slice(0, index);
12
15
  let hash = cookie.slice(index + 1);
13
16
  let data = encoder.encode(value);
14
17
  let key = await createKey(secret, ['verify']);
15
18
  try {
16
- let signature = byteStringToUint8Array(atob(hash));
19
+ let signature = byteStringToArray(atob(hash));
17
20
  let valid = await crypto.subtle.verify('HMAC', key, signature, data);
18
21
  return valid ? value : false;
19
22
  }
@@ -27,7 +30,7 @@ export async function unsign(cookie, secret) {
27
30
  async function createKey(secret, usages) {
28
31
  return crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, usages);
29
32
  }
30
- function byteStringToUint8Array(byteString) {
33
+ function byteStringToArray(byteString) {
31
34
  let array = new Uint8Array(byteString.length);
32
35
  for (let i = 0; i < byteString.length; i++) {
33
36
  array[i] = byteString.charCodeAt(i);
@@ -1,11 +1,86 @@
1
1
  import { type CookieProperties } from '@remix-run/headers';
2
+ type SameSiteValue = 'Strict' | 'Lax' | 'None';
2
3
  /**
3
- * Creates a new `Cookie` object.
4
- * @param name The name of the cookie
5
- * @param options The options for the cookie
6
- * @returns A new cookie instance
4
+ * Represents a HTTP cookie.
5
+ *
6
+ * Supports parsing and serializing the cookie to/from `Cookie` and `Set-Cookie` headers.
7
+ *
8
+ * Also supports cryptographic signing of the cookie value to ensure it's not tampered with, and
9
+ * secret rotation to easily rotate secrets without breaking existing cookies.
7
10
  */
8
- export declare function createCookie(name: string, options?: CookieOptions): Cookie;
11
+ export interface Cookie {
12
+ /**
13
+ * The domain of the cookie.
14
+ *
15
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#domaindomain-value)
16
+ */
17
+ readonly domain: string | undefined;
18
+ /**
19
+ * The expiration date of the cookie.
20
+ *
21
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#expiresdate)
22
+ */
23
+ readonly expires: Date | undefined;
24
+ /**
25
+ * True if the cookie is HTTP-only.
26
+ *
27
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#httponly)
28
+ */
29
+ readonly httpOnly: boolean;
30
+ /**
31
+ * The maximum age of the cookie in seconds.
32
+ *
33
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#max-agenumber)
34
+ */
35
+ readonly maxAge: number | undefined;
36
+ /**
37
+ * The name of the cookie.
38
+ *
39
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#cookie-namecookie-value)
40
+ */
41
+ readonly name: string;
42
+ /**
43
+ * Extracts the value of this cookie from a `Cookie` header value.
44
+ * @param headerValue The `Cookie` header to parse
45
+ * @returns The value of this cookie, or `null` if it's not present
46
+ */
47
+ parse(headerValue: string | null): Promise<string | null>;
48
+ /**
49
+ * True if the cookie is partitioned.
50
+ *
51
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#partitioned)
52
+ */
53
+ readonly partitioned: boolean;
54
+ /**
55
+ * The path of the cookie. Defaults to `/`.
56
+ *
57
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#pathpath-value)
58
+ */
59
+ readonly path: string;
60
+ /**
61
+ * The `SameSite` attribute of the cookie. Defaults to `Lax`.
62
+ *
63
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value)
64
+ */
65
+ readonly sameSite: SameSiteValue;
66
+ /**
67
+ * True if the cookie is secure (only sent over HTTPS).
68
+ *
69
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#secure)
70
+ */
71
+ readonly secure: boolean;
72
+ /**
73
+ * Returns the value to use in a `Set-Cookie` header for this cookie.
74
+ * @param value The value to serialize
75
+ * @param props (optional) Additional properties to use when serializing the cookie
76
+ * @returns The `Set-Cookie` header for this cookie
77
+ */
78
+ serialize(value: string, props?: CookieProperties): Promise<string>;
79
+ /**
80
+ * True if this cookie uses one or more secrets for verification.
81
+ */
82
+ readonly signed: boolean;
83
+ }
9
84
  export interface CookieOptions extends CookieProperties {
10
85
  /**
11
86
  * A function that decodes the cookie value.
@@ -36,32 +111,11 @@ export interface CookieOptions extends CookieProperties {
36
111
  secrets?: string[];
37
112
  }
38
113
  /**
39
- * A container for metadata about a HTTP cookie; its name and secrets that may be used
40
- * to sign/unsign the value of the cookie to ensure it's not tampered with.
114
+ * Creates a new cookie object.
115
+ * @param name The name of the cookie
116
+ * @param options (optional) Additional options for the cookie
117
+ * @returns A cookie object
41
118
  */
42
- export declare class Cookie {
43
- #private;
44
- constructor(name: string, options?: CookieOptions);
45
- /**
46
- * The name of the cookie.
47
- */
48
- readonly name: string;
49
- /**
50
- * True if this cookie uses one or more secrets for verification.
51
- */
52
- get signed(): boolean;
53
- /**
54
- * Extracts the value of this cookie from a `Cookie` header value.
55
- * @param headerValue The value of the `Cookie` header to parse
56
- * @returns The value of this cookie, or `null` if it's not present
57
- */
58
- parse(headerValue: string | null): Promise<string | null>;
59
- /**
60
- * Returns the value to use in a `Set-Cookie` header for this cookie.
61
- * @param value The value to serialize
62
- * @param props (optional) Additional properties to use when serializing the cookie
63
- * @returns The value to use in a `Set-Cookie` header for this cookie
64
- */
65
- serialize(value: string, props?: CookieProperties): Promise<string>;
66
- }
119
+ export declare function createCookie(name: string, options?: CookieOptions): Cookie;
120
+ export {};
67
121
  //# sourceMappingURL=cookie.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cookie.d.ts","sourceRoot":"","sources":["../../src/lib/cookie.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,oBAAoB,CAAA;AAI3B;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAE1E;AAED,MAAM,WAAW,aAAc,SAAQ,gBAAgB;IACrD;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;IAClC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED;;;GAGG;AACH,qBAAa,MAAM;;gBACL,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa;IAejD;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAOrB;;OAEG;IACH,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED;;;;OAIG;IACG,KAAK,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAa/D;;;;;OAKG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC;CAa1E"}
1
+ {"version":3,"file":"cookie.d.ts","sourceRoot":"","sources":["../../src/lib/cookie.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,oBAAoB,CAAA;AAI3B,KAAK,aAAa,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAA;AAE9C;;;;;;;GAOG;AACH,MAAM,WAAW,MAAM;IACrB;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,IAAI,GAAG,SAAS,CAAA;IAClC;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,KAAK,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACzD;;;;OAIG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAA;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;IAChC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB;;;;;OAKG;IACH,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACnE;;OAEG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,aAAc,SAAQ,gBAAgB;IACrD;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;IAClC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAkF1E"}
@@ -1,76 +1,78 @@
1
1
  import { Cookie as CookieHeader, SetCookie as SetCookieHeader, } from '@remix-run/headers';
2
- import { sign, unsign } from "./crypto.js";
2
+ import { sign, unsign } from "./cookie-signing.js";
3
3
  /**
4
- * Creates a new `Cookie` object.
4
+ * Creates a new cookie object.
5
5
  * @param name The name of the cookie
6
- * @param options The options for the cookie
7
- * @returns A new cookie instance
6
+ * @param options (optional) Additional options for the cookie
7
+ * @returns A cookie object
8
8
  */
9
9
  export function createCookie(name, options) {
10
- return new Cookie(name, options);
11
- }
12
- /**
13
- * A container for metadata about a HTTP cookie; its name and secrets that may be used
14
- * to sign/unsign the value of the cookie to ensure it's not tampered with.
15
- */
16
- export class Cookie {
17
- constructor(name, options) {
18
- let { decode = decodeURIComponent, encode = encodeURIComponent, secrets = [], ...props } = options ?? {};
19
- this.name = name;
20
- this.#decode = decode;
21
- this.#encode = encode;
22
- this.#secrets = secrets;
23
- this.#props = props;
24
- }
25
- /**
26
- * The name of the cookie.
27
- */
28
- name;
29
- #decode;
30
- #encode;
31
- #secrets;
32
- #props;
33
- /**
34
- * True if this cookie uses one or more secrets for verification.
35
- */
36
- get signed() {
37
- return this.#secrets.length > 0;
38
- }
39
- /**
40
- * Extracts the value of this cookie from a `Cookie` header value.
41
- * @param headerValue The value of the `Cookie` header to parse
42
- * @returns The value of this cookie, or `null` if it's not present
43
- */
44
- async parse(headerValue) {
45
- if (!headerValue)
46
- return null;
47
- let header = new CookieHeader(headerValue);
48
- if (!header.has(this.name))
49
- return null;
50
- let value = header.get(this.name);
51
- if (value === '')
52
- return '';
53
- let decoded = await decodeCookieValue(value, this.#secrets, this.#decode);
54
- return decoded;
55
- }
56
- /**
57
- * Returns the value to use in a `Set-Cookie` header for this cookie.
58
- * @param value The value to serialize
59
- * @param props (optional) Additional properties to use when serializing the cookie
60
- * @returns The value to use in a `Set-Cookie` header for this cookie
61
- */
62
- async serialize(value, props) {
63
- let header = new SetCookieHeader({
64
- name: this.name,
65
- value: value === '' ? '' : await encodeCookieValue(value, this.#secrets, this.#encode),
66
- // sane defaults
67
- path: '/',
68
- sameSite: 'Lax',
69
- ...this.#props,
70
- ...props,
71
- });
72
- return header.toString();
10
+ let { decode = decodeURIComponent, encode = encodeURIComponent, secrets = [], domain, expires, httpOnly, maxAge, path = '/', partitioned, secure, sameSite = 'Lax', } = options ?? {};
11
+ if (partitioned === true) {
12
+ // Partitioned cookies must be set with Secure
13
+ // See https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Privacy_sandbox/Partitioned_cookies
14
+ secure = true;
73
15
  }
16
+ return {
17
+ get domain() {
18
+ return domain;
19
+ },
20
+ get expires() {
21
+ return expires;
22
+ },
23
+ get httpOnly() {
24
+ return httpOnly ?? false;
25
+ },
26
+ get maxAge() {
27
+ return maxAge;
28
+ },
29
+ get name() {
30
+ return name;
31
+ },
32
+ async parse(headerValue) {
33
+ if (!headerValue)
34
+ return null;
35
+ let header = new CookieHeader(headerValue);
36
+ if (!header.has(name))
37
+ return null;
38
+ let value = header.get(name);
39
+ if (value === '')
40
+ return '';
41
+ let decoded = await decodeCookieValue(value, secrets, decode);
42
+ return decoded;
43
+ },
44
+ get partitioned() {
45
+ return partitioned ?? false;
46
+ },
47
+ get path() {
48
+ return path;
49
+ },
50
+ get sameSite() {
51
+ return sameSite;
52
+ },
53
+ get secure() {
54
+ return secure ?? false;
55
+ },
56
+ async serialize(value, props) {
57
+ let header = new SetCookieHeader({
58
+ name: name,
59
+ value: value === '' ? '' : await encodeCookieValue(value, secrets, encode),
60
+ domain,
61
+ expires,
62
+ httpOnly,
63
+ maxAge,
64
+ partitioned,
65
+ path,
66
+ sameSite,
67
+ secure,
68
+ ...props,
69
+ });
70
+ return header.toString();
71
+ },
72
+ get signed() {
73
+ return secrets.length > 0;
74
+ },
75
+ };
74
76
  }
75
77
  async function decodeCookieValue(value, secrets, decode) {
76
78
  if (secrets.length > 0) {
@@ -123,9 +125,8 @@ function hex(code, length) {
123
125
  }
124
126
  async function encodeCookieValue(value, secrets, encode) {
125
127
  let encoded = encodeValue(value, encode);
126
- if (secrets.length > 0) {
128
+ if (secrets.length > 0)
127
129
  encoded = await sign(encoded, secrets[0]);
128
- }
129
130
  return encoded;
130
131
  }
131
132
  function encodeValue(value, encode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/cookie",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "A toolkit for working with cookies in JavaScript",
5
5
  "author": "Michael Jackson <mjijackson@gmail.com>",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "@types/node": "^24.6.0"
30
30
  },
31
31
  "peerDependencies": {
32
- "@remix-run/headers": "^0.16.0"
32
+ "@remix-run/headers": "^0.17.0"
33
33
  },
34
34
  "keywords": [
35
35
  "http",
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export { createCookie, type CookieOptions, Cookie } from './lib/cookie.ts'
1
+ export { type Cookie, type CookieOptions, createCookie } from './lib/cookie.ts'
@@ -11,14 +11,18 @@ export async function sign(value: string, secret: string): Promise<string> {
11
11
 
12
12
  export async function unsign(cookie: string, secret: string): Promise<string | false> {
13
13
  let index = cookie.lastIndexOf('.')
14
+
15
+ if (index === -1) {
16
+ return false
17
+ }
18
+
14
19
  let value = cookie.slice(0, index)
15
20
  let hash = cookie.slice(index + 1)
16
-
17
21
  let data = encoder.encode(value)
18
-
19
22
  let key = await createKey(secret, ['verify'])
23
+
20
24
  try {
21
- let signature = byteStringToUint8Array(atob(hash))
25
+ let signature = byteStringToArray(atob(hash))
22
26
  let valid = await crypto.subtle.verify('HMAC', key, signature, data)
23
27
 
24
28
  return valid ? value : false
@@ -40,7 +44,7 @@ async function createKey(secret: string, usages: CryptoKey['usages']): Promise<C
40
44
  )
41
45
  }
42
46
 
43
- function byteStringToUint8Array(byteString: string): Uint8Array<ArrayBuffer> {
47
+ function byteStringToArray(byteString: string): Uint8Array<ArrayBuffer> {
44
48
  let array = new Uint8Array(byteString.length)
45
49
 
46
50
  for (let i = 0; i < byteString.length; i++) {
package/src/lib/cookie.ts CHANGED
@@ -4,16 +4,90 @@ import {
4
4
  type CookieProperties,
5
5
  } from '@remix-run/headers'
6
6
 
7
- import { sign, unsign } from './crypto.ts'
7
+ import { sign, unsign } from './cookie-signing.ts'
8
+
9
+ type SameSiteValue = 'Strict' | 'Lax' | 'None'
8
10
 
9
11
  /**
10
- * Creates a new `Cookie` object.
11
- * @param name The name of the cookie
12
- * @param options The options for the cookie
13
- * @returns A new cookie instance
12
+ * Represents a HTTP cookie.
13
+ *
14
+ * Supports parsing and serializing the cookie to/from `Cookie` and `Set-Cookie` headers.
15
+ *
16
+ * Also supports cryptographic signing of the cookie value to ensure it's not tampered with, and
17
+ * secret rotation to easily rotate secrets without breaking existing cookies.
14
18
  */
15
- export function createCookie(name: string, options?: CookieOptions): Cookie {
16
- return new Cookie(name, options)
19
+ export interface Cookie {
20
+ /**
21
+ * The domain of the cookie.
22
+ *
23
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#domaindomain-value)
24
+ */
25
+ readonly domain: string | undefined
26
+ /**
27
+ * The expiration date of the cookie.
28
+ *
29
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#expiresdate)
30
+ */
31
+ readonly expires: Date | undefined
32
+ /**
33
+ * True if the cookie is HTTP-only.
34
+ *
35
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#httponly)
36
+ */
37
+ readonly httpOnly: boolean
38
+ /**
39
+ * The maximum age of the cookie in seconds.
40
+ *
41
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#max-agenumber)
42
+ */
43
+ readonly maxAge: number | undefined
44
+ /**
45
+ * The name of the cookie.
46
+ *
47
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#cookie-namecookie-value)
48
+ */
49
+ readonly name: string
50
+ /**
51
+ * Extracts the value of this cookie from a `Cookie` header value.
52
+ * @param headerValue The `Cookie` header to parse
53
+ * @returns The value of this cookie, or `null` if it's not present
54
+ */
55
+ parse(headerValue: string | null): Promise<string | null>
56
+ /**
57
+ * True if the cookie is partitioned.
58
+ *
59
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#partitioned)
60
+ */
61
+ readonly partitioned: boolean
62
+ /**
63
+ * The path of the cookie. Defaults to `/`.
64
+ *
65
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#pathpath-value)
66
+ */
67
+ readonly path: string
68
+ /**
69
+ * The `SameSite` attribute of the cookie. Defaults to `Lax`.
70
+ *
71
+ * [MDN Reference](https://developer.mozilla.org/en-US/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value)
72
+ */
73
+ readonly sameSite: SameSiteValue
74
+ /**
75
+ * True if the cookie is secure (only sent over HTTPS).
76
+ *
77
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#secure)
78
+ */
79
+ readonly secure: boolean
80
+ /**
81
+ * Returns the value to use in a `Set-Cookie` header for this cookie.
82
+ * @param value The value to serialize
83
+ * @param props (optional) Additional properties to use when serializing the cookie
84
+ * @returns The `Set-Cookie` header for this cookie
85
+ */
86
+ serialize(value: string, props?: CookieProperties): Promise<string>
87
+ /**
88
+ * True if this cookie uses one or more secrets for verification.
89
+ */
90
+ readonly signed: boolean
17
91
  }
18
92
 
19
93
  export interface CookieOptions extends CookieProperties {
@@ -47,85 +121,101 @@ export interface CookieOptions extends CookieProperties {
47
121
  }
48
122
 
49
123
  /**
50
- * A container for metadata about a HTTP cookie; its name and secrets that may be used
51
- * to sign/unsign the value of the cookie to ensure it's not tampered with.
124
+ * Creates a new cookie object.
125
+ * @param name The name of the cookie
126
+ * @param options (optional) Additional options for the cookie
127
+ * @returns A cookie object
52
128
  */
53
- export class Cookie {
54
- constructor(name: string, options?: CookieOptions) {
55
- let {
56
- decode = decodeURIComponent,
57
- encode = encodeURIComponent,
58
- secrets = [],
59
- ...props
60
- } = options ?? {}
61
-
62
- this.name = name
63
- this.#decode = decode
64
- this.#encode = encode
65
- this.#secrets = secrets
66
- this.#props = props
67
- }
68
-
69
- /**
70
- * The name of the cookie.
71
- */
72
- readonly name: string
73
-
74
- readonly #decode: (value: string) => string
75
- readonly #encode: (value: string) => string
76
- readonly #secrets: string[]
77
- readonly #props: CookieProperties
129
+ export function createCookie(name: string, options?: CookieOptions): Cookie {
130
+ let {
131
+ decode = decodeURIComponent,
132
+ encode = encodeURIComponent,
133
+ secrets = [],
134
+ domain,
135
+ expires,
136
+ httpOnly,
137
+ maxAge,
138
+ path = '/',
139
+ partitioned,
140
+ secure,
141
+ sameSite = 'Lax',
142
+ } = options ?? {}
78
143
 
79
- /**
80
- * True if this cookie uses one or more secrets for verification.
81
- */
82
- get signed(): boolean {
83
- return this.#secrets.length > 0
144
+ if (partitioned === true) {
145
+ // Partitioned cookies must be set with Secure
146
+ // See https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Privacy_sandbox/Partitioned_cookies
147
+ secure = true
84
148
  }
85
149
 
86
- /**
87
- * Extracts the value of this cookie from a `Cookie` header value.
88
- * @param headerValue The value of the `Cookie` header to parse
89
- * @returns The value of this cookie, or `null` if it's not present
90
- */
91
- async parse(headerValue: string | null): Promise<string | null> {
92
- if (!headerValue) return null
150
+ return {
151
+ get domain() {
152
+ return domain
153
+ },
154
+ get expires() {
155
+ return expires
156
+ },
157
+ get httpOnly() {
158
+ return httpOnly ?? false
159
+ },
160
+ get maxAge() {
161
+ return maxAge
162
+ },
163
+ get name() {
164
+ return name
165
+ },
166
+ async parse(headerValue: string | null): Promise<string | null> {
167
+ if (!headerValue) return null
93
168
 
94
- let header = new CookieHeader(headerValue)
95
- if (!header.has(this.name)) return null
169
+ let header = new CookieHeader(headerValue)
170
+ if (!header.has(name)) return null
96
171
 
97
- let value = header.get(this.name)!
98
- if (value === '') return ''
172
+ let value = header.get(name)!
173
+ if (value === '') return ''
99
174
 
100
- let decoded = await decodeCookieValue(value, this.#secrets, this.#decode)
101
- return decoded
102
- }
175
+ let decoded = await decodeCookieValue(value, secrets, decode)
176
+ return decoded
177
+ },
178
+ get partitioned() {
179
+ return partitioned ?? false
180
+ },
181
+ get path() {
182
+ return path
183
+ },
184
+ get sameSite() {
185
+ return sameSite
186
+ },
187
+ get secure() {
188
+ return secure ?? false
189
+ },
190
+ async serialize(value: string, props?: CookieProperties): Promise<string> {
191
+ let header = new SetCookieHeader({
192
+ name: name,
193
+ value: value === '' ? '' : await encodeCookieValue(value, secrets, encode),
194
+ domain,
195
+ expires,
196
+ httpOnly,
197
+ maxAge,
198
+ partitioned,
199
+ path,
200
+ sameSite,
201
+ secure,
202
+ ...props,
203
+ })
103
204
 
104
- /**
105
- * Returns the value to use in a `Set-Cookie` header for this cookie.
106
- * @param value The value to serialize
107
- * @param props (optional) Additional properties to use when serializing the cookie
108
- * @returns The value to use in a `Set-Cookie` header for this cookie
109
- */
110
- async serialize(value: string, props?: CookieProperties): Promise<string> {
111
- let header = new SetCookieHeader({
112
- name: this.name,
113
- value: value === '' ? '' : await encodeCookieValue(value, this.#secrets, this.#encode),
114
- // sane defaults
115
- path: '/',
116
- sameSite: 'Lax',
117
- ...this.#props,
118
- ...props,
119
- })
120
-
121
- return header.toString()
205
+ return header.toString()
206
+ },
207
+ get signed() {
208
+ return secrets.length > 0
209
+ },
122
210
  }
123
211
  }
124
212
 
213
+ type Coder = (value: string) => string
214
+
125
215
  async function decodeCookieValue(
126
216
  value: string,
127
217
  secrets: string[],
128
- decode: (value: string) => string,
218
+ decode: Coder,
129
219
  ): Promise<string | null> {
130
220
  if (secrets.length > 0) {
131
221
  for (let secret of secrets) {
@@ -141,7 +231,7 @@ async function decodeCookieValue(
141
231
  return decodeValue(value, decode)
142
232
  }
143
233
 
144
- function decodeValue(value: string, decode: (value: string) => string): string | null {
234
+ function decodeValue(value: string, decode: Coder): string | null {
145
235
  try {
146
236
  return decode(myEscape(atob(value)))
147
237
  } catch {
@@ -177,21 +267,13 @@ function hex(code: number, length: number): string {
177
267
  return result
178
268
  }
179
269
 
180
- async function encodeCookieValue(
181
- value: string,
182
- secrets: string[],
183
- encode: (value: string) => string,
184
- ): Promise<string> {
270
+ async function encodeCookieValue(value: string, secrets: string[], encode: Coder): Promise<string> {
185
271
  let encoded = encodeValue(value, encode)
186
-
187
- if (secrets.length > 0) {
188
- encoded = await sign(encoded, secrets[0])
189
- }
190
-
272
+ if (secrets.length > 0) encoded = await sign(encoded, secrets[0])
191
273
  return encoded
192
274
  }
193
275
 
194
- function encodeValue(value: string, encode: (value: string) => string): string {
276
+ function encodeValue(value: string, encode: Coder): string {
195
277
  return btoa(myUnescape(encode(value)))
196
278
  }
197
279
 
@@ -1 +0,0 @@
1
- {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/lib/crypto.ts"],"names":[],"mappings":"AAEA,wBAAsB,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOzE;AAED,wBAAsB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,KAAK,CAAC,CAmBpF"}