server/idapiProxy.ts (204 lines of code) (raw):
import type { NextFunction, Request, Response } from 'express';
import type { HTTPMethod } from '../shared/apiTypes';
import { conf as mmaConfig } from './config';
import type { IdapiConfig } from './idapiConfig';
import { getConfig } from './idapiConfig';
import { OAuthAccessTokenCookieName } from './oauthConfig';
import { getConfig as getOktaConfig } from './oktaConfig';
import { handleError } from './util';
export type NewsletterPatchRequest = {
id: string;
subscribed: boolean;
};
export type ConsentPatchRequest = NewsletterPatchRequest;
interface IdapiFetchOptions {
route: string;
method: string;
headers: {
'X-GU-ID-Client-Access-Token'?: string;
'X-GU-ID-FOWARDED-SC-GU-U'?: string;
Authorization?: string;
'Content-Type'?: string;
Origin?: string;
Referer?: string;
};
}
interface CookiesWithToken {
SC_GU_U: string;
[key: string]: string;
}
const getBaseDomain = (): string => {
const { STAGE } = mmaConfig;
switch (STAGE) {
case 'PROD':
return 'theguardian.com';
default:
return 'code.dev-theguardian.com';
}
};
const SECURITY_HEADER_NAME = 'X-GU-ID-FOWARDED-SC-GU-U';
const SECURITY_COOKIE_NAME = 'SC_GU_U';
const securityCookieToHeader = (cookies: CookiesWithToken) => ({
[SECURITY_HEADER_NAME]: cookies[SECURITY_COOKIE_NAME],
});
const prepareBody = <T>(body: T | undefined) => {
if (!body) {
return undefined;
}
if (typeof body === 'string') {
return body;
}
if (Buffer.isBuffer(body)) {
return body;
}
// Body might be a JSON object, so we need to stringify it
try {
return JSON.stringify(body);
} catch (e) {
throw new Error(`Error stringifying request body: ${e}`);
}
};
const idapiOrOAuthHeaders = ({
sendAuthHeader,
config,
useOkta,
cookies,
signedCookies,
subdomain,
}: {
sendAuthHeader: boolean;
config: IdapiConfig;
useOkta: boolean;
cookies: CookiesWithToken;
signedCookies: CookiesWithToken;
subdomain: string;
}): HeadersInit => {
if (!sendAuthHeader) {
return {};
}
if (useOkta) {
return {
'X-GU-IS-OAUTH': 'true',
Authorization: `Bearer ${signedCookies[OAuthAccessTokenCookieName]}`,
};
} else {
return {
'X-GU-ID-Client-Access-Token': `Bearer ${config.accessToken}`,
...securityCookieToHeader(cookies),
// Avatar API expects a Cookie header with the SC_GU_U cookie.
Cookie: subdomain === 'avatar' ? `SC_GU_U=${cookies.SC_GU_U};` : '',
};
}
};
/**
* Prepares the options object for a fetch request to IDAPI
* or Avatar API (AAPI).
*
* @param options - The options object to prepare
* @param options.sendAuthHeader - Whether to send the access token or IDAPI cookies
* in the request
* @param options.useOkta - Whether to use Okta for authentication
* @param options.path - The path to the IDAPI endpoint (e.g. '/user/me')
* @param options.subdomain - The subdomain of the IDAPI endpoint
* (e.g. 'idapi' or 'avatar')
* @param options.method - The HTTP method to use
* @param options.cookies - The cookies coming from the client. These are used to
* build the security and cookie headers in the request
* @param options.signedCookies - The signed cookies coming from the client, also
* used to build the security and cookie headers in the request
* @param options.config - The IDAPI configuration object
* @returns The options object for the fetch request
*/
export const setOptions = ({
sendAuthHeader,
useOkta,
path,
subdomain,
method,
cookies,
signedCookies,
config,
}: {
sendAuthHeader: boolean;
useOkta: boolean;
path: string;
subdomain: string;
method: HTTPMethod;
cookies: CookiesWithToken;
signedCookies: CookiesWithToken;
config: IdapiConfig;
}): IdapiFetchOptions => {
const hostname = `${subdomain}.${getBaseDomain()}`;
const headers = {
...idapiOrOAuthHeaders({
sendAuthHeader,
config,
useOkta,
cookies,
signedCookies,
subdomain,
}),
'Content-Type': 'application/json',
Origin: `https://manage.${getBaseDomain()}`,
Referer: `https://manage.${getBaseDomain()}`,
};
const options = {
headers,
method,
route: `https://${hostname}${path}`,
};
return options;
};
/**
* Performs a fetch request to IDAPI.
* @template T - The type of the JSON payload sent to IDAPI
*/
export const idapiFetch = async <T>({
options,
body,
}: {
options: IdapiFetchOptions;
body?: T;
}) => {
const response = await fetch(options.route, {
method: options.method,
headers: options.headers,
body: prepareBody<T>(body),
});
return response;
};
/**
* Route handler which proxies a request made from the MMA client to IDAPI.
*
* @template T The type of the JSON response from IDAPI
* @param url The IDAPI endpoint to hit
* @param method The HTTP method to use (default: 'GET')
* @param processData A function to process the JSON response (optional)
* @param sendAuthHeader Whether to send the access token or IDAPI cookies
* in the request (default: false)
* @returns An Express route handler
*/
export const idapiProxyHandler =
<T>({
url,
method = 'GET',
processData,
sendAuthHeader = false,
}: {
url: string;
method?: HTTPMethod;
processData?: (json: T, res: Response) => Response | void;
useOAuth?: boolean;
sendAuthHeader?: boolean;
}) =>
async (req: Request, res: Response, next: NextFunction) => {
let config;
try {
config = await getConfig();
} catch (e) {
return handleError(e, res, next);
}
const { useOkta } = await getOktaConfig();
const options = setOptions({
sendAuthHeader,
useOkta,
path: url,
subdomain: 'idapi',
method,
cookies: req.cookies,
signedCookies: req.signedCookies,
config,
});
try {
const response = await idapiFetch<T>({
options,
// The body will come in as a Buffer because we're not parsing JSON
// so we simply pass it through as is. We don't care what's inside it.
body: ['POST', 'PUT', 'PATCH'].includes(method)
? req.body
: undefined,
});
if (response.status === 204) {
return res.sendStatus(204);
} else {
try {
const json = await response.json();
if (processData) {
return processData(json, res);
} else {
// Just send the JSON response as is
return res.status(response.status).json(json);
}
} catch (e) {
// Swallow JSON parse errors
if (e instanceof SyntaxError) {
return res.sendStatus(response.status);
} else {
return handleError(e, res, next);
}
}
}
} catch (error) {
handleError(error, res, next);
}
};