@remix-run/cookie 0.3.0 → 0.4.0

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,9 @@ 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'
25
23
 
26
- let sessionCookie = new Cookie('session')
24
+ let sessionCookie = createCookie('session', { secrets: ['s3cret1'] })
27
25
 
28
26
  // Get the value of the "session" cookie from the request's `Cookie` header
29
27
  let value = await sessionCookie.parse(request.headers.get('Cookie'))
@@ -46,7 +44,7 @@ Secret rotation is also supported, so you can easily rotate in new secrets witho
46
44
  import { Cookie } from '@remix-run/cookie'
47
45
 
48
46
  // Start with a single secret
49
- let sessionCookie = new Cookie('session', {
47
+ let sessionCookie = createCookie('session', {
50
48
  secrets: ['secret1'],
51
49
  })
52
50
 
@@ -62,12 +60,19 @@ let response = new Response('Hello, world!', {
62
60
  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
61
 
64
62
  ```tsx
65
- let sessionCookie = new Cookie('session', {
63
+ let sessionCookie = createCookie('session', {
66
64
  secrets: ['secret2', 'secret1'],
67
65
  })
68
66
 
69
- // This will still work for cookies signed with the old secret
67
+ // This works for cookies signed with either secret
70
68
  let value = await sessionCookie.parse(request.headers.get('Cookie'))
69
+
70
+ // Newly serialized cookies will be signed with the new secret
71
+ let response = new Response('Hello, world!', {
72
+ headers: {
73
+ 'Set-Cookie': await sessionCookie.serialize(value),
74
+ },
75
+ })
71
76
  ```
72
77
 
73
78
  ### Custom Encoding
@@ -75,7 +80,7 @@ let value = await sessionCookie.parse(request.headers.get('Cookie'))
75
80
  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
81
 
77
82
  ```tsx
78
- let sessionCookie = new Cookie('session', {
83
+ let sessionCookie = createCookie('session', {
79
84
  encode: (value) => value,
80
85
  decode: (value) => value,
81
86
  })
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,CA4E1E"}
@@ -1,76 +1,73 @@
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();
73
- }
10
+ let { decode = decodeURIComponent, encode = encodeURIComponent, secrets = [], domain, expires, httpOnly, maxAge, path = '/', partitioned, secure, sameSite = 'Lax', } = options ?? {};
11
+ return {
12
+ get domain() {
13
+ return domain;
14
+ },
15
+ get expires() {
16
+ return expires;
17
+ },
18
+ get httpOnly() {
19
+ return httpOnly ?? false;
20
+ },
21
+ get maxAge() {
22
+ return maxAge;
23
+ },
24
+ get name() {
25
+ return name;
26
+ },
27
+ async parse(headerValue) {
28
+ if (!headerValue)
29
+ return null;
30
+ let header = new CookieHeader(headerValue);
31
+ if (!header.has(name))
32
+ return null;
33
+ let value = header.get(name);
34
+ if (value === '')
35
+ return '';
36
+ let decoded = await decodeCookieValue(value, secrets, decode);
37
+ return decoded;
38
+ },
39
+ get partitioned() {
40
+ return partitioned ?? false;
41
+ },
42
+ get path() {
43
+ return path;
44
+ },
45
+ get sameSite() {
46
+ return sameSite;
47
+ },
48
+ get secure() {
49
+ return secure ?? false;
50
+ },
51
+ async serialize(value, props) {
52
+ let header = new SetCookieHeader({
53
+ name: name,
54
+ value: value === '' ? '' : await encodeCookieValue(value, secrets, encode),
55
+ domain,
56
+ expires,
57
+ httpOnly,
58
+ maxAge,
59
+ partitioned,
60
+ path,
61
+ sameSite,
62
+ secure,
63
+ ...props,
64
+ });
65
+ return header.toString();
66
+ },
67
+ get signed() {
68
+ return secrets.length > 0;
69
+ },
70
+ };
74
71
  }
75
72
  async function decodeCookieValue(value, secrets, decode) {
76
73
  if (secrets.length > 0) {
@@ -123,9 +120,8 @@ function hex(code, length) {
123
120
  }
124
121
  async function encodeCookieValue(value, secrets, encode) {
125
122
  let encoded = encodeValue(value, encode);
126
- if (secrets.length > 0) {
123
+ if (secrets.length > 0)
127
124
  encoded = await sign(encoded, secrets[0]);
128
- }
129
125
  return encoded;
130
126
  }
131
127
  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.0",
4
4
  "description": "A toolkit for working with cookies in JavaScript",
5
5
  "author": "Michael Jackson <mjijackson@gmail.com>",
6
6
  "license": "MIT",
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,95 @@ 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
78
-
79
- /**
80
- * True if this cookie uses one or more secrets for verification.
81
- */
82
- get signed(): boolean {
83
- return this.#secrets.length > 0
84
- }
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 ?? {}
85
143
 
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
144
+ return {
145
+ get domain() {
146
+ return domain
147
+ },
148
+ get expires() {
149
+ return expires
150
+ },
151
+ get httpOnly() {
152
+ return httpOnly ?? false
153
+ },
154
+ get maxAge() {
155
+ return maxAge
156
+ },
157
+ get name() {
158
+ return name
159
+ },
160
+ async parse(headerValue: string | null): Promise<string | null> {
161
+ if (!headerValue) return null
93
162
 
94
- let header = new CookieHeader(headerValue)
95
- if (!header.has(this.name)) return null
163
+ let header = new CookieHeader(headerValue)
164
+ if (!header.has(name)) return null
96
165
 
97
- let value = header.get(this.name)!
98
- if (value === '') return ''
166
+ let value = header.get(name)!
167
+ if (value === '') return ''
99
168
 
100
- let decoded = await decodeCookieValue(value, this.#secrets, this.#decode)
101
- return decoded
102
- }
169
+ let decoded = await decodeCookieValue(value, secrets, decode)
170
+ return decoded
171
+ },
172
+ get partitioned() {
173
+ return partitioned ?? false
174
+ },
175
+ get path() {
176
+ return path
177
+ },
178
+ get sameSite() {
179
+ return sameSite
180
+ },
181
+ get secure() {
182
+ return secure ?? false
183
+ },
184
+ async serialize(value: string, props?: CookieProperties): Promise<string> {
185
+ let header = new SetCookieHeader({
186
+ name: name,
187
+ value: value === '' ? '' : await encodeCookieValue(value, secrets, encode),
188
+ domain,
189
+ expires,
190
+ httpOnly,
191
+ maxAge,
192
+ partitioned,
193
+ path,
194
+ sameSite,
195
+ secure,
196
+ ...props,
197
+ })
103
198
 
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()
199
+ return header.toString()
200
+ },
201
+ get signed() {
202
+ return secrets.length > 0
203
+ },
122
204
  }
123
205
  }
124
206
 
207
+ type Coder = (value: string) => string
208
+
125
209
  async function decodeCookieValue(
126
210
  value: string,
127
211
  secrets: string[],
128
- decode: (value: string) => string,
212
+ decode: Coder,
129
213
  ): Promise<string | null> {
130
214
  if (secrets.length > 0) {
131
215
  for (let secret of secrets) {
@@ -141,7 +225,7 @@ async function decodeCookieValue(
141
225
  return decodeValue(value, decode)
142
226
  }
143
227
 
144
- function decodeValue(value: string, decode: (value: string) => string): string | null {
228
+ function decodeValue(value: string, decode: Coder): string | null {
145
229
  try {
146
230
  return decode(myEscape(atob(value)))
147
231
  } catch {
@@ -177,21 +261,13 @@ function hex(code: number, length: number): string {
177
261
  return result
178
262
  }
179
263
 
180
- async function encodeCookieValue(
181
- value: string,
182
- secrets: string[],
183
- encode: (value: string) => string,
184
- ): Promise<string> {
264
+ async function encodeCookieValue(value: string, secrets: string[], encode: Coder): Promise<string> {
185
265
  let encoded = encodeValue(value, encode)
186
-
187
- if (secrets.length > 0) {
188
- encoded = await sign(encoded, secrets[0])
189
- }
190
-
266
+ if (secrets.length > 0) encoded = await sign(encoded, secrets[0])
191
267
  return encoded
192
268
  }
193
269
 
194
- function encodeValue(value: string, encode: (value: string) => string): string {
270
+ function encodeValue(value: string, encode: Coder): string {
195
271
  return btoa(myUnescape(encode(value)))
196
272
  }
197
273
 
@@ -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"}