server/apiProxy.ts (187 lines of code) (raw):

import { parse } from 'url'; import * as Sentry from '@sentry/node'; import type { Request, Response } from 'express'; import fetch from 'node-fetch'; import { LOGGING_CODE_SUFFIX_HEADER } from '../shared/globals'; import { X_GU_ID_FORWARDED_SCOPE } from '../shared/identity'; import { MDA_TEST_USER_HEADER } from '../shared/productResponse'; import { conf } from './config'; import { getCookiesOrEmptyString } from './idapiAuth'; import { log, putMetric } from './log'; import { augmentRedirectURL } from './middleware/requestMiddleware'; import { OAuthAccessTokenCookieName } from './oauthConfig'; import { getConfig as getOktaConfig } from './oktaConfig'; type BodyHandler = (res: Response, body: Buffer) => void; type JsonString = Buffer | string | undefined; export const straightThroughBodyHandler: BodyHandler = (res, body) => res.send(body); function safeJsonParse(jsonStr: JsonString): object | JsonString { try { if (jsonStr) { return JSON.parse(jsonStr.toString()); } return jsonStr; } catch { return jsonStr; } } export type Headers = Record<string, string>; export type AdditionalHeaderGenerator = ( method: string, host: string, path: string, body: string, ) => Promise<Headers>; export const proxyApiHandler = ( host: string, headers: Headers = {}, additionalHeaderGenerator: AdditionalHeaderGenerator = () => Promise.resolve({}), ) => (bodyHandler: BodyHandler) => ( path: string, mainLoggingCode: string, urlParamNamesToReplace: string[] = [], forwardQueryArgs?: boolean, // TODO could we eliminate this and always forward query params shouldNotLogBody?: boolean, ) => async (req: Request, res: Response) => { const parameterisedPath = urlParamNamesToReplace .reduce( (evolvingPath: string, urlParamName: string) => evolvingPath.replace( ':' + urlParamName, req.params[urlParamName] || '', ), path, ) .replace(/\/$/, ''); // strips any trailing slashes const queryString = forwardQueryArgs && req.query && Object.keys(req.query).length > 0 ? `?${parse(req.url).query}` : ''; const isTestUser = req.header(MDA_TEST_USER_HEADER) === 'true'; const requestBody = Buffer.isBuffer(req.body) ? req.body : undefined; const httpMethod = req.method; const finalPath = `/${parameterisedPath}${queryString}`; const outgoingURL = `https://${host}${finalPath}`; const loggingCode = `${mainLoggingCode}${ req.header(LOGGING_CODE_SUFFIX_HEADER) || '' }`; // tslint:disable-next-line:no-object-mutation res.locals.loggingDetail = { loggingCode, httpMethod, isTestUser, identityID: res.locals.identity && res.locals.identity.userId, incomingURL: req.originalUrl, requestBody: safeJsonParse(requestBody), outgoingURL, }; fetch(outgoingURL, { method: httpMethod, body: requestBody, headers: { ...(await authorizationOrCookieHeader({ req, host })), 'Content-Type': 'application/json', // TODO: set this from the client req headers (would need to check all client calls actually specify content-type) [X_GU_ID_FORWARDED_SCOPE]: req.header(X_GU_ID_FORWARDED_SCOPE) || '', ...headers, ...(await additionalHeaderGenerator( httpMethod, host, finalPath, requestBody?.toString() || '', )), }, }) .then((intermediateResponse) => { // tslint:disable-next-line:no-object-mutation res.locals.loggingDetail.status = intermediateResponse.status; // tslint:disable-next-line:no-object-mutation res.locals.loggingDetail.isOK = intermediateResponse.ok; res.status(intermediateResponse.status); // Forward certain headers in the response to the client [ 'Content-Type', 'Content-Length', MDA_TEST_USER_HEADER, ].forEach((headerName) => res.header( headerName, intermediateResponse.headers.get(headerName) || undefined, ), ); const idapiRedirect = intermediateResponse.headers.get( 'X-GU-IDAPI-Redirect', ); if (intermediateResponse.status === 401 && idapiRedirect) { res.header( 'Location', augmentRedirectURL( req, idapiRedirect, conf.DOMAIN, true, ), ); } return intermediateResponse.buffer(); }) .then((body) => { const suitableLog = res.locals.loggingDetail.isOK ? log.info : log.warn; suitableLog( 'proxying', shouldNotLogBody ? res.locals.loggingDetail : { ...res.locals.loggingDetail, responseBody: safeJsonParse(body), }, ); putMetric(res.locals.loggingDetail); bodyHandler(res, body); }) .catch((error) => { log.error('ERROR proxying', { ...res.locals.loggingDetail, exception: error ? error.toString() : 'undefined', }); Sentry.captureException(error); putMetric(res.locals.loggingDetail); res.status(500).send('Something broke!'); }); }; export const authorizationOrCookieHeader = async ({ req, host, }: { req: Request; host: string; }): Promise<Headers> => { // If Okta is disabled, always return the cookie header const { useOkta } = await getOktaConfig(); if (!useOkta) { return { Cookie: getCookiesOrEmptyString(req), }; } switch (host) { case 'members-data-api.' + conf.DOMAIN: case 'user-benefits.' + conf.API_DOMAIN: return { Authorization: `Bearer ${req.signedCookies[OAuthAccessTokenCookieName]}`, }; default: return {}; } }; export const customMembersDataApiHandler = proxyApiHandler( 'members-data-api.' + conf.DOMAIN, ); export const membersDataApiHandler = customMembersDataApiHandler( straightThroughBodyHandler, ); export const userBenefitsApiHandler = proxyApiHandler( 'user-benefits.' + conf.API_DOMAIN, )(straightThroughBodyHandler);