@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 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)
@@ -0,0 +1,2 @@
1
+ export { type CookieOptions, Cookie } from './lib/cookie.ts';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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,3 @@
1
+ export declare function sign(value: string, secret: string): Promise<string>;
2
+ export declare function unsign(cookie: string, secret: string): Promise<string | false>;
3
+ //# sourceMappingURL=crypto.d.ts.map
@@ -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
+ }