Skip to content

Commit 853f751

Browse files
authored
feat: Cookies and encryption module (#11)
* feat: Encryption module * Cookies * Fix TSC * chore: Automate Rollup external deps
1 parent 658a612 commit 853f751

File tree

14 files changed

+504
-32
lines changed

14 files changed

+504
-32
lines changed

.eslintrc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
{
1010
"files": ["__tests__/**/*.ts"],
1111
"rules": {
12-
"@typescript-eslint/consistent-type-assertions": "off"
12+
"@typescript-eslint/consistent-type-assertions": "off",
13+
"@typescript-eslint/no-unsafe-member-access": "off",
14+
"@typescript-eslint/no-non-null-assertion": "off"
1315
}
1416
}
1517
]

__tests__/encrypt.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Tests based on https://github.com/hapijs/iron/blob/93fd15c76e656b1973ba134de64f3aeac66a0405/test/index.js
3+
* Copyright (c) 2012-2020, Sideway Inc, and project contributors
4+
* All rights reserved.
5+
* https://github.com/hapijs/iron/blob/93fd15c76e656b1973ba134de64f3aeac66a0405/LICENSE.md
6+
*
7+
* Rewritten and repurposed by Michał Miszczyszyn 2021
8+
*/
9+
import * as EncryptCookies from '../src/utils/encryptCookies';
10+
11+
describe('encrypt', () => {
12+
const secret = 'some_not_random_password_that_is';
13+
const value = 'test data';
14+
15+
it('turns object into a sealed then parses the sealed successfully', async () => {
16+
const sealed = await EncryptCookies.seal({ value, secret });
17+
const unsealed = await EncryptCookies.unseal({ sealed, secret });
18+
expect(unsealed).toEqual(value);
19+
});
20+
21+
it('unseal and sealed object with expiration', async () => {
22+
const sealed = await EncryptCookies.seal({ value, secret, ttl: 200 });
23+
const unsealed = await EncryptCookies.unseal({ sealed, secret });
24+
expect(unsealed).toEqual(value);
25+
});
26+
27+
it('fails for too short secret', async () => {
28+
await expect(EncryptCookies.seal({ value, secret: 'too short' })).rejects.toThrowErrorMatchingInlineSnapshot(
29+
`"Secret must be exactly 32 characters long!"`,
30+
);
31+
});
32+
33+
it('unseals a sealed', async () => {
34+
const sealed =
35+
'Fe26.2**SqhOkY8av81FPay7I60ktrpeOq7SgRNCcNN0rHWAMSg*3xsUfKKg2KiUWhsOmm1Nnw*_MeWO7OhJooR1Jc0cXQ5pp-wrtooQBeZsvNCSF9Yl5mm5xpCr8_SwxPJJkzwxN43**r3lxz-MMOws6YE-lDcXy6rmZc0mHHMVbXsndXmePgnA*JRDpLG7MxvgdoJqTeaTnUEQ-c0E6eyA66hVSr3f4BLmdfzZYU7fWIYGImEpEZgwzp_0jlF44R0Vr8BDQBlJiNw';
36+
const unsealed = await EncryptCookies.unseal({ sealed, secret });
37+
expect(JSON.parse(unsealed)).toEqual({
38+
a: 1,
39+
array: [5, 6, {}],
40+
nested: {
41+
k: true,
42+
},
43+
});
44+
});
45+
46+
it('returns an error when number of sealed components is wrong', async () => {
47+
const sealed =
48+
'x*Fe26.2**SqhOkY8av81FPay7I60ktrpeOq7SgRNCcNN0rHWAMSg*3xsUfKKg2KiUWhsOmm1Nnw*_MeWO7OhJooR1Jc0cXQ5pp-wrtooQBeZsvNCSF9Yl5mm5xpCr8_SwxPJJkzwxN43**r3lxz-MMOws6YE-lDcXy6rmZc0mHHMVbXsndXmePgnA*JRDpLG7MxvgdoJqTeaTnUEQ-c0E6eyA66hVSr3f4BLmdfzZYU7fWIYGImEpEZgwzp_0jlF44R0Vr8BDQBlJiNw';
49+
await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot(
50+
`"Cannot unseal: Incorrect data format."`,
51+
);
52+
});
53+
54+
it('returns an error when mac prefix is wrong', async () => {
55+
const sealed =
56+
'Fe27.2**SqhOkY8av81FPay7I60ktrpeOq7SgRNCcNN0rHWAMSg*3xsUfKKg2KiUWhsOmm1Nnw*_MeWO7OhJooR1Jc0cXQ5pp-wrtooQBeZsvNCSF9Yl5mm5xpCr8_SwxPJJkzwxN43**r3lxz-MMOws6YE-lDcXy6rmZc0mHHMVbXsndXmePgnA*JRDpLG7MxvgdoJqTeaTnUEQ-c0E6eyA66hVSr3f4BLmdfzZYU7fWIYGImEpEZgwzp_0jlF44R0Vr8BDQBlJiNw';
57+
await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot(
58+
`"Cannot unseal: Unsupported version."`,
59+
);
60+
});
61+
62+
it('returns an error when integrity check fails', async () => {
63+
const sealed =
64+
'Fe26.2**SqhOkY8av81FPay7I60ktrpeOq7SgRNCcNN0rHWAMSg*3xsUfKKg2KiUWhsOmm1Nnw*_MeWO7OhJooR1Jc0cXQ5pp-wrtooQBeZsvNCSF9Yl5mm5xpCr8_SwxPJJkzwxN43**r3lxz-MMOws6YE-lDcXy6rmZc0mHHMVbXsndXmePgnA*JRDpLG7MxvgdoJqTeaTnUEQ-c0E6eyA66hVSr3f4BLmdfzZYU7fWIYGImEpEZgwzp_0jlF44R0Vr8BDQBlJiNwLOL';
65+
await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot(
66+
`"Cannot unseal: Incorrect hmac seal value"`,
67+
);
68+
});
69+
70+
it('returns an error when iv base64 decoding fails', async () => {
71+
const sealed =
72+
'Fe26.2**0a27f421711152214f2cdd7fd8c515738204828f2d5c1ac50685231d38614de1*hUkUfX6sYUoKXh1QNx8oywLOL*AxjnFXiFUlQqdpNYK9lzAJzfm0S07vKo599fOi1Og7vuPaiQ6z8o487hDrs7xDu0**4eb9bef394dbaffa866f1e4246cf9d8c72a19d403da89760a3fc65c95d82301a*l65Cto8YluxfUbex2aD27hrA9Hccvhcryac0pkHfPvs';
73+
await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot(
74+
`"Cannot unseal: Incorrect hmac seal value"`,
75+
);
76+
});
77+
78+
it('returns an error when expired', async () => {
79+
const sealed =
80+
'Fe26.2**552bc79cfa73de9855b539a624c6b404496995f443baf057b95c097f5503f330*sk9We2FqPEyHc5bSzfA1yA*tlyeEmz0jWnaRd4CDmrqeQ*1623946580929*807a2f0ac5aebd5e413e06c52ffbf52158566e73a551d805d3b68164c7869ed8*Y5XBmJC-4QZ4Q1iRUiN2f8SStLL23-57wXNayX-tiF0';
81+
await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot(
82+
`"Cannot unseal: Expired seal"`,
83+
);
84+
});
85+
86+
it('returns an error when expiration NaN', async () => {
87+
const sealed =
88+
'Fe26.2**71ccf7404636c565d498200c002837f55ff5a0bf5e9ddecbd93953336709e9a4*JnIlC3F0_AhVSJQ2ALF3ow*A3s_DWrqGwWRjgC6mD5-SQ*1623946786465dupa*0e8513880d1c8410fb0e8a8e0c7ad43285ee67568b80ab2e76721e7381e14a14*iEz4o4dDQirX6Y1x2Om6Lpglg3XtDVjzkZvq3iRtFuM';
89+
await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot(
90+
`"Cannot unseal: Invalid expiration"`,
91+
);
92+
});
93+
});

__tests__/router.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import Crypto from 'crypto';
2+
13
import { createApp } from '../src';
4+
import { unseal } from '../src/utils/encryptCookies';
25

36
declare module '../src' {
47
interface TypeOfWebEvents {
@@ -7,6 +10,72 @@ declare module '../src' {
710
}
811

912
describe('router', () => {
13+
it('should set cookies', async () => {
14+
const app = createApp({ cookies: { secret: 'a'.repeat(32) } }).route({
15+
path: '/users',
16+
method: 'get',
17+
validation: {},
18+
handler: async (_request, t) => {
19+
await t.setCookie('test', 'testowa', { encrypted: false });
20+
return null;
21+
},
22+
});
23+
24+
const result = await app.inject({
25+
method: 'get',
26+
path: '/users',
27+
});
28+
29+
expect(result.headers['set-cookie']).toEqual([`test=testowa; Path=/; HttpOnly; Secure; SameSite=Lax`]);
30+
});
31+
32+
it('should set encrypted cookies', async () => {
33+
const secret = Crypto.randomBytes(22).toString('base64');
34+
const app = createApp({ cookies: { secret } }).route({
35+
path: '/users',
36+
method: 'get',
37+
validation: {},
38+
handler: async (_request, t) => {
39+
await t.setCookie('test', 'testowa', { encrypted: true });
40+
return null;
41+
},
42+
});
43+
44+
const result = await app.inject({
45+
method: 'get',
46+
path: '/users',
47+
});
48+
49+
expect(result.headers['set-cookie']).toHaveLength(1);
50+
const [key, val] = (result.headers['set-cookie'] as readonly [string])[0].split(';')[0]!.split('=');
51+
expect(key).toEqual('test');
52+
expect(await unseal({ sealed: val!, secret })).toEqual('testowa');
53+
});
54+
55+
it('should read all cookies', async () => {
56+
const app = createApp({ cookies: { secret: 'a'.repeat(32) } }).route({
57+
path: '/users',
58+
method: 'get',
59+
validation: {},
60+
handler: (request) => {
61+
expect(request.cookies['test']).toEqual('testowa');
62+
expect(request.cookies['secret']).toEqual('testowa');
63+
return null;
64+
},
65+
});
66+
67+
await app.inject({
68+
method: 'get',
69+
path: '/users',
70+
cookies: [
71+
'test=testowa',
72+
'secret=Fe26.2**n0Jf_bWyQEP8IGbXE2USt82PE9W_dGIvOSlLTeKvwnA*a_fJJWNvbhjvvy0yBg2APw*PzP5xlZ63c6EImBsgDaZ7A**LF1tHWLrtR1pckVC9moT-V2b8LVmE_NYWfsgYqYhZwk*i40hiIKMSNNpwQUmd2HalR9KEvODTfDRPLah2H_q2JqdPg3ecHW6PvnJTC2YAHGUiwvIERWqE5LvaSjCyULvbQ',
73+
],
74+
});
75+
76+
expect.assertions(2);
77+
});
78+
1079
it('should not accept multiple params in path segment', () => {
1180
const app = createApp({});
1281

__tests__/types.spec.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,15 @@ const assertTsd = (diagnostics: readonly Diagnostic[]) => {
2323

2424
describe('@typeofweb/schema', () => {
2525
const typesTests = Globby.sync(['./__tests__/*.test-d.ts']);
26-
it.each(typesTests.map((path) => ({ path, name: path.replace('./__tests__/', '').replace('.test-d.ts', '') })))(
27-
'tsd $name',
28-
async (dir) => {
29-
assertTsd(
30-
await tsd({
31-
cwd: join(Path.dirname(Url.fileURLToPath(import.meta.url)), '..'),
32-
typingsFile: './dist/index.d.ts',
33-
testFiles: [dir.path],
34-
}),
35-
);
36-
},
37-
);
26+
it.concurrent.each(
27+
typesTests.map((path) => ({ path, name: path.replace('./__tests__/', '').replace('.test-d.ts', '') })),
28+
)('tsd $name', async (dir) => {
29+
assertTsd(
30+
await tsd({
31+
cwd: join(Path.dirname(Url.fileURLToPath(import.meta.url)), '..'),
32+
typingsFile: './dist/index.d.ts',
33+
testFiles: [dir.path],
34+
}),
35+
);
36+
});
3837
});

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,17 @@
3535
],
3636
"dependencies": {
3737
"@typeofweb/schema": "0.8.0-5",
38+
"@types/cookie-parser": "1.4.2",
3839
"@types/cors": "2.8.10",
3940
"@types/express": "4.17.12",
4041
"@types/supertest": "2.0.11",
42+
"cookie-parser": "1.4.5",
4143
"cors": "2.8.5",
4244
"express": "4.17.1",
4345
"stoppable": "1.1.0",
4446
"supertest": "6.1.3"
4547
},
48+
"peerDependencies": {},
4649
"devDependencies": {
4750
"@rollup/plugin-commonjs": "19.0.0",
4851
"@rollup/plugin-json": "4.1.0",
@@ -54,6 +57,7 @@
5457
"@types/node": "12",
5558
"@types/stoppable": "1.1.1",
5659
"all-contributors-cli": "6.20.0",
60+
"builtin-modules": "3.2.0",
5761
"eslint": "7.28.0",
5862
"fast-check": "2.16.0",
5963
"globby": "11.0.3",

rollup.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// @ts-check
2+
import BuiltinModules from 'builtin-modules';
23
import commonjs from '@rollup/plugin-commonjs';
34
import typescript from '@rollup/plugin-typescript';
45
import prettier from 'rollup-plugin-prettier';
@@ -12,6 +13,8 @@ import pkg from './package.json';
1213
const shouldCompress = process.env.COMPRESS_BUNDLES ? true : false;
1314
const shouldPrettify = !shouldCompress && (process.env.PRETTIFY ? true : false);
1415

16+
const dependencies = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})];
17+
1518
const rollupConfig = [
1619
{
1720
input: 'src/index.ts',
@@ -90,7 +93,7 @@ LICENSE file in the root directory of this source tree.
9093
`.trim(),
9194
}),
9295
],
93-
external: ['url', 'body-parser', 'express', 'stoppable', '@typeofweb/schema', 'events', 'supertest', 'cors', 'os'],
96+
external: [...dependencies, ...BuiltinModules],
9497
},
9598
];
9699
// eslint-disable-next-line import/no-default-export

src/modules/app.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { URL } from 'url';
22

3+
import CookieParser from 'cookie-parser';
34
import Cors from 'cors';
45
import Supertest from 'supertest';
56

67
import { deepMerge } from '../utils/merge';
8+
import { promiseOriginFnToNodeCallback } from '../utils/node';
79
import { generateServerId } from '../utils/uniqueId';
810

911
import { createEventBus } from './events';
@@ -22,6 +24,13 @@ const defaultAppOptions: AppOptions = {
2224
origin: true,
2325
credentials: true,
2426
},
27+
cookies: {
28+
encrypted: true,
29+
secure: true,
30+
httpOnly: true,
31+
sameSite: 'lax',
32+
secret: '',
33+
},
2534
};
2635

2736
export function createApp(opts: DeepPartial<AppOptions>): TypeOfWebApp {
@@ -73,17 +82,23 @@ export function createApp(opts: DeepPartial<AppOptions>): TypeOfWebApp {
7382
}
7483

7584
if (options.cors) {
85+
const origin =
86+
typeof options.cors.origin === 'function'
87+
? promiseOriginFnToNodeCallback(options.cors.origin)
88+
: options.cors.origin;
7689
app._rawExpressApp.use(
7790
Cors({
78-
origin: options.cors.origin,
91+
origin,
7992
credentials: options.cors.credentials,
8093
}),
8194
);
8295
}
8396

97+
app._rawExpressApp.use(CookieParser(''));
98+
8499
await initServerPlugins();
85100

86-
app._rawExpressRouter = initRouter({ server, routes, plugins });
101+
app._rawExpressRouter = initRouter({ server, appOptions: options, routes, plugins });
87102
app._rawExpressApp.use(app._rawExpressRouter);
88103

89104
mutableIsInitialized = true;
@@ -125,6 +140,11 @@ export function createApp(opts: DeepPartial<AppOptions>): TypeOfWebApp {
125140
);
126141
}
127142

143+
if (injection.cookies) {
144+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- string[]
145+
mutableTest = mutableTest.set('Cookie', injection.cookies as string[]);
146+
}
147+
128148
const result = await mutableTest;
129149
return result;
130150
},

0 commit comments

Comments
 (0)