@remix-run/cookie 0.2.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 +14 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/{crypto.d.ts → cookie-signing.d.ts} +1 -1
- package/dist/lib/cookie-signing.d.ts.map +1 -0
- package/dist/lib/{crypto.js → cookie-signing.js} +5 -2
- package/dist/lib/cookie.d.ts +89 -25
- package/dist/lib/cookie.d.ts.map +1 -1
- package/dist/lib/cookie.js +77 -65
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/lib/{crypto.ts → cookie-signing.ts} +8 -4
- package/src/lib/cookie.ts +173 -73
- package/dist/lib/crypto.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cookie
|
|
2
2
|
|
|
3
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
4
|
|
|
@@ -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 {
|
|
22
|
+
import { createCookie } from '@remix-run/cookie'
|
|
25
23
|
|
|
26
|
-
let sessionCookie =
|
|
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 =
|
|
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 =
|
|
63
|
+
let sessionCookie = createCookie('session', {
|
|
66
64
|
secrets: ['secret2', 'secret1'],
|
|
67
65
|
})
|
|
68
66
|
|
|
69
|
-
// This
|
|
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 =
|
|
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 { type CookieOptions,
|
|
1
|
+
export { type Cookie, type CookieOptions, createCookie } from './lib/cookie.ts';
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAE,
|
|
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 {
|
|
1
|
+
export { createCookie } from "./lib/cookie.js";
|
|
@@ -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 =
|
|
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
|
|
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);
|
package/dist/lib/cookie.d.ts
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
import { type CookieProperties } from '@remix-run/headers';
|
|
2
|
-
|
|
2
|
+
type SameSiteValue = 'Strict' | 'Lax' | 'None';
|
|
3
|
+
/**
|
|
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.
|
|
10
|
+
*/
|
|
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
|
+
}
|
|
84
|
+
export interface CookieOptions extends CookieProperties {
|
|
3
85
|
/**
|
|
4
86
|
* A function that decodes the cookie value.
|
|
5
87
|
*
|
|
@@ -29,29 +111,11 @@ export interface CookieOptions {
|
|
|
29
111
|
secrets?: string[];
|
|
30
112
|
}
|
|
31
113
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
34
118
|
*/
|
|
35
|
-
export declare
|
|
36
|
-
|
|
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
|
-
}
|
|
119
|
+
export declare function createCookie(name: string, options?: CookieOptions): Cookie;
|
|
120
|
+
export {};
|
|
57
121
|
//# sourceMappingURL=cookie.d.ts.map
|
package/dist/lib/cookie.d.ts.map
CHANGED
|
@@ -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,MAAM,WAAW,
|
|
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"}
|
package/dist/lib/cookie.js
CHANGED
|
@@ -1,72 +1,75 @@
|
|
|
1
1
|
import { Cookie as CookieHeader, SetCookie as SetCookieHeader, } from '@remix-run/headers';
|
|
2
|
-
import { sign, unsign } from "./
|
|
2
|
+
import { sign, unsign } from "./cookie-signing.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Creates a new cookie object.
|
|
5
|
+
* @param name The name of the cookie
|
|
6
|
+
* @param options (optional) Additional options for the cookie
|
|
7
|
+
* @returns A cookie object
|
|
6
8
|
*/
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
9
|
+
export function createCookie(name, options) {
|
|
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
|
+
};
|
|
65
71
|
}
|
|
66
|
-
function
|
|
67
|
-
return btoa(myUnescape(encode(value)));
|
|
68
|
-
}
|
|
69
|
-
async function decodeCookieValue(value, secrets, decode = decodeURIComponent) {
|
|
72
|
+
async function decodeCookieValue(value, secrets, decode) {
|
|
70
73
|
if (secrets.length > 0) {
|
|
71
74
|
for (let secret of secrets) {
|
|
72
75
|
let unsignedValue = await unsign(value, secret);
|
|
@@ -115,6 +118,15 @@ function hex(code, length) {
|
|
|
115
118
|
result = '0' + result;
|
|
116
119
|
return result;
|
|
117
120
|
}
|
|
121
|
+
async function encodeCookieValue(value, secrets, encode) {
|
|
122
|
+
let encoded = encodeValue(value, encode);
|
|
123
|
+
if (secrets.length > 0)
|
|
124
|
+
encoded = await sign(encoded, secrets[0]);
|
|
125
|
+
return encoded;
|
|
126
|
+
}
|
|
127
|
+
function encodeValue(value, encode) {
|
|
128
|
+
return btoa(myUnescape(encode(value)));
|
|
129
|
+
}
|
|
118
130
|
// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.unescape.js
|
|
119
131
|
function myUnescape(value) {
|
|
120
132
|
let str = value.toString();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remix-run/cookie",
|
|
3
|
-
"version": "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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@types/node": "^24.6.0"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@remix-run/headers": "0.
|
|
32
|
+
"@remix-run/headers": "^0.16.0"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
35
|
"http",
|
package/src/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { type CookieOptions,
|
|
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 =
|
|
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
|
|
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,9 +4,93 @@ import {
|
|
|
4
4
|
type CookieProperties,
|
|
5
5
|
} from '@remix-run/headers'
|
|
6
6
|
|
|
7
|
-
import { sign, unsign } from './
|
|
7
|
+
import { sign, unsign } from './cookie-signing.ts'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
type SameSiteValue = 'Strict' | 'Lax' | 'None'
|
|
10
|
+
|
|
11
|
+
/**
|
|
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.
|
|
18
|
+
*/
|
|
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
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CookieOptions extends CookieProperties {
|
|
10
94
|
/**
|
|
11
95
|
* A function that decodes the cookie value.
|
|
12
96
|
*
|
|
@@ -37,89 +121,95 @@ export interface CookieOptions {
|
|
|
37
121
|
}
|
|
38
122
|
|
|
39
123
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
42
128
|
*/
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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 ?? {}
|
|
73
143
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
76
162
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
163
|
+
let header = new CookieHeader(headerValue)
|
|
164
|
+
if (!header.has(name)) return null
|
|
80
165
|
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
}
|
|
166
|
+
let value = header.get(name)!
|
|
167
|
+
if (value === '') return ''
|
|
100
168
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
})
|
|
107
198
|
|
|
108
|
-
|
|
109
|
-
|
|
199
|
+
return header.toString()
|
|
200
|
+
},
|
|
201
|
+
get signed() {
|
|
202
|
+
return secrets.length > 0
|
|
203
|
+
},
|
|
110
204
|
}
|
|
111
|
-
|
|
112
|
-
return encoded
|
|
113
205
|
}
|
|
114
206
|
|
|
115
|
-
|
|
116
|
-
return btoa(myUnescape(encode(value)))
|
|
117
|
-
}
|
|
207
|
+
type Coder = (value: string) => string
|
|
118
208
|
|
|
119
209
|
async function decodeCookieValue(
|
|
120
210
|
value: string,
|
|
121
211
|
secrets: string[],
|
|
122
|
-
decode:
|
|
212
|
+
decode: Coder,
|
|
123
213
|
): Promise<string | null> {
|
|
124
214
|
if (secrets.length > 0) {
|
|
125
215
|
for (let secret of secrets) {
|
|
@@ -135,7 +225,7 @@ async function decodeCookieValue(
|
|
|
135
225
|
return decodeValue(value, decode)
|
|
136
226
|
}
|
|
137
227
|
|
|
138
|
-
function decodeValue(value: string, decode:
|
|
228
|
+
function decodeValue(value: string, decode: Coder): string | null {
|
|
139
229
|
try {
|
|
140
230
|
return decode(myEscape(atob(value)))
|
|
141
231
|
} catch {
|
|
@@ -171,6 +261,16 @@ function hex(code: number, length: number): string {
|
|
|
171
261
|
return result
|
|
172
262
|
}
|
|
173
263
|
|
|
264
|
+
async function encodeCookieValue(value: string, secrets: string[], encode: Coder): Promise<string> {
|
|
265
|
+
let encoded = encodeValue(value, encode)
|
|
266
|
+
if (secrets.length > 0) encoded = await sign(encoded, secrets[0])
|
|
267
|
+
return encoded
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function encodeValue(value: string, encode: Coder): string {
|
|
271
|
+
return btoa(myUnescape(encode(value)))
|
|
272
|
+
}
|
|
273
|
+
|
|
174
274
|
// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.unescape.js
|
|
175
275
|
function myUnescape(value: string): string {
|
|
176
276
|
let str = value.toString()
|
package/dist/lib/crypto.d.ts.map
DELETED
|
@@ -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"}
|