@remix-run/cookie 0.0.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/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/cookie.d.ts +57 -0
- package/dist/lib/cookie.d.ts.map +1 -0
- package/dist/lib/cookie.js +147 -0
- package/dist/lib/crypto.d.ts +3 -0
- package/dist/lib/crypto.d.ts.map +1 -0
- package/dist/lib/crypto.js +36 -0
- package/package.json +47 -0
- package/src/index.ts +1 -0
- package/src/lib/cookie.ts +202 -0
- package/src/lib/crypto.ts +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Michael Jackson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @remix-run/cookie
|
|
2
|
+
|
|
3
|
+
Simplify HTTP cookie management in JavaScript with type-safe, secure cookie handling. `@remix-run/cookie` provides a clean, intuitive API for creating, parsing, and serializing HTTP cookies with built-in support for signing, secret rotation, and comprehensive cookie attribute management.
|
|
4
|
+
|
|
5
|
+
HTTP cookies are essential for web applications, from session management and user preferences to authentication tokens and tracking. While the standard cookie parsing libraries provide basic functionality, they often leave complex scenarios like secure signing, secret rotation, and type-safe value handling up to you.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
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
|
+
- **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
|
+
- **Web Standards Compliant:** Built on Web Crypto API and standard cookie parsing, making it runtime-agnostic (Node.js, Bun, Deno, Cloudflare Workers).
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @remix-run/cookie
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { Cookie } from '@remix-run/cookie'
|
|
25
|
+
|
|
26
|
+
let sessionCookie = new Cookie('session')
|
|
27
|
+
|
|
28
|
+
// Get the value of the "session" cookie from the request's `Cookie` header
|
|
29
|
+
let value = await sessionCookie.parse(request.headers.get('Cookie'))
|
|
30
|
+
|
|
31
|
+
// Set the value of the cookie in a Response's `Set-Cookie` header
|
|
32
|
+
let response = new Response('Hello, world!', {
|
|
33
|
+
headers: {
|
|
34
|
+
'Set-Cookie': await sessionCookie.serialize(value),
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Signing Cookies
|
|
40
|
+
|
|
41
|
+
This library supports signing cookies, which is useful for ensuring the integrity of the cookie value and preventing tampering. Signing happens automatically when you provide a `secrets` option to the `Cookie` constructor.
|
|
42
|
+
|
|
43
|
+
Secret rotation is also supported, so you can easily rotate in new secrets without breaking existing cookies.
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
import { Cookie } from '@remix-run/cookie'
|
|
47
|
+
|
|
48
|
+
// Start with a single secret
|
|
49
|
+
let sessionCookie = new Cookie('session', {
|
|
50
|
+
secrets: ['secret1'],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
console.log(sessionCookie.isSigned) // true
|
|
54
|
+
|
|
55
|
+
let response = new Response('Hello, world!', {
|
|
56
|
+
headers: {
|
|
57
|
+
'Set-Cookie': await sessionCookie.serialize(value),
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
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
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
let sessionCookie = new Cookie('session', {
|
|
66
|
+
secrets: ['secret2', 'secret1'],
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// This will still work for cookies signed with the old secret
|
|
70
|
+
let value = await sessionCookie.parse(request.headers.get('Cookie'))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Custom Encoding
|
|
74
|
+
|
|
75
|
+
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
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
let sessionCookie = new Cookie('session', {
|
|
79
|
+
encode: (value) => value,
|
|
80
|
+
decode: (value) => value,
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
This can be useful for viewing the value of cookies in a human-readable format in the browser's developer tools. But you should be sure that the cookie value contains only characters that are [valid in a cookie value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#attributes).
|
|
85
|
+
|
|
86
|
+
## Related Packages
|
|
87
|
+
|
|
88
|
+
- [`headers`](https://github.com/remix-run/remix/tree/main/packages/headers) - Type-safe HTTP header manipulation
|
|
89
|
+
- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Build HTTP routers using the web fetch API
|
|
90
|
+
- [`node-fetch-server`](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server) - Build HTTP servers on Node.js using the web fetch API
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Cookie } from "./lib/cookie.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type CookieProperties } from '@remix-run/headers';
|
|
2
|
+
export interface CookieOptions {
|
|
3
|
+
/**
|
|
4
|
+
* A function that decodes the cookie value.
|
|
5
|
+
*
|
|
6
|
+
* Defaults to `decodeURIComponent`, which decodes any URL-encoded sequences into their original
|
|
7
|
+
* characters.
|
|
8
|
+
*
|
|
9
|
+
* See [RFC 6265](https://tools.ietf.org/html/rfc6265#section-4.1.1) for more details.
|
|
10
|
+
*/
|
|
11
|
+
decode?: (value: string) => string;
|
|
12
|
+
/**
|
|
13
|
+
* A function that encodes the cookie value.
|
|
14
|
+
*
|
|
15
|
+
* Defaults to `encodeURIComponent`, which percent-encodes all characters that are not allowed
|
|
16
|
+
* in a cookie value.
|
|
17
|
+
*
|
|
18
|
+
* See [RFC 6265](https://tools.ietf.org/html/rfc6265#section-4.1.1) for more details.
|
|
19
|
+
*/
|
|
20
|
+
encode?: (value: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* An array of secrets that may be used to sign/unsign the value of a cookie.
|
|
23
|
+
*
|
|
24
|
+
* The array makes it easy to rotate secrets. New secrets should be added to
|
|
25
|
+
* the beginning of the array. `cookie.serialize()` will always use the first
|
|
26
|
+
* value in the array, but `cookie.parse()` may use any of them so that
|
|
27
|
+
* cookies that were signed with older secrets still work.
|
|
28
|
+
*/
|
|
29
|
+
secrets?: string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A container for metadata about a HTTP cookie; its name and secrets that may be used
|
|
33
|
+
* to sign/unsign the value of the cookie to ensure it's not tampered with.
|
|
34
|
+
*/
|
|
35
|
+
export declare class Cookie {
|
|
36
|
+
#private;
|
|
37
|
+
readonly name: string;
|
|
38
|
+
constructor(name: string, options?: CookieOptions);
|
|
39
|
+
/**
|
|
40
|
+
* True if this cookie uses one or more secrets for verification.
|
|
41
|
+
*/
|
|
42
|
+
get isSigned(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Extracts the value of this cookie from a `Cookie` header value.
|
|
45
|
+
* @param headerValue The value of the `Cookie` header to parse
|
|
46
|
+
* @returns The value of this cookie, or `null` if it's not present
|
|
47
|
+
*/
|
|
48
|
+
parse(headerValue: string | null): Promise<string | null>;
|
|
49
|
+
/**
|
|
50
|
+
* Returns the value to use in a `Set-Cookie` header for this cookie.
|
|
51
|
+
* @param value The value to serialize
|
|
52
|
+
* @param props (optional) Additional properties to use when serializing the cookie
|
|
53
|
+
* @returns The value to use in a `Set-Cookie` header for this cookie
|
|
54
|
+
*/
|
|
55
|
+
serialize(value: string, props?: CookieProperties): Promise<string>;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=cookie.d.ts.map
|
|
@@ -0,0 +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,MAAM,WAAW,aAAa;IAC5B;;;;;;;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;;IACjB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;gBAKT,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa;IAOjD;;OAEG;IACH,IAAI,QAAQ,IAAI,OAAO,CAEtB;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;CAY1E"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Cookie as CookieHeader, SetCookie as SetCookieHeader, } from '@remix-run/headers';
|
|
2
|
+
import { sign, unsign } from "./crypto.js";
|
|
3
|
+
/**
|
|
4
|
+
* A container for metadata about a HTTP cookie; its name and secrets that may be used
|
|
5
|
+
* to sign/unsign the value of the cookie to ensure it's not tampered with.
|
|
6
|
+
*/
|
|
7
|
+
export class Cookie {
|
|
8
|
+
name;
|
|
9
|
+
#decode;
|
|
10
|
+
#encode;
|
|
11
|
+
#secrets;
|
|
12
|
+
constructor(name, options) {
|
|
13
|
+
this.name = name;
|
|
14
|
+
this.#decode = options?.decode;
|
|
15
|
+
this.#encode = options?.encode;
|
|
16
|
+
this.#secrets = options?.secrets ?? [];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* True if this cookie uses one or more secrets for verification.
|
|
20
|
+
*/
|
|
21
|
+
get isSigned() {
|
|
22
|
+
return this.#secrets.length > 0;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracts the value of this cookie from a `Cookie` header value.
|
|
26
|
+
* @param headerValue The value of the `Cookie` header to parse
|
|
27
|
+
* @returns The value of this cookie, or `null` if it's not present
|
|
28
|
+
*/
|
|
29
|
+
async parse(headerValue) {
|
|
30
|
+
if (!headerValue)
|
|
31
|
+
return null;
|
|
32
|
+
let header = new CookieHeader(headerValue);
|
|
33
|
+
if (!header.has(this.name))
|
|
34
|
+
return null;
|
|
35
|
+
let value = header.get(this.name);
|
|
36
|
+
if (value === '')
|
|
37
|
+
return '';
|
|
38
|
+
let decoded = await decodeCookieValue(value, this.#secrets, this.#decode);
|
|
39
|
+
return decoded;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns the value to use in a `Set-Cookie` header for this cookie.
|
|
43
|
+
* @param value The value to serialize
|
|
44
|
+
* @param props (optional) Additional properties to use when serializing the cookie
|
|
45
|
+
* @returns The value to use in a `Set-Cookie` header for this cookie
|
|
46
|
+
*/
|
|
47
|
+
async serialize(value, props) {
|
|
48
|
+
let header = new SetCookieHeader({
|
|
49
|
+
name: this.name,
|
|
50
|
+
value: value === '' ? '' : await encodeCookieValue(value, this.#secrets, this.#encode),
|
|
51
|
+
// sane defaults
|
|
52
|
+
path: '/',
|
|
53
|
+
sameSite: 'Lax',
|
|
54
|
+
...props,
|
|
55
|
+
});
|
|
56
|
+
return header.toString();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function encodeCookieValue(value, secrets, encode = encodeURIComponent) {
|
|
60
|
+
let encoded = encodeValue(value, encode);
|
|
61
|
+
if (secrets.length > 0) {
|
|
62
|
+
encoded = await sign(encoded, secrets[0]);
|
|
63
|
+
}
|
|
64
|
+
return encoded;
|
|
65
|
+
}
|
|
66
|
+
function encodeValue(value, encode) {
|
|
67
|
+
return btoa(myUnescape(encode(value)));
|
|
68
|
+
}
|
|
69
|
+
async function decodeCookieValue(value, secrets, decode = decodeURIComponent) {
|
|
70
|
+
if (secrets.length > 0) {
|
|
71
|
+
for (let secret of secrets) {
|
|
72
|
+
let unsignedValue = await unsign(value, secret);
|
|
73
|
+
if (unsignedValue !== false) {
|
|
74
|
+
return decodeValue(unsignedValue, decode);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return decodeValue(value, decode);
|
|
80
|
+
}
|
|
81
|
+
function decodeValue(value, decode) {
|
|
82
|
+
try {
|
|
83
|
+
return decode(myEscape(atob(value)));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.escape.js
|
|
90
|
+
function myEscape(value) {
|
|
91
|
+
let str = value.toString();
|
|
92
|
+
let result = '';
|
|
93
|
+
let index = 0;
|
|
94
|
+
let chr, code;
|
|
95
|
+
while (index < str.length) {
|
|
96
|
+
chr = str.charAt(index++);
|
|
97
|
+
if (/[\w*+\-./@]/.exec(chr)) {
|
|
98
|
+
result += chr;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
code = chr.charCodeAt(0);
|
|
102
|
+
if (code < 256) {
|
|
103
|
+
result += '%' + hex(code, 2);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
result += '%u' + hex(code, 4).toUpperCase();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
function hex(code, length) {
|
|
113
|
+
let result = code.toString(16);
|
|
114
|
+
while (result.length < length)
|
|
115
|
+
result = '0' + result;
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.unescape.js
|
|
119
|
+
function myUnescape(value) {
|
|
120
|
+
let str = value.toString();
|
|
121
|
+
let result = '';
|
|
122
|
+
let index = 0;
|
|
123
|
+
let chr, part;
|
|
124
|
+
while (index < str.length) {
|
|
125
|
+
chr = str.charAt(index++);
|
|
126
|
+
if (chr === '%') {
|
|
127
|
+
if (str.charAt(index) === 'u') {
|
|
128
|
+
part = str.slice(index + 1, index + 5);
|
|
129
|
+
if (/^[\da-f]{4}$/i.exec(part)) {
|
|
130
|
+
result += String.fromCharCode(parseInt(part, 16));
|
|
131
|
+
index += 5;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
part = str.slice(index, index + 2);
|
|
137
|
+
if (/^[\da-f]{2}$/i.exec(part)) {
|
|
138
|
+
result += String.fromCharCode(parseInt(part, 16));
|
|
139
|
+
index += 2;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
result += chr;
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
export async function sign(value, secret) {
|
|
3
|
+
let data = encoder.encode(value);
|
|
4
|
+
let key = await createKey(secret, ['sign']);
|
|
5
|
+
let signature = await crypto.subtle.sign('HMAC', key, data);
|
|
6
|
+
let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=+$/, '');
|
|
7
|
+
return value + '.' + hash;
|
|
8
|
+
}
|
|
9
|
+
export async function unsign(cookie, secret) {
|
|
10
|
+
let index = cookie.lastIndexOf('.');
|
|
11
|
+
let value = cookie.slice(0, index);
|
|
12
|
+
let hash = cookie.slice(index + 1);
|
|
13
|
+
let data = encoder.encode(value);
|
|
14
|
+
let key = await createKey(secret, ['verify']);
|
|
15
|
+
try {
|
|
16
|
+
let signature = byteStringToUint8Array(atob(hash));
|
|
17
|
+
let valid = await crypto.subtle.verify('HMAC', key, signature, data);
|
|
18
|
+
return valid ? value : false;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
// atob will throw a DOMException with name === 'InvalidCharacterError'
|
|
22
|
+
// if the signature contains a non-base64 character, which should just
|
|
23
|
+
// be treated as an invalid signature.
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function createKey(secret, usages) {
|
|
28
|
+
return crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, usages);
|
|
29
|
+
}
|
|
30
|
+
function byteStringToUint8Array(byteString) {
|
|
31
|
+
let array = new Uint8Array(byteString.length);
|
|
32
|
+
for (let i = 0; i < byteString.length; i++) {
|
|
33
|
+
array[i] = byteString.charCodeAt(i);
|
|
34
|
+
}
|
|
35
|
+
return array;
|
|
36
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remix-run/cookie",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A toolkit for working with cookies in JavaScript",
|
|
5
|
+
"author": "Michael Jackson <mjijackson@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/remix-run/remix.git",
|
|
10
|
+
"directory": "packages/cookie"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/remix-run/remix/tree/main/packages/cookie#readme",
|
|
13
|
+
"files": [
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"README.md",
|
|
16
|
+
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"!src/**/*.test.ts"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^24.6.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@remix-run/headers": "0.14.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"http",
|
|
36
|
+
"cookie",
|
|
37
|
+
"cookies",
|
|
38
|
+
"http-cookies",
|
|
39
|
+
"set-cookie"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"clean": "git clean -fdX",
|
|
44
|
+
"test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type CookieOptions, Cookie } from './lib/cookie.ts'
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Cookie as CookieHeader,
|
|
3
|
+
SetCookie as SetCookieHeader,
|
|
4
|
+
type CookieProperties,
|
|
5
|
+
} from '@remix-run/headers'
|
|
6
|
+
|
|
7
|
+
import { sign, unsign } from './crypto.ts'
|
|
8
|
+
|
|
9
|
+
export interface CookieOptions {
|
|
10
|
+
/**
|
|
11
|
+
* A function that decodes the cookie value.
|
|
12
|
+
*
|
|
13
|
+
* Defaults to `decodeURIComponent`, which decodes any URL-encoded sequences into their original
|
|
14
|
+
* characters.
|
|
15
|
+
*
|
|
16
|
+
* See [RFC 6265](https://tools.ietf.org/html/rfc6265#section-4.1.1) for more details.
|
|
17
|
+
*/
|
|
18
|
+
decode?: (value: string) => string
|
|
19
|
+
/**
|
|
20
|
+
* A function that encodes the cookie value.
|
|
21
|
+
*
|
|
22
|
+
* Defaults to `encodeURIComponent`, which percent-encodes all characters that are not allowed
|
|
23
|
+
* in a cookie value.
|
|
24
|
+
*
|
|
25
|
+
* See [RFC 6265](https://tools.ietf.org/html/rfc6265#section-4.1.1) for more details.
|
|
26
|
+
*/
|
|
27
|
+
encode?: (value: string) => string
|
|
28
|
+
/**
|
|
29
|
+
* An array of secrets that may be used to sign/unsign the value of a cookie.
|
|
30
|
+
*
|
|
31
|
+
* The array makes it easy to rotate secrets. New secrets should be added to
|
|
32
|
+
* the beginning of the array. `cookie.serialize()` will always use the first
|
|
33
|
+
* value in the array, but `cookie.parse()` may use any of them so that
|
|
34
|
+
* cookies that were signed with older secrets still work.
|
|
35
|
+
*/
|
|
36
|
+
secrets?: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A container for metadata about a HTTP cookie; its name and secrets that may be used
|
|
41
|
+
* to sign/unsign the value of the cookie to ensure it's not tampered with.
|
|
42
|
+
*/
|
|
43
|
+
export class Cookie {
|
|
44
|
+
readonly name: string
|
|
45
|
+
readonly #decode?: (value: string) => string
|
|
46
|
+
readonly #encode?: (value: string) => string
|
|
47
|
+
readonly #secrets: string[]
|
|
48
|
+
|
|
49
|
+
constructor(name: string, options?: CookieOptions) {
|
|
50
|
+
this.name = name
|
|
51
|
+
this.#decode = options?.decode
|
|
52
|
+
this.#encode = options?.encode
|
|
53
|
+
this.#secrets = options?.secrets ?? []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True if this cookie uses one or more secrets for verification.
|
|
58
|
+
*/
|
|
59
|
+
get isSigned(): boolean {
|
|
60
|
+
return this.#secrets.length > 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extracts the value of this cookie from a `Cookie` header value.
|
|
65
|
+
* @param headerValue The value of the `Cookie` header to parse
|
|
66
|
+
* @returns The value of this cookie, or `null` if it's not present
|
|
67
|
+
*/
|
|
68
|
+
async parse(headerValue: string | null): Promise<string | null> {
|
|
69
|
+
if (!headerValue) return null
|
|
70
|
+
|
|
71
|
+
let header = new CookieHeader(headerValue)
|
|
72
|
+
if (!header.has(this.name)) return null
|
|
73
|
+
|
|
74
|
+
let value = header.get(this.name)!
|
|
75
|
+
if (value === '') return ''
|
|
76
|
+
|
|
77
|
+
let decoded = await decodeCookieValue(value, this.#secrets, this.#decode)
|
|
78
|
+
return decoded
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns the value to use in a `Set-Cookie` header for this cookie.
|
|
83
|
+
* @param value The value to serialize
|
|
84
|
+
* @param props (optional) Additional properties to use when serializing the cookie
|
|
85
|
+
* @returns The value to use in a `Set-Cookie` header for this cookie
|
|
86
|
+
*/
|
|
87
|
+
async serialize(value: string, props?: CookieProperties): Promise<string> {
|
|
88
|
+
let header = new SetCookieHeader({
|
|
89
|
+
name: this.name,
|
|
90
|
+
value: value === '' ? '' : await encodeCookieValue(value, this.#secrets, this.#encode),
|
|
91
|
+
// sane defaults
|
|
92
|
+
path: '/',
|
|
93
|
+
sameSite: 'Lax',
|
|
94
|
+
...props,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return header.toString()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function encodeCookieValue(
|
|
102
|
+
value: string,
|
|
103
|
+
secrets: string[],
|
|
104
|
+
encode: (value: string) => string = encodeURIComponent,
|
|
105
|
+
): Promise<string> {
|
|
106
|
+
let encoded = encodeValue(value, encode)
|
|
107
|
+
|
|
108
|
+
if (secrets.length > 0) {
|
|
109
|
+
encoded = await sign(encoded, secrets[0])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return encoded
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function encodeValue(value: string, encode: (value: string) => string): string {
|
|
116
|
+
return btoa(myUnescape(encode(value)))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function decodeCookieValue(
|
|
120
|
+
value: string,
|
|
121
|
+
secrets: string[],
|
|
122
|
+
decode: (value: string) => string = decodeURIComponent,
|
|
123
|
+
): Promise<string | null> {
|
|
124
|
+
if (secrets.length > 0) {
|
|
125
|
+
for (let secret of secrets) {
|
|
126
|
+
let unsignedValue = await unsign(value, secret)
|
|
127
|
+
if (unsignedValue !== false) {
|
|
128
|
+
return decodeValue(unsignedValue, decode)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return decodeValue(value, decode)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function decodeValue(value: string, decode: (value: string) => string): string | null {
|
|
139
|
+
try {
|
|
140
|
+
return decode(myEscape(atob(value)))
|
|
141
|
+
} catch {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.escape.js
|
|
147
|
+
function myEscape(value: string): string {
|
|
148
|
+
let str = value.toString()
|
|
149
|
+
let result = ''
|
|
150
|
+
let index = 0
|
|
151
|
+
let chr, code
|
|
152
|
+
while (index < str.length) {
|
|
153
|
+
chr = str.charAt(index++)
|
|
154
|
+
if (/[\w*+\-./@]/.exec(chr)) {
|
|
155
|
+
result += chr
|
|
156
|
+
} else {
|
|
157
|
+
code = chr.charCodeAt(0)
|
|
158
|
+
if (code < 256) {
|
|
159
|
+
result += '%' + hex(code, 2)
|
|
160
|
+
} else {
|
|
161
|
+
result += '%u' + hex(code, 4).toUpperCase()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return result
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hex(code: number, length: number): string {
|
|
169
|
+
let result = code.toString(16)
|
|
170
|
+
while (result.length < length) result = '0' + result
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.unescape.js
|
|
175
|
+
function myUnescape(value: string): string {
|
|
176
|
+
let str = value.toString()
|
|
177
|
+
let result = ''
|
|
178
|
+
let index = 0
|
|
179
|
+
let chr, part
|
|
180
|
+
while (index < str.length) {
|
|
181
|
+
chr = str.charAt(index++)
|
|
182
|
+
if (chr === '%') {
|
|
183
|
+
if (str.charAt(index) === 'u') {
|
|
184
|
+
part = str.slice(index + 1, index + 5)
|
|
185
|
+
if (/^[\da-f]{4}$/i.exec(part)) {
|
|
186
|
+
result += String.fromCharCode(parseInt(part, 16))
|
|
187
|
+
index += 5
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
part = str.slice(index, index + 2)
|
|
192
|
+
if (/^[\da-f]{2}$/i.exec(part)) {
|
|
193
|
+
result += String.fromCharCode(parseInt(part, 16))
|
|
194
|
+
index += 2
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
result += chr
|
|
200
|
+
}
|
|
201
|
+
return result
|
|
202
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const encoder = new TextEncoder()
|
|
2
|
+
|
|
3
|
+
export async function sign(value: string, secret: string): Promise<string> {
|
|
4
|
+
let data = encoder.encode(value)
|
|
5
|
+
let key = await createKey(secret, ['sign'])
|
|
6
|
+
let signature = await crypto.subtle.sign('HMAC', key, data)
|
|
7
|
+
let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=+$/, '')
|
|
8
|
+
|
|
9
|
+
return value + '.' + hash
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function unsign(cookie: string, secret: string): Promise<string | false> {
|
|
13
|
+
let index = cookie.lastIndexOf('.')
|
|
14
|
+
let value = cookie.slice(0, index)
|
|
15
|
+
let hash = cookie.slice(index + 1)
|
|
16
|
+
|
|
17
|
+
let data = encoder.encode(value)
|
|
18
|
+
|
|
19
|
+
let key = await createKey(secret, ['verify'])
|
|
20
|
+
try {
|
|
21
|
+
let signature = byteStringToUint8Array(atob(hash))
|
|
22
|
+
let valid = await crypto.subtle.verify('HMAC', key, signature, data)
|
|
23
|
+
|
|
24
|
+
return valid ? value : false
|
|
25
|
+
} catch (error: unknown) {
|
|
26
|
+
// atob will throw a DOMException with name === 'InvalidCharacterError'
|
|
27
|
+
// if the signature contains a non-base64 character, which should just
|
|
28
|
+
// be treated as an invalid signature.
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function createKey(secret: string, usages: CryptoKey['usages']): Promise<CryptoKey> {
|
|
34
|
+
return crypto.subtle.importKey(
|
|
35
|
+
'raw',
|
|
36
|
+
encoder.encode(secret),
|
|
37
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
38
|
+
false,
|
|
39
|
+
usages,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function byteStringToUint8Array(byteString: string): Uint8Array {
|
|
44
|
+
let array = new Uint8Array(byteString.length)
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < byteString.length; i++) {
|
|
47
|
+
array[i] = byteString.charCodeAt(i)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return array
|
|
51
|
+
}
|