1 | 'use strict';
|
2 |
|
3 | const cookie = require('set-cookie-parser');
|
4 | const lolex = require('lolex');
|
5 | const nock = require('nock');
|
6 | const s3oMiddlewareUtils = require('@financial-times/s3o-middleware-utils');
|
7 |
|
8 | const { USERNAME: S3O_USERNAME, TOKEN: S3O_TOKEN } = s3oMiddlewareUtils.cookies;
|
9 |
|
10 | jest.mock('@financial-times/s3o-middleware-utils', () => ({
|
11 | ...jest.requireActual('@financial-times/s3o-middleware-utils'),
|
12 | authenticate: jest.fn(),
|
13 | }));
|
14 |
|
15 | describe('s3o-lambda', () => {
|
16 | const publicKey =
|
17 | 'MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuh0hlN3WBqVzC2lurGxLfFlUYQCgR2UpV0k2i5czy9XUW0lhzT5e9/FOp6wPIOMoJpWHP8KwY/P5U0Z2qZUCKmVhf2M61mvfvnUFEEqNAc35wNH15GZmjGnf1n6yTNPrM2cdCUeRzk49h2Ej4LBamA6GWZdl+ReYzOVqWiTQWHjjHy+Q/UrLk77Ra/drC+jj3rDkJz4qNXs9MldjYgudPt15pbLvqRk8VSXY36TinfJeySQQQAgNwvq49/qD145I+3DYrzCrDMIs8bHKy7IGIa1XW2YiSxn/9SwnwYt2PjhI3TuID7AyBt633Tsl3hfli/goBA5z0tBpkUB9uxLiFgPdgNEUzxHCBPHD+C8pXi8XRQrn1uwpusrbjgOUZkRNhguVnyinTQPhZG0LbzaXDbjSDIwwIjSVWkBhgT6LDbHvIlu0U6czVyA1OahqHLcwvA70wR2vXmwlbVKIcvGj5wvk8v1BNxtv1MbiWHf0s6mJysd9Sy2b9gb5gpBjvlfUyw6BsIlDf9ysYXITiD4JaXJGdlmupQMxdA0pGp4C6ROmupgzEgF+H/ycyBWtIUsl4L/Ceq4Sj0XBZ/QqumW176VUQTL5fKklfKw2fv4n1JrUssOz/xmcsRA/7BGiIsiSv/l/Mwt5qE8e+1u0jd8uJKcKhmqPfXZTkK+jJR+d4fsCAwEAAQ==';
|
18 | const publicKeyTTLSeconds = 60;
|
19 | const mockCallback = jest.fn();
|
20 | const mockAuthenticate = {
|
21 | authenticateToken: jest.fn(),
|
22 | };
|
23 | const mockPublicKeyConsumer = jest.fn();
|
24 | let publicKeyInterceptor;
|
25 | let publicKeyScope;
|
26 | let s3oLambda;
|
27 |
|
28 | const resetPublicKeyStub = (
|
29 | responseStatusCode = 200,
|
30 | responseBody = publicKey,
|
31 | responseHeaders = {
|
32 | 'Cache-control': `public,max-age=${publicKeyTTLSeconds},s-maxage=${publicKeyTTLSeconds}`,
|
33 | }
|
34 | ) => {
|
35 | if (publicKeyInterceptor) {
|
36 | nock.removeInterceptor(publicKeyInterceptor);
|
37 | }
|
38 | publicKeyInterceptor = nock('https://s3o.ft.com')
|
39 | .persist()
|
40 | .get('/publickey');
|
41 | publicKeyScope = publicKeyInterceptor.reply(
|
42 | responseStatusCode,
|
43 | responseBody,
|
44 | responseHeaders
|
45 | );
|
46 | };
|
47 |
|
48 | const getEventStub = ({
|
49 | queryStringParameters = {},
|
50 | path = '/',
|
51 | cookie = `${S3O_USERNAME}=patrick.stewart;${S3O_TOKEN}=some-token`,
|
52 | headers = {
|
53 | Host: 'lantern.ft.com',
|
54 | },
|
55 | stage = 'production',
|
56 | } = {}) => ({
|
57 | queryStringParameters,
|
58 | headers: Object.assign(
|
59 | {
|
60 | Cookie: cookie,
|
61 | },
|
62 | headers
|
63 | ),
|
64 | requestContext: {
|
65 | stage,
|
66 | },
|
67 | path,
|
68 | });
|
69 |
|
70 | beforeEach(() => {
|
71 |
|
72 | jest.isolateModules(() => {
|
73 | s3oLambda = require('../index');
|
74 | });
|
75 | mockAuthenticate.authenticateToken.mockImplementation(() => true);
|
76 | s3oMiddlewareUtils.authenticate.mockImplementation(publicKeyGetter => {
|
77 | mockPublicKeyConsumer(publicKeyGetter());
|
78 | return mockAuthenticate;
|
79 | });
|
80 |
|
81 | nock.disableNetConnect();
|
82 | nock.enableNetConnect('127.0.0.1');
|
83 | resetPublicKeyStub();
|
84 | });
|
85 |
|
86 | afterEach(() => {
|
87 | jest.resetAllMocks();
|
88 | nock.enableNetConnect();
|
89 | nock.cleanAll();
|
90 | });
|
91 |
|
92 | const makeLambdaRequest = (
|
93 | event = getEventStub(),
|
94 | callback = mockCallback,
|
95 | options
|
96 | ) => s3oLambda(event, callback, options);
|
97 |
|
98 | describe('options', () => {
|
99 | [
|
100 | {
|
101 | functionName: 'getS3oClientName',
|
102 | expectedError:
|
103 | "The option 'getS3oClientName' must be a function which returns an ID or a constant string ID. Only requests with a matching ID are authorised by the returned s3o_token from S3O",
|
104 | },
|
105 | {
|
106 | functionName: 'getHost',
|
107 | expectedError:
|
108 | "The option 'getHost' must be a function which returns a host string or a constant string. This is used in a redirectURL",
|
109 | },
|
110 | {
|
111 | functionName: 'getPath',
|
112 | expectedError:
|
113 | "The option 'getPath' must be a function which returns a path segment or a constant string, to be used in a redirectURL",
|
114 | },
|
115 | ].forEach(({ functionName, expectedError }) => {
|
116 | it(`should reject if the '${functionName}' option is not a function or string`, () => {
|
117 | const givenFunction = { notAFunction: 23 };
|
118 | const doRequest = () =>
|
119 | makeLambdaRequest(getEventStub(), mockCallback, {
|
120 | [functionName]: givenFunction,
|
121 | });
|
122 |
|
123 | return expect(doRequest()).rejects.toEqual(
|
124 | new Error(expectedError)
|
125 | );
|
126 | });
|
127 | });
|
128 |
|
129 | [
|
130 | {
|
131 | header: 'X-FT-Forwarding-Host',
|
132 | headers: {
|
133 | 'X-FT-Forwarding-Host': 'biz-ops.ft.com',
|
134 | Host: 'aws-host-name.com',
|
135 | },
|
136 | expectedValue: 'biz-ops.ft.com',
|
137 | },
|
138 | {
|
139 | header: 'Fastly-Orig-Host',
|
140 | headers: {
|
141 | 'Fastly-Orig-Host': 'fastly-service.ft.com',
|
142 | Host: 'aws-host-name.com',
|
143 | },
|
144 | expectedValue: 'fastly-service.ft.com',
|
145 | },
|
146 | {
|
147 | header: 'Host',
|
148 | headers: {
|
149 | Host: 'runbooks.ft.com',
|
150 | },
|
151 | expectedValue: 'runbooks.ft.com',
|
152 | },
|
153 | {
|
154 | getHost(event) {
|
155 | return 'my-custom-host.com';
|
156 | },
|
157 | expectedValue: 'my-custom-host.com',
|
158 | },
|
159 | {
|
160 | getHost: 'my-static-host.com',
|
161 | expectedValue: 'my-static-host.com',
|
162 | },
|
163 | ].forEach(({ getHost, header, headers, expectedValue }) => {
|
164 | const func = getHost ? 'user provided' : 'default';
|
165 |
|
166 | it(`should use the ${func} 'getHost' function for 'getS3oClientName' which retrieves from the ${header} header`, async () => {
|
167 | const givenUsername = 'worf';
|
168 | await makeLambdaRequest(
|
169 | getEventStub({
|
170 | cookie: `${S3O_USERNAME}=${givenUsername};${S3O_TOKEN}=some-token`,
|
171 | headers,
|
172 | }),
|
173 | mockCallback,
|
174 | {
|
175 | getHost,
|
176 | }
|
177 | );
|
178 |
|
179 | expect(
|
180 | mockAuthenticate.authenticateToken
|
181 | ).toHaveBeenCalledTimes(1);
|
182 | expect(
|
183 | mockAuthenticate.authenticateToken.mock.calls[0]
|
184 | ).toEqual([givenUsername, expectedValue, 'some-token']);
|
185 | });
|
186 |
|
187 | it(`should use the ${func} 'getHost' function which retrieves from the ${header} header to get the S3O redirect parameter`, async () => {
|
188 | await makeLambdaRequest(
|
189 | getEventStub({
|
190 | cookie: '',
|
191 | headers,
|
192 | }),
|
193 | mockCallback,
|
194 | {
|
195 | getHost,
|
196 | }
|
197 | );
|
198 |
|
199 | expect(mockCallback).toHaveBeenCalledTimes(1);
|
200 | const redirectHost = new URL(
|
201 | decodeURIComponent(
|
202 | new URL(
|
203 | mockCallback.mock.calls[0][1].headers.location
|
204 | ).searchParams.get('redirect')
|
205 | )
|
206 | ).host;
|
207 |
|
208 | expect(redirectHost).toEqual(expectedValue);
|
209 | });
|
210 | });
|
211 |
|
212 | it('should use accept a string as getS3oClientName to be used as the S3O redirect parameter', async () => {
|
213 | const givenS3oClientName = 'some-identifier';
|
214 | const givenUsername = 'data';
|
215 | await makeLambdaRequest(
|
216 | getEventStub({
|
217 | cookie: `${S3O_USERNAME}=${givenUsername};${S3O_TOKEN}=some-token`,
|
218 | }),
|
219 | mockCallback,
|
220 | {
|
221 | getS3oClientName: givenS3oClientName,
|
222 | }
|
223 | );
|
224 |
|
225 | expect(mockAuthenticate.authenticateToken).toHaveBeenCalledTimes(1);
|
226 | expect(mockAuthenticate.authenticateToken.mock.calls[0]).toEqual([
|
227 | givenUsername,
|
228 | givenS3oClientName,
|
229 | 'some-token',
|
230 | ]);
|
231 | });
|
232 |
|
233 | [
|
234 | {
|
235 | name: 'X-FT-Forwarding-Path header',
|
236 | headers: {
|
237 | 'X-FT-Forwarding-Path': '/v1/my-forwarded-path',
|
238 | Host: 'some-host.com',
|
239 | },
|
240 | path: '/some-path',
|
241 | expectedValue: '/v1/my-forwarded-path',
|
242 | },
|
243 | {
|
244 | name: 'path',
|
245 | headers: {
|
246 | Host: 'some-host.com',
|
247 | },
|
248 | path: '/some-path',
|
249 | expectedValue: '/some-path',
|
250 | },
|
251 | {
|
252 | name: 'stage and path when host is Amazon API Gateway',
|
253 | headers: {
|
254 | Host: 'amazonaws.com',
|
255 | },
|
256 | stage: 'test-stage',
|
257 | path: '/some-path',
|
258 | expectedValue: '/test-stage/some-path',
|
259 | },
|
260 | {
|
261 | name: 'user provided getPath',
|
262 | getPath() {
|
263 | return '/my-custom-path';
|
264 | },
|
265 | expectedValue: '/my-custom-path',
|
266 | },
|
267 | {
|
268 | name: 'user provided getPath',
|
269 | getPath: '/my-static-path',
|
270 | expectedValue: '/my-static-path',
|
271 | },
|
272 | ].forEach(({ name, getPath, headers, path, stage, expectedValue }) => {
|
273 | const func = getPath ? 'user provided' : 'default';
|
274 |
|
275 | it(`should use the ${func} 'getPath' function to get the path from ${name}`, async () => {
|
276 | await makeLambdaRequest(
|
277 | getEventStub({
|
278 | cookie: '',
|
279 | headers,
|
280 | path,
|
281 | stage,
|
282 | }),
|
283 | mockCallback,
|
284 | {
|
285 | getPath,
|
286 | }
|
287 | );
|
288 |
|
289 | expect(mockCallback).toHaveBeenCalledTimes(1);
|
290 | const redirectUrl = new URL(
|
291 | decodeURIComponent(
|
292 | new URL(
|
293 | mockCallback.mock.calls[0][1].headers.location
|
294 | ).searchParams.get('redirect')
|
295 | )
|
296 | );
|
297 | const redirectPath = redirectUrl.pathname.replace(
|
298 | redirectUrl.host,
|
299 | ''
|
300 | );
|
301 |
|
302 | expect(redirectPath).toEqual(expectedValue);
|
303 | });
|
304 | });
|
305 | });
|
306 |
|
307 | describe('set cookies', () => {
|
308 | const getCookies = () => {
|
309 | return cookie.parse(
|
310 | ['Set-Cookie', 'set-cookie'].map(
|
311 | header => mockCallback.mock.calls[0][1].headers[header]
|
312 | )
|
313 | );
|
314 | };
|
315 |
|
316 | it('should set cookies when username and token are in query parameters', async () => {
|
317 | const givenUsername = 'bashir';
|
318 | const givenToken = 'secret';
|
319 | await makeLambdaRequest(
|
320 | getEventStub({
|
321 | queryStringParameters: {
|
322 | username: givenUsername,
|
323 | token: givenToken,
|
324 | },
|
325 | }),
|
326 | mockCallback
|
327 | );
|
328 |
|
329 | expect(getCookies()).toEqual([
|
330 | {
|
331 | httpOnly: true,
|
332 | maxAge: 900000,
|
333 | name: S3O_USERNAME,
|
334 | path: '/',
|
335 | secure: true,
|
336 | value: 'bashir',
|
337 | },
|
338 | {
|
339 | httpOnly: true,
|
340 | maxAge: 900000,
|
341 | name: S3O_TOKEN,
|
342 | path: '/',
|
343 | secure: true,
|
344 | value: 'secret',
|
345 | },
|
346 | ]);
|
347 | });
|
348 |
|
349 | it('should vary maxAge based on options.maxAge', async () => {
|
350 | const givenMaxAge = 900;
|
351 | await makeLambdaRequest(
|
352 | getEventStub({
|
353 | queryStringParameters: {
|
354 | username: 'bashir',
|
355 | token: 'secret',
|
356 | },
|
357 | }),
|
358 | mockCallback,
|
359 | {
|
360 | cookies: {
|
361 | maxAge: givenMaxAge,
|
362 | },
|
363 | }
|
364 | );
|
365 |
|
366 | expect(getCookies()[0].maxAge).toEqual(givenMaxAge);
|
367 | expect(getCookies()[1].maxAge).toEqual(givenMaxAge);
|
368 | });
|
369 |
|
370 | it('should vary secure based on options.secure', async () => {
|
371 | const givenSecure = true;
|
372 | await makeLambdaRequest(
|
373 | getEventStub({
|
374 | queryStringParameters: {
|
375 | username: 'bashir',
|
376 | token: 'secret',
|
377 | },
|
378 | }),
|
379 | mockCallback,
|
380 | {
|
381 | protocol: 'http',
|
382 | cookies: {
|
383 | secure: givenSecure,
|
384 | },
|
385 | }
|
386 | );
|
387 |
|
388 | expect(getCookies()[0].secure).toEqual(true);
|
389 | expect(getCookies()[1].secure).toEqual(true);
|
390 | });
|
391 |
|
392 | [
|
393 | { protocol: 'https', expectedValue: true },
|
394 | { protocol: 'http', expectedValue: undefined },
|
395 | ].forEach(({ protocol, expectedValue }) => {
|
396 | it(`should vary secure based on protocol by default. If protocol is '${protocol}' httpOnly should be ${expectedValue}`, async () => {
|
397 | await makeLambdaRequest(
|
398 | getEventStub({
|
399 | queryStringParameters: {
|
400 | username: 'bashir',
|
401 | token: 'secret',
|
402 | },
|
403 | }),
|
404 | mockCallback,
|
405 | {
|
406 | protocol,
|
407 | }
|
408 | );
|
409 |
|
410 | expect(getCookies()[0].secure).toEqual(expectedValue);
|
411 | expect(getCookies()[1].secure).toEqual(expectedValue);
|
412 | });
|
413 | });
|
414 |
|
415 | it('should vary httpOnly based on options.httpOnly', async () => {
|
416 | const givenHttpOnly = false;
|
417 | await makeLambdaRequest(
|
418 | getEventStub({
|
419 | queryStringParameters: {
|
420 | username: 'bashir',
|
421 | token: 'secret',
|
422 | },
|
423 | }),
|
424 | mockCallback,
|
425 | {
|
426 | cookies: {
|
427 | httpOnly: givenHttpOnly,
|
428 | },
|
429 | }
|
430 | );
|
431 |
|
432 | expect(getCookies()[0].httpOnly).toEqual(undefined);
|
433 | expect(getCookies()[1].httpOnly).toEqual(undefined);
|
434 | });
|
435 | });
|
436 |
|
437 | describe('authenticate', () => {
|
438 | [
|
439 | {
|
440 | name: `only an ${S3O_USERNAME} cookie`,
|
441 | value: `${S3O_USERNAME}=patrick.stewart`,
|
442 | expectedUsername: 'patrick.stewart',
|
443 | },
|
444 | {
|
445 | name: `only an ${S3O_TOKEN} cookie`,
|
446 | value: `${S3O_TOKEN}=some-token`,
|
447 | },
|
448 | {
|
449 | name: 'no cookies',
|
450 | value: null,
|
451 | },
|
452 | {
|
453 | name: `empty ${S3O_USERNAME} and ${S3O_TOKEN} cookies`,
|
454 | value: 'other-cookie=',
|
455 | },
|
456 | ].forEach(({ name, value, expectedUsername }) => {
|
457 | it(`should redirect the user to authenticate with s3o in the case: ${name}`, async () => {
|
458 | const result = await makeLambdaRequest(
|
459 | getEventStub({
|
460 | cookie: value,
|
461 | })
|
462 | );
|
463 |
|
464 | expect(mockCallback).toHaveBeenCalledTimes(1);
|
465 | expect(mockCallback).toHaveBeenCalledWith(null, {
|
466 | headers: {
|
467 | 'Cache-Control': 'no-cache, private, max-age=0',
|
468 | location:
|
469 | 'https://s3o.ft.com/v3/authenticate?host=lantern.ft.com&redirect=https://lantern.ft.com/',
|
470 | },
|
471 | statusCode: 302,
|
472 | });
|
473 | expect(result).toEqual({
|
474 | isSignedIn: false,
|
475 | username: expectedUsername,
|
476 | });
|
477 | });
|
478 | });
|
479 |
|
480 | [
|
481 | {
|
482 | name: 'an empty string',
|
483 | value: '',
|
484 | },
|
485 | {
|
486 | name: 'undefined',
|
487 | value: undefined,
|
488 | },
|
489 | ].forEach(({ name, value }) => {
|
490 | it(`should throw an error if the \'getS3oClientName\' option returns ${name}`, () => {
|
491 | const doRequest = () =>
|
492 | makeLambdaRequest(
|
493 | getEventStub({
|
494 | cookie: '',
|
495 | }),
|
496 | mockCallback,
|
497 | {
|
498 | getS3oClientName() {
|
499 | return value;
|
500 | },
|
501 | }
|
502 | );
|
503 |
|
504 | return expect(doRequest()).rejects.toEqual(
|
505 | new Error(
|
506 | `The option 'getS3oClientName' returned '${value}'. This function must return a non-empty value`
|
507 | )
|
508 | );
|
509 | });
|
510 | });
|
511 |
|
512 | it('should return a 401 response and clear cookies when cookie validation fails', async () => {
|
513 | mockAuthenticate.authenticateToken.mockImplementation(() => false);
|
514 |
|
515 | const result = await makeLambdaRequest();
|
516 |
|
517 | expect(mockCallback).toHaveBeenCalledTimes(1);
|
518 | expect(mockCallback).toHaveBeenCalledWith(null, {
|
519 | statusCode: 401,
|
520 | body: '{"error":"Not authenticated"}',
|
521 | headers: {
|
522 | 'Content-Type': 'application/json',
|
523 | 'Set-Cookie': `${S3O_USERNAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT`,
|
524 | 'set-cookie': `${S3O_TOKEN}=; expires=Thu, 01 Jan 1970 00:00:00 GMT`,
|
525 | },
|
526 | });
|
527 | expect(result.isSignedIn).toEqual(false);
|
528 | });
|
529 | });
|
530 |
|
531 | describe('credentials validation', () => {
|
532 | it('should validate using the public key, s3o username and token from cookies, and s3o client name', async () => {
|
533 | const givenUsername = 'captain';
|
534 | const givenToken = 'jeann-luc-picard';
|
535 | const givenS3oClientName = 'uss-enterprise';
|
536 |
|
537 | await makeLambdaRequest(
|
538 | getEventStub({
|
539 | cookie: `${S3O_USERNAME}=${givenUsername};${S3O_TOKEN}=${givenToken}`,
|
540 | }),
|
541 | mockCallback,
|
542 | {
|
543 | getS3oClientName() {
|
544 | return givenS3oClientName;
|
545 | },
|
546 | }
|
547 | );
|
548 |
|
549 | expect(s3oMiddlewareUtils.authenticate).toHaveBeenCalledTimes(1);
|
550 | expect(mockPublicKeyConsumer).toHaveBeenCalledTimes(1);
|
551 | expect(mockPublicKeyConsumer).toHaveBeenCalledWith(publicKey);
|
552 | expect(mockAuthenticate.authenticateToken).toHaveBeenCalledTimes(1);
|
553 | expect(mockAuthenticate.authenticateToken).toHaveBeenCalledWith(
|
554 | givenUsername,
|
555 | givenS3oClientName,
|
556 | givenToken
|
557 | );
|
558 | });
|
559 |
|
560 | it('should return that the user is signed in if validation succeeds', async () => {
|
561 | const givenUsername = 'captain';
|
562 | const givenToken = 'jeann-luc-picard';
|
563 |
|
564 | const result = await makeLambdaRequest(
|
565 | getEventStub({
|
566 | cookie: `${S3O_USERNAME}=${givenUsername};${S3O_TOKEN}=${givenToken}`,
|
567 | }),
|
568 | );
|
569 |
|
570 | expect(result).toEqual({
|
571 | isSignedIn: true,
|
572 | username: givenUsername,
|
573 | })
|
574 | });
|
575 | });
|
576 |
|
577 | describe('s3o public key', () => {
|
578 | let clock;
|
579 |
|
580 | beforeEach(() => {
|
581 | clock = lolex.install({
|
582 | toFake: ['Date'],
|
583 | });
|
584 | });
|
585 |
|
586 | afterEach(() => {
|
587 | clock.uninstall();
|
588 | });
|
589 |
|
590 | it('fetches the s3o public key when making an authorisation request', async () => {
|
591 | await makeLambdaRequest(getEventStub());
|
592 |
|
593 | expect(publicKeyScope.isDone()).toEqual(true);
|
594 | });
|
595 |
|
596 | it('uses the cached public key when the time between requests is under the public key Cache-Control ttl', async () => {
|
597 | await makeLambdaRequest();
|
598 |
|
599 | expect(publicKeyScope.isDone()).toEqual(true);
|
600 |
|
601 | clock.tick(1000 * publicKeyTTLSeconds - 1);
|
602 | resetPublicKeyStub();
|
603 | await makeLambdaRequest();
|
604 |
|
605 | expect(publicKeyScope.isDone()).toEqual(false);
|
606 | });
|
607 |
|
608 | it('refetches the s3o public key when the time between requests is over the public key Cache-Control ttl', async () => {
|
609 | await makeLambdaRequest();
|
610 |
|
611 | clock.tick(1000 * publicKeyTTLSeconds);
|
612 | resetPublicKeyStub();
|
613 | await makeLambdaRequest();
|
614 |
|
615 | expect(publicKeyScope.isDone()).toEqual(true);
|
616 | });
|
617 | });
|
618 | });
|