@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 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/cookies.ts';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/web",
3
- "version": "6.0.0-rc.3",
3
+ "version": "6.0.0-rc.4",
4
4
  "description": "Declarative api for Web Applications with support for the dependency injection.",
5
5
  "keywords": [
6
6
  "web",
@@ -25,7 +25,7 @@ export class CookieConfig implements CookieSetOptions {
25
25
  /**
26
26
  * Are they signed
27
27
  */
28
- signed = true;
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 = false;
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 CookiesInterceptor implements WebInterceptor<CookieConfig> {
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'), config);
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.export()) { response.headers.append('Set-Cookie', c); }
99
+ for (const c of jar.exportSetCookieHeader()) { response.headers.append('Set-Cookie', c); }
95
100
  return response;
96
101
  }
97
102
  }
@@ -15,4 +15,4 @@ export type Cookie = {
15
15
  };
16
16
 
17
17
  export type CookieGetOptions = { signed?: boolean };
18
- export type CookieSetOptions = Omit<Cookie, 'name' | 'value'>;
18
+ export type CookieSetOptions = Omit<Cookie, 'name' | 'value' | 'response'>;
@@ -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 fromHeaderValue(header: string): Cookie {
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 toHeaderValue(c: Cookie, response = true): string {
31
- const header = [pair(c.name, c.value)];
32
- if (response) {
33
- if (!c.value) {
34
- c.expires = new Date(0);
35
- c.maxAge = undefined;
36
- }
37
- if (c.maxAge) {
38
- c.expires = new Date(Date.now() + c.maxAge);
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
- constructor(input?: string | string[] | null | undefined | Cookie[] | CookieJar, options?: { keys?: string[], secure?: boolean }) {
58
- this.#grip = options?.keys?.length ? new keygrip(options.keys) : undefined;
59
- this.#secure = options?.secure ?? false;
60
- if (input instanceof CookieJar) {
61
- this.#cookies = { ...input.#cookies };
62
- } else if (Array.isArray(input)) {
63
- this.#import(input);
64
- } else {
65
- this.#import(input?.split(/\s{0,4},\s{0,4}/) ?? []);
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
- #checkSignature(c: Cookie): Cookie | undefined {
70
- if (!this.#grip) { return; }
71
- const key = pairText(c);
72
- const sc = this.#cookies[`${c.name}.sig`];
73
- if (!sc.value) { return; }
74
-
75
- const index = this.#grip.index(key, sc.value);
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
- #signCookie(c: Cookie): Cookie {
88
- if (!this.#grip) {
89
- throw new AppError('.keys required for signed cookies');
90
- } else if (!this.#secure && c.secure) {
91
- throw new AppError('Cannot send secure cookie over unencrypted connection');
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
- #import(inputs: (string | Cookie)[]): void {
97
- const toCheck = [];
98
- for (const input of inputs) {
99
- const c = typeof input === 'string' ? CookieJar.fromHeaderValue(input) : input;
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
- for (const c of toCheck) {
106
- const sc = this.#checkSignature(c);
107
- if (sc) {
108
- this.set(sc);
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
- const c = this.#cookies[name];
115
- return (c?.signed || !(opts.signed ?? !!this.#grip)) ? c?.value : undefined;
116
+ if (this.has(name, opts)) {
117
+ return this.#cookies[name]?.value;
118
+ }
116
119
  }
117
120
 
118
- set(c: Cookie): void {
119
- this.#cookies[c.name] = c;
120
- c.secure ??= this.#secure;
121
- c.signed ??= !!this.#grip;
122
- c.response = true;
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 (c.value === null || c.value === undefined) {
125
- c.maxAge = -1;
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 (c.signed) {
130
- const sc = this.#signCookie(c);
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
- export(response = true): string[] {
137
- return this.getAll()
138
- .filter(x => !response || x.response)
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
  }