@travetto/web 6.0.0-rc.3 → 6.0.0-rc.4
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/__index__.ts +1 -1
- package/package.json +1 -1
- package/src/interceptor/{cookies.ts → cookie.ts} +11 -6
- package/src/types/cookie.ts +1 -1
- package/src/util/cookie.ts +106 -88
package/__index__.ts
CHANGED
|
@@ -26,7 +26,7 @@ export * from './src/registry/types.ts';
|
|
|
26
26
|
export * from './src/interceptor/accept.ts';
|
|
27
27
|
export * from './src/interceptor/body-parse.ts';
|
|
28
28
|
export * from './src/interceptor/cors.ts';
|
|
29
|
-
export * from './src/interceptor/
|
|
29
|
+
export * from './src/interceptor/cookie.ts';
|
|
30
30
|
export * from './src/interceptor/compress.ts';
|
|
31
31
|
export * from './src/interceptor/context.ts';
|
|
32
32
|
export * from './src/interceptor/decompress.ts';
|
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ export class CookieConfig implements CookieSetOptions {
|
|
|
25
25
|
/**
|
|
26
26
|
* Are they signed
|
|
27
27
|
*/
|
|
28
|
-
signed
|
|
28
|
+
signed?: boolean;
|
|
29
29
|
/**
|
|
30
30
|
* Supported only via http (not in JS)
|
|
31
31
|
*/
|
|
@@ -42,18 +42,22 @@ export class CookieConfig implements CookieSetOptions {
|
|
|
42
42
|
/**
|
|
43
43
|
* Is the cookie only valid for https
|
|
44
44
|
*/
|
|
45
|
-
secure?: boolean
|
|
45
|
+
secure?: boolean;
|
|
46
46
|
/**
|
|
47
47
|
* The domain of the cookie
|
|
48
48
|
*/
|
|
49
49
|
domain?: string;
|
|
50
|
+
/**
|
|
51
|
+
* The default path of the cookie
|
|
52
|
+
*/
|
|
53
|
+
path: string = '/';
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
57
|
* Loads cookies from the request, verifies, exposes, and then signs and sets
|
|
54
58
|
*/
|
|
55
59
|
@Injectable()
|
|
56
|
-
export class
|
|
60
|
+
export class CookieInterceptor implements WebInterceptor<CookieConfig> {
|
|
57
61
|
|
|
58
62
|
#cookieJar = new AsyncContextValue<CookieJar>(this);
|
|
59
63
|
|
|
@@ -77,8 +81,9 @@ export class CookiesInterceptor implements WebInterceptor<CookieConfig> {
|
|
|
77
81
|
|
|
78
82
|
finalizeConfig({ config }: WebInterceptorContext<CookieConfig>): CookieConfig {
|
|
79
83
|
const url = new URL(this.webConfig.baseUrl ?? 'x://localhost');
|
|
80
|
-
config.secure ??= url.protocol === 'https';
|
|
84
|
+
config.secure ??= url.protocol === 'https:';
|
|
81
85
|
config.domain ??= url.hostname;
|
|
86
|
+
config.signed ??= !!config.keys?.length;
|
|
82
87
|
return config;
|
|
83
88
|
}
|
|
84
89
|
|
|
@@ -87,11 +92,11 @@ export class CookiesInterceptor implements WebInterceptor<CookieConfig> {
|
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
async filter({ request, config, next }: WebChainedContext<CookieConfig>): Promise<WebResponse> {
|
|
90
|
-
const jar = new CookieJar(request.headers.get('Cookie')
|
|
95
|
+
const jar = new CookieJar(config).importCookieHeader(request.headers.get('Cookie'));
|
|
91
96
|
this.#cookieJar.set(jar);
|
|
92
97
|
|
|
93
98
|
const response = await next();
|
|
94
|
-
for (const c of jar.
|
|
99
|
+
for (const c of jar.exportSetCookieHeader()) { response.headers.append('Set-Cookie', c); }
|
|
95
100
|
return response;
|
|
96
101
|
}
|
|
97
102
|
}
|
package/src/types/cookie.ts
CHANGED
package/src/util/cookie.ts
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import keygrip from 'keygrip';
|
|
2
2
|
import { AppError, castKey, castTo } from '@travetto/runtime';
|
|
3
3
|
|
|
4
|
-
import { Cookie, CookieGetOptions } from '../types/cookie.ts';
|
|
4
|
+
import { Cookie, CookieGetOptions, CookieSetOptions } from '../types/cookie.ts';
|
|
5
5
|
|
|
6
6
|
const pairText = (c: Cookie): string => `${c.name}=${c.value}`;
|
|
7
7
|
const pair = (k: string, v: unknown): string => `${k}=${v}`;
|
|
8
8
|
|
|
9
|
+
type CookieJarOptions = { keys?: string[] } & CookieSetOptions;
|
|
10
|
+
|
|
9
11
|
export class CookieJar {
|
|
10
12
|
|
|
11
|
-
static
|
|
13
|
+
static parseCookieHeader(header: string): Cookie[] {
|
|
14
|
+
return header.split(/\s{0,4};\s{0,4}/g)
|
|
15
|
+
.map(x => x.trim())
|
|
16
|
+
.filter(x => !!x)
|
|
17
|
+
.map(item => {
|
|
18
|
+
const kv = item.split(/\s{0,4}=\s{0,4}/);
|
|
19
|
+
return { name: kv[0], value: kv[1] };
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static parseSetCookieHeader(header: string): Cookie {
|
|
12
24
|
const parts = header.split(/\s{0,4};\s{0,4}/g);
|
|
13
25
|
const [name, value] = parts[0].split(/\s{0,4}=\s{0,4}/);
|
|
14
26
|
const c: Cookie = { name, value };
|
|
@@ -27,119 +39,125 @@ export class CookieJar {
|
|
|
27
39
|
return c;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
static
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (c.path) { header.push(pair('path', c.path)); }
|
|
42
|
-
if (c.expires) { header.push(pair('expires', c.expires.toUTCString())); }
|
|
43
|
-
if (c.domain) { header.push(pair('domain', c.domain)); }
|
|
44
|
-
if (c.priority) { header.push(pair('priority', c.priority.toLowerCase())); }
|
|
45
|
-
if (c.sameSite) { header.push(pair('samesite', c.sameSite.toLowerCase())); }
|
|
46
|
-
if (c.secure) { header.push('secure'); }
|
|
47
|
-
if (c.httpOnly) { header.push('httponly'); }
|
|
48
|
-
if (c.partitioned) { header.push('partitioned'); }
|
|
49
|
-
}
|
|
50
|
-
return header.join(';');
|
|
42
|
+
static responseSuffix(c: Cookie): string[] {
|
|
43
|
+
const parts = [];
|
|
44
|
+
if (c.path) { parts.push(pair('path', c.path)); }
|
|
45
|
+
if (c.expires) { parts.push(pair('expires', c.expires.toUTCString())); }
|
|
46
|
+
if (c.domain) { parts.push(pair('domain', c.domain)); }
|
|
47
|
+
if (c.priority) { parts.push(pair('priority', c.priority.toLowerCase())); }
|
|
48
|
+
if (c.sameSite) { parts.push(pair('samesite', c.sameSite.toLowerCase())); }
|
|
49
|
+
if (c.secure) { parts.push('secure'); }
|
|
50
|
+
if (c.httpOnly) { parts.push('httponly'); }
|
|
51
|
+
if (c.partitioned) { parts.push('partitioned'); }
|
|
52
|
+
return parts;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
#secure?: boolean;
|
|
54
55
|
#grip?: keygrip;
|
|
55
56
|
#cookies: Record<string, Cookie> = {};
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
57
|
+
#setOptions: CookieSetOptions = {};
|
|
58
|
+
#deleteOptions: CookieSetOptions = { maxAge: 0, expires: undefined };
|
|
59
|
+
|
|
60
|
+
constructor({ keys, ...options }: CookieJarOptions = {}) {
|
|
61
|
+
this.#grip = keys?.length ? new keygrip(keys) : undefined;
|
|
62
|
+
this.#setOptions = {
|
|
63
|
+
secure: false,
|
|
64
|
+
path: '/',
|
|
65
|
+
signed: !!keys?.length,
|
|
66
|
+
...options,
|
|
67
|
+
};
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
c.signed = index >= 0;
|
|
77
|
-
sc.signed = false;
|
|
78
|
-
sc.secure = c.secure;
|
|
79
|
-
|
|
80
|
-
if (index >= 1) {
|
|
81
|
-
sc.value = this.#grip.sign(key);
|
|
82
|
-
sc.response = true;
|
|
83
|
-
return sc;
|
|
70
|
+
#exportCookie(cookie: Cookie, response?: boolean): string[] {
|
|
71
|
+
const suffix = response ? CookieJar.responseSuffix(cookie) : null;
|
|
72
|
+
const payload = pairText(cookie);
|
|
73
|
+
const out = suffix ? [[payload, ...suffix].join(';')] : [payload];
|
|
74
|
+
if (cookie.signed) {
|
|
75
|
+
const sigPair = pair(`${cookie.name}.sig`, this.#grip!.sign(payload));
|
|
76
|
+
out.push(suffix ? [sigPair, ...suffix].join(';') : sigPair);
|
|
84
77
|
}
|
|
78
|
+
return out;
|
|
85
79
|
}
|
|
86
80
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
import(cookies: Cookie[]): this {
|
|
82
|
+
const signatures: Record<string, string> = {};
|
|
83
|
+
for (const cookie of cookies) {
|
|
84
|
+
if (this.#setOptions.signed && cookie.name.endsWith('.sig')) {
|
|
85
|
+
signatures[cookie.name.replace(/[.]sig$/, '')] = cookie.value!;
|
|
86
|
+
} else {
|
|
87
|
+
this.#cookies[cookie.name] = { signed: false, ...cookie };
|
|
88
|
+
}
|
|
92
89
|
}
|
|
93
|
-
return { ...c, name: `${c.name}.sig`, value: this.#grip.sign(pairText(c)) };
|
|
94
|
-
}
|
|
95
90
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.#cookies[c.name] = c;
|
|
101
|
-
if (this.#grip && !c.name.endsWith('.sig')) {
|
|
102
|
-
toCheck.push(c);
|
|
91
|
+
for (const [name, value] of Object.entries(signatures)) {
|
|
92
|
+
const cookie = this.#cookies[name];
|
|
93
|
+
if (!cookie) {
|
|
94
|
+
continue;
|
|
103
95
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
96
|
+
cookie.signed = true;
|
|
97
|
+
|
|
98
|
+
const computed = pairText(cookie);
|
|
99
|
+
const index = this.#grip!.index(computed, value);
|
|
100
|
+
|
|
101
|
+
if (index < 0) {
|
|
102
|
+
delete this.#cookies[name];
|
|
103
|
+
} else if (index >= 1) {
|
|
104
|
+
cookie.response = true;
|
|
109
105
|
}
|
|
110
106
|
}
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
has(name: string, opts: CookieGetOptions = {}): boolean {
|
|
111
|
+
const needSigned = opts.signed ?? this.#setOptions.signed;
|
|
112
|
+
return name in this.#cookies && this.#cookies[name].signed === needSigned;
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
get(name: string, opts: CookieGetOptions = {}): string | undefined {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
if (this.has(name, opts)) {
|
|
117
|
+
return this.#cookies[name]?.value;
|
|
118
|
+
}
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
set(
|
|
119
|
-
this.#cookies[
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
set(cookie: Cookie): void {
|
|
122
|
+
const alias = this.#cookies[cookie.name] = {
|
|
123
|
+
...this.#setOptions,
|
|
124
|
+
...cookie,
|
|
125
|
+
response: true,
|
|
126
|
+
...(cookie.value === null || cookie.value === undefined) ? this.#deleteOptions : {},
|
|
127
|
+
};
|
|
123
128
|
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
c.expires = undefined;
|
|
129
|
+
if (!this.#setOptions.secure && alias.secure) {
|
|
130
|
+
throw new AppError('Cannot send secure cookie over unencrypted connection');
|
|
127
131
|
}
|
|
128
132
|
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
this.#cookies[sc.name] = sc;
|
|
132
|
-
sc.response = true;
|
|
133
|
+
if (alias.signed && !this.#grip) {
|
|
134
|
+
throw new AppError('Signing keys required for signed cookies');
|
|
133
135
|
}
|
|
134
|
-
}
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.map(c => CookieJar.toHeaderValue(c, response));
|
|
137
|
+
if (alias.maxAge !== undefined && !alias.expires) {
|
|
138
|
+
alias.expires = new Date(Date.now() + alias.maxAge);
|
|
139
|
+
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
getAll(): Cookie[] {
|
|
143
143
|
return Object.values(this.#cookies);
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
importCookieHeader(header: string | null | undefined): this {
|
|
147
|
+
return this.import(CookieJar.parseCookieHeader(header ?? ''));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
importSetCookieHeader(headers: string[] | null | undefined): this {
|
|
151
|
+
return this.import(headers?.map(CookieJar.parseSetCookieHeader) ?? []);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
exportCookieHeader(): string {
|
|
155
|
+
return this.getAll().flatMap(c => this.#exportCookie(c)).join('; ');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
exportSetCookieHeader(): string[] {
|
|
159
|
+
return this.getAll()
|
|
160
|
+
.filter(x => x.response)
|
|
161
|
+
.flatMap(c => this.#exportCookie(c, true));
|
|
162
|
+
}
|
|
145
163
|
}
|