server/middleware/identityMiddleware.ts (281 lines of code) (raw):
import url from 'url';
import type OktaJwtVerifier from '@okta/jwt-verifier';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import fetch from 'node-fetch';
import { handleAwsRelatedError } from '@/server/awsIntegration';
import { conf } from '@/server/config';
import type { RedirectResponseBody } from '@/server/idapiAuth';
import {
containsSignInTokenQueryParameters,
getCookiesOrEmptyString,
redirectOrCustomStatusCode,
} from '@/server/idapiAuth';
import { idapiConfigPromise } from '@/server/idapiConfig';
import { log } from '@/server/log';
import {
augmentRedirectURL,
updateManageUrl,
} from '@/server/middleware/requestMiddleware';
import {
allIdapiCookiesSet,
idTokenIsRecent,
performAuthorizationCodeFlow,
revokeAccessToken,
sanitizeReturnPath,
setLocalStateFromIdTokenOrUserCookie,
verifyOAuthCookiesLocally,
} from '@/server/oauth';
import {
OAuthAccessTokenCookieName,
oauthCookieOptions,
OAuthIdTokenCookieName,
scopes,
} from '@/server/oauthConfig';
import type { OktaConfig } from '@/server/oktaConfig';
import { getConfig as getOktaConfig } from '@/server/oktaConfig';
import {
getScopeFromRequestPathOrEmptyString,
X_GU_ID_FORWARDED_SCOPE,
} from '@/shared/identity';
import { requiresSignin } from '@/shared/requiresSignin';
declare const CYPRESS: string;
const handleIdentityMiddlewareError = (err: Error, res: Response) => {
log.error('OAuth / Middleware / Error', err);
res.redirect('/sign-in-error');
};
export const clearOAuthCookies = (res: Response) => {
res.clearCookie(OAuthAccessTokenCookieName, oauthCookieOptions);
res.clearCookie(OAuthIdTokenCookieName, oauthCookieOptions);
};
const signedOutAfterTokensIssued = ({
guSoTimestamp,
accessToken,
}: {
guSoTimestamp: number;
accessToken: OktaJwtVerifier.Jwt;
}) =>
guSoTimestamp &&
typeof accessToken.claims.iat === 'number' &&
guSoTimestamp > accessToken.claims.iat &&
// Check that the GU_SO cookie value is not set into the future to avoid a redirect loop
// where the middleware may continually keep running performAuthorizationCodeFlow().
guSoTimestamp <= Math.floor(Date.now() / 1000);
export const withIdentity: (
statusCodeOverride?: number,
keepQueryParams?: boolean,
) => RequestHandler =
(statusCodeOverride?: number, keepQueryParams?: boolean) =>
async (req: Request, res: Response, next: NextFunction) => {
if (CYPRESS === 'SKIP_IDAPI') {
return next();
}
try {
const oktaConfigOverride =
process.env.RUNNING_IN_CYPRESS === 'true' &&
req.cookies['okta-config-override']
? JSON.parse(req.cookies['okta-config-override'])
: {};
const oktaConfig = await getOktaConfig(oktaConfigOverride);
if (oktaConfig.useOkta) {
return authenticateWithOAuth(
req,
res,
next,
oktaConfig,
keepQueryParams,
);
} else {
return authenticateWithIdapi(statusCodeOverride)(
req,
res,
next,
);
}
} catch (err) {
handleIdentityMiddlewareError(err, res);
}
};
export const authenticateWithOAuth = async (
req: Request,
res: Response,
next: NextFunction,
oktaConfig: OktaConfig,
keepQueryParams: boolean = false,
) => {
// Get the path of the current page and use it as our returnPath after the OAuth callback.
const returnPath = keepQueryParams
? req.originalUrl
: sanitizeReturnPath(req.originalUrl);
try {
const verifiedTokens = await verifyOAuthCookiesLocally(req);
const guSoTimestamp = parseInt(req.cookies['GU_SO']);
if (requiresSignin(req.originalUrl)) {
// The route requires signin
/////////////////////////////////////////////////////////////////////////////////////
if (verifiedTokens?.accessToken && verifiedTokens?.idToken) {
// Check GU_SO cookie timestamp (we want to know if the user has signed out _after_ these tokens were issued,
// so we can redirect them to the OAuth flow to get new tokens for the currently signed-in user (if any).
if (
signedOutAfterTokensIssued({
guSoTimestamp,
accessToken: verifiedTokens.accessToken,
})
) {
clearOAuthCookies(res);
return performAuthorizationCodeFlow(req, res, {
redirectUri: `https://manage.${conf.DOMAIN}/oauth/callback`,
scopes,
returnPath,
});
}
// At this point, the GU_SO cookie is either not set, or it's older than the tokens,
// so we know the tokens belong to the currently signed-in user.
// Check the recency of the ID token (it must be within the configured maxAge in the Okta config).
// If it is not recent, we need to do the following:
// 1. Revoke the access token with Okta
// 2. Clear the OAuth cookies
// 3. Redirect the user to the /reauthenticate route in Gateway, which always shows a sign-in form.
// We need to send the user to /reauthenticate manually because there is undocumented Okta behaviour
// where a user of FEDERATED or SOCIAL auth type will be automatically granted new tokens by Okta
// irrespective of the max_age value in the OAuth request. If we run the standard OAuth callback flow
// here, we'll end up in a redirect loop.
if (
!idTokenIsRecent(verifiedTokens.idToken, oktaConfig.maxAge)
) {
await revokeAccessToken(
req.signedCookies[OAuthAccessTokenCookieName],
);
clearOAuthCookies(res);
const returnUrl = `https://manage.${conf.DOMAIN}${returnPath}`;
return res.redirect(
`https://profile.${
conf.DOMAIN
}/reauthenticate?returnUrl=${encodeURIComponent(
returnUrl,
)}`,
);
}
if (allIdapiCookiesSet(req)) {
// The user has valid access and ID tokens, and the full set of IDAPI cookies,
// so they're signed in. We set req.locals.identity so that the frontend can
// correctly show the user as signed in and continue to the route.
setLocalStateFromIdTokenOrUserCookie(
req,
res,
verifiedTokens.idToken,
oktaConfig.maxAge,
);
return next();
}
}
// We don't have the tokens, or they're invalid, or we're missing IDAPI cookies,
// so we need to get them.
return performAuthorizationCodeFlow(req, res, {
redirectUri: `https://manage.${conf.DOMAIN}/oauth/callback`,
scopes,
returnPath,
});
} else {
// The route does not require signin (but the user _may_ be signed in), e.g. help centre
////////////////////////////////////////////////////////////////////////////////////////
if (verifiedTokens?.accessToken && verifiedTokens?.idToken) {
// Check GU_SO cookie timestamp
if (
signedOutAfterTokensIssued({
guSoTimestamp,
accessToken: verifiedTokens.accessToken,
})
) {
clearOAuthCookies(res);
return next();
}
}
if (verifiedTokens?.idToken) {
// Check the recency of the ID token, if set (it must be within the configured maxAge in the Okta config).
// If it is not recent, we need to do the following:
// 1. Revoke the access token with Okta (if it exists)
// 2. Clear the OAuth cookies
// We do not redirect to /reauthenticate here because these routes do not require signin.
if (
!idTokenIsRecent(verifiedTokens?.idToken, oktaConfig.maxAge)
) {
if (verifiedTokens?.accessToken) {
await revokeAccessToken(
req.signedCookies[OAuthAccessTokenCookieName],
);
}
clearOAuthCookies(res);
}
}
// Set as much as possible of the local state from the available combination of
// GU_U and the ID token.
setLocalStateFromIdTokenOrUserCookie(
req,
res,
verifiedTokens?.idToken,
oktaConfig.maxAge,
);
return next();
}
} catch (err) {
return handleIdentityMiddlewareError(err, res);
}
};
const authenticateWithIdapi: (statusCodeOverride?: number) => RequestHandler =
(statusCodeOverride?: number) =>
(req: Request, res: Response, next: NextFunction) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- assume we don't know the range of possible types for the detail argument?
const errorHandler = (message: string, detail?: any) => {
handleAwsRelatedError(message, detail);
res.redirect('/sign-in-error');
};
const useRefererHeaderForManageUrl = !!statusCodeOverride;
idapiConfigPromise
.then((idapiConfig) => {
if (idapiConfig) {
fetch(
url.format({
protocol: 'https',
host: idapiConfig.host,
pathname: 'auth/redirect',
}),
{
headers: {
'X-GU-ID-Client-Access-Token':
'Bearer ' + idapiConfig.accessToken,
[X_GU_ID_FORWARDED_SCOPE]:
req.header(X_GU_ID_FORWARDED_SCOPE) ||
getScopeFromRequestPathOrEmptyString(
req.path,
),
Cookie: getCookiesOrEmptyString(req),
},
},
)
.then(
(redirectResponse) =>
redirectResponse.json() as Promise<RedirectResponseBody>,
)
.then((redirectResponseBody) => {
// tslint:disable-next-line:no-object-mutation
Object.assign(res.locals, {
identity: redirectResponseBody,
});
if (!requiresSignin(req.originalUrl)) {
next();
} else if (redirectResponseBody.redirect) {
redirectOrCustomStatusCode(
res,
augmentRedirectURL(
req,
redirectResponseBody.redirect.url,
conf.DOMAIN,
useRefererHeaderForManageUrl,
),
statusCodeOverride,
);
} else if (
redirectResponseBody.signInStatus ===
'signedInRecently'
) {
// If the request to manage contains sign-in token query parameters,
// but they are not needed because the user is already signed in,
// redirect them to the same url, but with the sign-in token query parameters removed.
// This ensures the sensitive query parameters will not be recorded by Ophan,
// in addition to the url the user sees in the browser being simpler.
if (containsSignInTokenQueryParameters(req)) {
// Note it is vital that updateManageUrl() removes the auto sign-in query parameters,
// otherwise, on redirect this branch of code would get executed again, causing a redirect loop to occur!
res.redirect(
updateManageUrl(
req,
useRefererHeaderForManageUrl,
),
);
} else {
next();
}
} else {
errorHandler(
'unexpected response from IDAPI redirect service',
redirectResponseBody,
);
}
})
.catch((err) => {
const message =
'error back from IDAPI redirect service';
if (requiresSignin(req.originalUrl)) {
errorHandler(message, err);
} else {
log.error(message, err);
next();
}
});
} else {
errorHandler('IDAPI config is undefined');
}
})
.catch((err) => errorHandler('error fetching IDAPI config', err));
};