server/oauth.ts (296 lines of code) (raw):
import crypto from 'crypto';
import { joinUrl } from '@guardian/libs';
import OktaJwtVerifier from '@okta/jwt-verifier';
import type { Request, Response } from 'express';
import ms from 'ms';
import type { Client, IssuerMetadata } from 'openid-client';
import { generators, Issuer } from 'openid-client';
import { conf } from '@/server/config';
import { setIdentityLocalState } from '@/server/identityLocalState';
import { log } from '@/server/log';
import type { Scopes, VerifiedOAuthCookies } from '@/server/oauthConfig';
import {
IdTokenClaims,
OAuthAccessTokenCookieName,
oauthCookieOptions,
OAuthIdTokenCookieName,
OAuthStateCookieName,
scopes,
} from '@/server/oauthConfig';
import type { OktaConfig } from '@/server/oktaConfig';
import { getConfig as getOktaConfig } from '@/server/oktaConfig';
/**
* @function getOktaOrgUrl
*
* In PROD and CODE, our Okta org URL comes directly from the Okta config.
* In DEV, we always want it to point to CODE, despite the fact that the Okta config
* for DEV points to the DEV org URL. This is so we can use the same Okta auth server
* in DEV and CODE.
*/
const getOktaOrgUrl = (oktaConfig: OktaConfig) => {
const { orgUrl } = oktaConfig;
switch (conf.STAGE) {
case 'PROD':
case 'CODE':
return orgUrl;
case 'DEV':
default:
return orgUrl.replace(
'.thegulocal.com',
'.code.dev-theguardian.com',
);
}
};
const sharedTokenVerifierOptions = (
oktaConfig: OktaConfig,
): OktaJwtVerifier.VerifierOptions => ({
issuer: joinUrl(
getOktaOrgUrl(oktaConfig),
'/oauth2/',
oktaConfig.authServerId,
),
clientId: oktaConfig.clientId,
});
/**
* By default, the access token is verified by checking:
* - It has the correct audience (the URL of the resource server that should accept the token)
* This is checked using the expectedAudience parameter of the verifyAccessToken method.
* - It has the correct issuer (the URL of the authorization server that issued the token)
* This is checked using the issuer property of the OktaJwtVerifier constructor.
* Additionally, we check that the client ID (cid) matches the client ID of the MMA application.
* This ensures that MMA only accepts access tokens that it has generated.
* This is checked using the optional assertClaims parameter of the verifyAccessToken method.
*/
const oauthAccessTokenVerifier = (oktaConfig: OktaConfig) =>
new OktaJwtVerifier({
...sharedTokenVerifierOptions(oktaConfig),
assertClaims: {
cid: oktaConfig.clientId,
},
});
/**
* By default, the ID token is verified by checking:
* - Its client ID matches the client ID of the MMA application
* This is checked using the expectedClientId parameter of the verifyIdToken method.
* - It has the correct issuer (the URL of the authorization server that issued the token)
* This is checked using the issuer property of the OktaJwtVerifier constructor.
* We don't need to make any custom assertClaims checks for the ID token.
*/
const oauthIdTokenVerifier = (oktaConfig: OktaConfig) =>
new OktaJwtVerifier({
...sharedTokenVerifierOptions(oktaConfig),
});
export const verifyAccessToken = async (token: string) => {
const oktaConfig = await getOktaConfig();
try {
const jwt = await oauthAccessTokenVerifier(
oktaConfig,
).verifyAccessToken(token, joinUrl(getOktaOrgUrl(oktaConfig), '/'));
return jwt;
} catch (error) {
log.error('OAuth / Access Token / Verification Error', error);
}
};
export const verifyIdToken = async (token: string) => {
const oktaConfig = await getOktaConfig();
try {
const jwt = await oauthIdTokenVerifier(oktaConfig).verifyIdToken(
token,
oktaConfig.clientId,
);
return jwt;
} catch (error) {
log.error('OAuth / ID Token / Verification Error', error);
}
};
export const ManageMyAccountOpenIdClient = async (oktaConfig: OktaConfig) => {
const issuer = joinUrl(
oktaConfig.orgUrl,
'/oauth2/',
oktaConfig.authServerId,
);
const OIDC_METADATA: IssuerMetadata = {
issuer,
authorization_endpoint: joinUrl(issuer, '/v1/authorize'),
token_endpoint: joinUrl(issuer, '/v1/token'),
jwks_uri: joinUrl(issuer, '/v1/keys'),
userinfo_endpoint: joinUrl(issuer, '/v1/userinfo'),
registration_endpoint: joinUrl(issuer, '/oauth2/v1/clients'),
introspection_endpoint: joinUrl(issuer, '/v1/introspect'),
revocation_endpoint: joinUrl(issuer, '/v1/revoke'),
end_session_endpoint: joinUrl(issuer, '/v1/logout'),
};
const OIDCIssuer = new Issuer(OIDC_METADATA);
return new OIDCIssuer.Client({
client_id: oktaConfig.clientId,
client_secret: oktaConfig.clientSecret,
redirect_uris: [`https://manage.${conf.DOMAIN}/oauth/callback`],
});
};
/**
* @class DevManageMyAccountOpenIdClient
*
* DEV ONLY
*
* Uses two issuers: one based on the dev config, which is used for
* routing to local Gateway for the authorization, and one pointing
* directly to CODE, which is used for the token exchange.
*/
const DevManageMyAccountOpenIdClient = (oktaConfig: OktaConfig) => {
const issuer = joinUrl(
'https://profile.code.dev-theguardian.com/oauth2/',
oktaConfig.authServerId,
);
const devIssuer = joinUrl(
oktaConfig.orgUrl,
'/oauth2/',
oktaConfig.authServerId,
);
const OIDC_METADATA: IssuerMetadata = {
issuer,
authorization_endpoint: joinUrl(devIssuer, '/v1/authorize'),
token_endpoint: joinUrl(devIssuer, '/v1/token'),
jwks_uri: joinUrl(devIssuer, '/v1/keys'),
userinfo_endpoint: joinUrl(devIssuer, '/v1/userinfo'),
registration_endpoint: joinUrl(devIssuer, '/oauth2/v1/clients'),
introspection_endpoint: joinUrl(devIssuer, '/v1/introspect'),
revocation_endpoint: joinUrl(devIssuer, '/v1/revoke'),
end_session_endpoint: joinUrl(devIssuer, '/v1/logout'),
};
const OIDCIssuer = new Issuer(OIDC_METADATA);
return new OIDCIssuer.Client({
client_id: oktaConfig.clientId,
client_secret: oktaConfig.clientSecret,
redirect_uris: [`https://manage.${conf.DOMAIN}/oauth/callback`],
});
};
/**
* @function getOpenIdClient
*
* Used to determine which OpenIdClient to get based on the stage and headers
* In development, we use the dev issuer to simulate a custom domain, so we
* want the DevProfileIdClient
* In production, we use the production issuer, so we want the ManageMyAccountOpenIdClient
*/
export const getOpenIdClient = async (): Promise<Client> => {
const oktaConfig = await getOktaConfig();
if (conf.STAGE === 'DEV') {
return DevManageMyAccountOpenIdClient(oktaConfig);
}
return ManageMyAccountOpenIdClient(oktaConfig);
};
/**
* @param redirectUri - the redirect uri to use for the /authorize endpoint
* @param scopes (optional) - any scopes to use for the /authorize endpoint, defaults to ['openid']
* @param sessionToken (optional) - if provided, we'll use this to set the session cookie
* @param returnPath (optional) - if provided, we'll use this as the return path after the /authorize callback
*/
interface PerformAuthorizationCodeFlowOptions {
redirectUri: string;
scopes: readonly Scopes[];
sessionToken?: string | null;
returnPath?: string;
}
/**
* @name performAuthorizationCodeFlow
* @description Helper method to perform the Authorization Code Flow
*
* Used for post authentication with the session token to set a session cookie.
*
* @param req - the express request object
* @param res - the express response object
* @param options - the options for the authorization code flow
* @returns 303 redirect to the okta /authorize endpoint
*/
export const performAuthorizationCodeFlow = async (
_req: Request,
res: Response,
{
sessionToken,
scopes = ['openid'],
redirectUri,
returnPath,
}: PerformAuthorizationCodeFlowOptions,
) => {
const OpenIdClient = await getOpenIdClient();
const oktaConfig = await getOktaConfig();
// Encode the returnPath, a state token, and the PKCE code verifier into a state cookie
const stateToken = crypto.randomBytes(16).toString('base64');
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const codeChallengeMethod = 'S256';
const state = {
returnPath,
stateToken,
codeVerifier,
};
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
res.cookie(OAuthStateCookieName, encodedState, {
...oauthCookieOptions,
maxAge: ms('10m'),
});
// generate the /authorize endpoint url which we'll redirect the user to
const authorizeUrl = OpenIdClient.authorizationUrl({
// The sessionToken from authentication to exchange for session cookie
sessionToken,
// we send the generated stateParam as the state parameter
state: stateToken,
// any scopes, by default the 'openid' scope is required
scope: scopes.join(' '),
// the redirect_uri is the callback location we'll redirect to after authentication
redirect_uri: redirectUri, // /oauth/callback
// PKCE
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
response_type: 'code',
// A max age value means that the user will be prompted to re-authenticate
// after that many seconds of inactivity. This only works for users who have not signed in
// with FEDERATED or SOCIAL type sessions, because Okta will automatically refresh
// the tokens for those users if they have an active session of any age.
max_age: oktaConfig.maxAge,
});
// redirect the user to the authorize URL
return res.redirect(303, authorizeUrl);
};
export const revokeAccessToken = async (token: string) => {
const openIdClient = await getOpenIdClient();
try {
await openIdClient.revoke(token, 'access_token');
} catch (error) {
log.error('OAuth / Revoke Access Token / Error', error);
}
};
/**
* @name verifyOAuthCookiesLocally
*
* This verifies the tokens locally, i.e. without making a request to the Okta API,
* by verifying the signature and checking the claims and expiry and ensuring that
* the scopes are all the ones we expect.
*
* @param req - the Express request object
* @returns either an object containing the verified access and ID tokens, or an empty object
*/
export const verifyOAuthCookiesLocally = async (
req: Request,
): Promise<VerifiedOAuthCookies | undefined> => {
const accessTokenCookie = req.signedCookies[OAuthAccessTokenCookieName];
const idTokenCookie = req.signedCookies[OAuthIdTokenCookieName];
if (accessTokenCookie && idTokenCookie) {
const accessToken = await verifyAccessToken(accessTokenCookie);
const idToken = await verifyIdToken(idTokenCookie);
if (
// check access token is valid
accessToken &&
// check that the id token is valid
idToken &&
// check that the access token is not expired
!accessToken.isExpired() &&
// check that the scopes are all the ones we expect
scopes.every((scope) => accessToken.claims.scp?.includes(scope))
) {
return {
accessToken,
idToken,
};
}
}
};
/**
* @name idTokenIsRecent
*
* @description Verifies that the ID token is for a session which was created within the session
* lifetime value (30 minutes by default). This is because Okta will automatically
* refresh the tokens if the user has an active session of any age when the session
* if of FEDERATED or SOCIAL type, even if the max_age parameter is sent in the
* /authorize request.
*
* We only verify the ID token because the Okta documentation says that the auth_time claim
* is a base claim, always present in the ID token. See:
* https://developer.okta.com/docs/reference/api/oidc/#claims-in-the-payload-section
*
* @param idToken - the ID token to check
* @param maxAge - the maximum permitted age of the ID token, in seconds
*/
export const idTokenIsRecent = (
idToken: OktaJwtVerifier.Jwt,
maxAge: number,
) => {
const nowSeconds = Math.floor(Date.now() / 1000);
const authTime = idToken.claims.auth_time;
if (
!authTime ||
typeof authTime !== 'number' ||
authTime < 0 ||
authTime > nowSeconds
) {
log.error('OAuth / ID Token / Invalid auth_time claim', {
authTime: idToken.claims.auth_time,
});
return false;
}
if (nowSeconds - authTime > maxAge) {
log.info('OAuth / ID Token / auth_time claim is too old');
return false;
}
return true;
};
/**
* @name signInStatus
*
* @description Returns one of three values:
* - 'signedInRecently' if the user has a recent ID token.
* - 'signedInNotRecently' if the user has a valid ID token but it's not recent,
* or if the user has only a GU_U cookie, but no ID token.
* - 'notSignedIn' if the user has no ID token or GU_U cookie.
*
* @param idToken - the ID token to check (optional)
* @param guUCookie - the GU_U cookie to check (optional)
* @param maxAge - the maximum permitted age of the ID token, in seconds
*/
export const signInStatus = (
idToken: OktaJwtVerifier.Jwt | undefined,
guUCookie: number | undefined,
maxAge: number,
) => {
const tokenIsRecent = idToken && idTokenIsRecent(idToken, maxAge);
if (tokenIsRecent) {
return 'signedInRecently';
} else if (idToken || guUCookie) {
return 'signedInNotRecently';
}
return 'notSignedIn';
};
export const setLocalStateFromIdTokenOrUserCookie = (
req: Request,
res: Response,
idToken: OktaJwtVerifier.Jwt | undefined,
maxAge: number,
) => {
// Mirrors the response we got previously from the auth/redirect endpoint
// in IDAPI. We store the user's ID, name and email on the identity object
// of res.locals so that it can be used by the rest of the app.
// signInStatus is always 'signedInRecently' because we only get here
// if the access and ID tokens are valid, and they're only valid for 30 minutes.
// If we have an ID token, we set the local state from that.
// Otherwise, if the GU_U cookie exists, we simply set 'signInStatus',
// but not the other fields. This will allow the frontend to show the
// signed in menu, but not show the user's name or email.
const guUCookie = req.cookies['GU_U'];
const result = IdTokenClaims.safeParse(idToken?.claims);
setIdentityLocalState(res, {
signInStatus: signInStatus(idToken, guUCookie, maxAge),
userId: result.success ? result.data.legacy_identity_id : undefined,
displayName: result.success ? result.data.name : undefined,
email: result.success ? result.data.email : undefined,
});
};
// Sanitize the return path to prevent open redirects.
// Allows relative paths starting with '/' and strips trailing slashes and query params.
export const sanitizeReturnPath = (returnPath: string) => {
try {
if (returnPath.match(/^(https?:)?\/\//)) {
return '/';
}
const url = new URL(`https://example.com${returnPath}`);
if (url.pathname.endsWith('/')) {
return url.pathname.slice(0, -1);
} else {
return url.pathname;
}
} catch {
return '/';
}
};
export const allIdapiCookiesSet = (req: Request) => {
const idapiCookies = ['GU_U', 'SC_GU_U'];
return idapiCookies.every((cookie) => req.cookies[cookie]);
};