diff --git a/api-server/server/utils/getSetAccessToken.js b/api-server/server/utils/getSetAccessToken.js new file mode 100644 index 0000000000..b915ecb04e --- /dev/null +++ b/api-server/server/utils/getSetAccessToken.js @@ -0,0 +1,76 @@ +import jwt from 'jsonwebtoken'; +import { isBefore } from 'date-fns'; + +import { jwtSecret as _jwtSecret } from '../../../config/secrets'; + +export const authHeaderNS = 'X-fcc-access-token'; +export const jwtCookieNS = 'jwt_access_token'; + +export function createCookieConfig(req) { + return { + signed: !!req.signedCookies, + domain: process.env.COOKIE_DOMAIN || 'localhost' + }; +} + +export function setAccessTokenToResponse( + { accessToken }, + req, + res, + jwtSecret = _jwtSecret +) { + const cookieConfig = { + ...createCookieConfig(req), + maxAge: accessToken.ttl || 77760000000 + }; + const jwtAccess = jwt.sign({ accessToken }, jwtSecret); + res.cookie(jwtCookieNS, jwtAccess, cookieConfig); + res.cookie('access_token', accessToken.id, cookieConfig); + res.cookie('userId', accessToken.userId, cookieConfig); + return; +} + +export function getAccessTokenFromRequest(req, jwtSecret = _jwtSecret) { + const maybeToken = + (req.headers && req.headers[authHeaderNS]) || + (req.signedCookies && req.signedCookies[jwtCookieNS]) || + (req.cookie && req.cookie[jwtCookieNS]); + if (!maybeToken) { + return { + accessToken: null, + error: errorTypes.noTokenFound + }; + } + let token; + try { + token = jwt.verify(maybeToken, jwtSecret); + } catch (err) { + return { accessToken: null, error: errorTypes.invalidToken }; + } + + const { accessToken } = token; + const { created, ttl } = accessToken; + const valid = isBefore(Date.now(), Date.parse(created) + ttl); + if (!valid) { + return { + accessToken: null, + error: errorTypes.expiredToken + }; + } + return { accessToken, error: '', jwt: maybeToken }; +} + +export function removeCookies(req, res) { + const config = createCookieConfig(req); + res.clearCookie(jwtCookieNS, config); + res.clearCookie('access_token', config); + res.clearCookie('userId', config); + res.clearCookie('_csrf', config); + return; +} + +export const errorTypes = { + noTokenFound: 'No token found', + invalidToken: 'Invalid token', + expiredToken: 'Token timed out' +}; diff --git a/api-server/server/utils/getSetAccessToken.test.js b/api-server/server/utils/getSetAccessToken.test.js new file mode 100644 index 0000000000..cf57742b3b --- /dev/null +++ b/api-server/server/utils/getSetAccessToken.test.js @@ -0,0 +1,201 @@ +/* global describe it expect */ +import { + getAccessTokenFromRequest, + errorTypes, + setAccessTokenToResponse, + removeCookies +} from './getSetAccessToken'; +import { mockReq, mockRes } from 'sinon-express-mock'; +import jwt from 'jsonwebtoken'; + +describe('getSetAccessToken', () => { + const validJWTSecret = 'this is a super secret string'; + const invalidJWTSecret = 'This is not correct secret'; + const now = new Date(Date.now()); + const theBeginningOfTime = new Date(0); + const accessToken = { + id: '123abc', + userId: '456def', + ttl: 60000, + created: now + }; + + describe('getAccessTokenFromRequest', () => { + it('return `no token` error if no token is found', () => { + const req = mockReq({ headers: {}, cookie: {} }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + expect(result.error).toEqual(errorTypes.noTokenFound); + }); + + describe('cookies', () => { + it('returns `invalid token` error for malformed tokens', () => { + const invalidJWT = jwt.sign({ accessToken }, invalidJWTSecret); + // eslint-disable-next-line camelcase + const req = mockReq({ cookie: { jwt_access_token: invalidJWT } }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + + expect(result.error).toEqual(errorTypes.invalidToken); + }); + + it('returns `expired token` error for expired tokens', () => { + const invalidJWT = jwt.sign( + { accessToken: { ...accessToken, created: theBeginningOfTime } }, + validJWTSecret + ); + // eslint-disable-next-line camelcase + const req = mockReq({ cookie: { jwt_access_token: invalidJWT } }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + + expect(result.error).toEqual(errorTypes.expiredToken); + }); + + it('returns a valid access token with no errors ', () => { + expect.assertions(2); + const validJWT = jwt.sign({ accessToken }, validJWTSecret); + // eslint-disable-next-line camelcase + const req = mockReq({ cookie: { jwt_access_token: validJWT } }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + + expect(result.error).toBeFalsy(); + expect(result.accessToken).toEqual({ + ...accessToken, + created: accessToken.created.toISOString() + }); + }); + + it('returns the signed jwt if found', () => { + const validJWT = jwt.sign({ accessToken }, validJWTSecret); + // eslint-disable-next-line camelcase + const req = mockReq({ cookie: { jwt_access_token: validJWT } }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + + expect(result.jwt).toEqual(validJWT); + }); + }); + + describe('Auth headers', () => { + it('returns `invalid token` error for malformed tokens', () => { + const invalidJWT = jwt.sign({ accessToken }, invalidJWTSecret); + // eslint-disable-next-line camelcase + const req = mockReq({ headers: { 'X-fcc-access-token': invalidJWT } }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + + expect(result.error).toEqual(errorTypes.invalidToken); + }); + + it('returns `expired token` error for expired tokens', () => { + const invalidJWT = jwt.sign( + { accessToken: { ...accessToken, created: theBeginningOfTime } }, + validJWTSecret + ); + // eslint-disable-next-line camelcase + const req = mockReq({ headers: { 'X-fcc-access-token': invalidJWT } }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + + expect(result.error).toEqual(errorTypes.expiredToken); + }); + + it('returns a valid access token with no errors ', () => { + expect.assertions(2); + const validJWT = jwt.sign({ accessToken }, validJWTSecret); + // eslint-disable-next-line camelcase + const req = mockReq({ headers: { 'X-fcc-access-token': validJWT } }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + + expect(result.error).toBeFalsy(); + expect(result.accessToken).toEqual({ + ...accessToken, + created: accessToken.created.toISOString() + }); + }); + + it('returns the signed jwt if found', () => { + const validJWT = jwt.sign({ accessToken }, validJWTSecret); + // eslint-disable-next-line camelcase + const req = mockReq({ headers: { 'X-fcc-access-token': validJWT } }); + const result = getAccessTokenFromRequest(req, validJWTSecret); + + expect(result.jwt).toEqual(validJWT); + }); + }); + }); + + describe('setAccessTokenToResponse', () => { + it('sets three cookies in the response', () => { + expect.assertions(3); + const req = mockReq(); + const res = mockRes(); + + const expectedJWT = jwt.sign({ accessToken }, validJWTSecret); + + setAccessTokenToResponse({ accessToken }, req, res, validJWTSecret); + + expect(res.cookie.getCall(0).args).toEqual([ + 'jwt_access_token', + expectedJWT, + { + signed: false, + domain: 'localhost', + maxAge: accessToken.ttl + } + ]); + expect(res.cookie.getCall(1).args).toEqual([ + 'access_token', + accessToken.id, + { + signed: false, + domain: 'localhost', + maxAge: accessToken.ttl + } + ]); + expect(res.cookie.getCall(2).args).toEqual([ + 'userId', + accessToken.userId, + { + signed: false, + domain: 'localhost', + maxAge: accessToken.ttl + } + ]); + }); + }); + + describe('removeCookies', () => { + it('removes four cookies set in the lifetime of an authenticated session', () => { + // expect.assertions(4); + const req = mockReq(); + const res = mockRes(); + + removeCookies(req, res); + + expect(res.clearCookie.getCall(0).args).toEqual([ + 'jwt_access_token', + { + signed: false, + domain: 'localhost' + } + ]); + expect(res.clearCookie.getCall(1).args).toEqual([ + 'access_token', + { + signed: false, + domain: 'localhost' + } + ]); + expect(res.clearCookie.getCall(2).args).toEqual([ + 'userId', + { + signed: false, + domain: 'localhost' + } + ]); + expect(res.clearCookie.getCall(3).args).toEqual([ + '_csrf', + { + signed: false, + domain: 'localhost' + } + ]); + }); + }); +});