UNPKG

16 kBJavaScriptView Raw
1'use strict';
2
3const cookie = require('set-cookie-parser');
4const lolex = require('lolex');
5const nock = require('nock');
6const s3oMiddlewareUtils = require('@financial-times/s3o-middleware-utils');
7
8const { USERNAME: S3O_USERNAME, TOKEN: S3O_TOKEN } = s3oMiddlewareUtils.cookies;
9
10jest.mock('@financial-times/s3o-middleware-utils', () => ({
11 ...jest.requireActual('@financial-times/s3o-middleware-utils'),
12 authenticate: jest.fn(),
13}));
14
15describe('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 // reset s3oLambda before each test, as it relies on global state
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});