Skip to content

Commit

Permalink
feat: support functions to check ID token auth_time
Browse files Browse the repository at this point in the history
  • Loading branch information
Raphael Kabo committed Jan 22, 2024
1 parent dd82adb commit 6a8acc3
Show file tree
Hide file tree
Showing 3 changed files with 78 additions and 10 deletions.
78 changes: 71 additions & 7 deletions server/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export const performAuthorizationCodeFlow = async (
}: 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');
Expand Down Expand Up @@ -258,9 +259,11 @@ export const performAuthorizationCodeFlow = async (
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
response_type: 'code',
// A max age of 30 minutes means that the user will be prompted to re-authenticate
// after 30 minutes of inactivity
max_age: Math.floor(ms('30m') / 1000),
// 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
Expand Down Expand Up @@ -304,7 +307,68 @@ export const verifyOAuthCookiesLocally = async (
}
};

export const setLocalStateFromIdTokenOrUserCookie = (
/**
* @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
*/
export const idTokenIsRecent = async (idToken: OktaJwtVerifier.Jwt) => {
const nowSeconds = Math.floor(Date.now() / 1000);
const { maxAge } = await getOktaConfig();
const authTime = idToken.claims.auth_time;

if (
!authTime ||
typeof authTime !== 'number' ||
authTime < 0 ||
authTime > nowSeconds
) {
return false;
}

if (nowSeconds - authTime > maxAge) {
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 a GU_U cookie.
* - 'notSignedIn' if the user has no valid ID token or GU_U cookie.
*
* @param idToken - the ID token to check (optional)
* @param guUCookie - the GU_U cookie to check (optional)
*/
const signInStatus = async (
idToken: OktaJwtVerifier.Jwt | undefined,
guUCookie: number | undefined,
) => {
const tokenIsRecent = idToken && (await idTokenIsRecent(idToken));
if (tokenIsRecent) {
return 'signedInRecently';
} else if (idToken || guUCookie) {
return 'signedInNotRecently';
}
return 'notSignedIn';
};

export const setLocalStateFromIdTokenOrUserCookie = async (
req: Request,
res: Response,
idToken?: OktaJwtVerifier.Jwt,
Expand All @@ -318,11 +382,11 @@ export const setLocalStateFromIdTokenOrUserCookie = (
// 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 hasIdTokenOrUserCookie = idToken || req.cookies['GU_U'];

const guUCookie = req.cookies['GU_U'];
const result = IdTokenClaims.safeParse(idToken?.claims);

setIdentityLocalState(res, {
signInStatus: hasIdTokenOrUserCookie ? 'signedInRecently' : undefined,
signInStatus: await signInStatus(idToken, guUCookie),
userId: result.success ? result.data.legacy_identity_id : undefined,
displayName: result.success ? result.data.name : undefined,
email: result.success ? result.data.email : undefined,
Expand Down
2 changes: 2 additions & 0 deletions server/oktaConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface OktaConfig {
// If true, the withIdentity middleware will use the Okta OAuth flow.
// If false, the withIdentity middleware will use the classic IDAPI cookie flow.
useOkta: boolean;
maxAge: number; // in seconds
orgUrl: string;
authServerId: string;
clientId: string;
Expand All @@ -14,6 +15,7 @@ export interface OktaConfig {

const isValidConfig = (config: any): config is OktaConfig =>
typeof config.useOkta === 'boolean' &&
typeof config.maxAge === 'number' && // Could be 0
config.orgUrl &&
config.authServerId &&
config.clientId &&
Expand Down
8 changes: 5 additions & 3 deletions server/routes/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Request, Response } from 'express';
import { Router } from 'express';
import ms from 'ms';
import { getOpenIdClient } from '@/server/oauth';
import { oauthCookieOptions, OAuthStateCookieName } from '@/server/oauthConfig';
import { conf } from '../config';
import { log } from '../log';
import { getConfig as getOktaConfig } from '../oktaConfig';

const router = Router();

Expand All @@ -14,6 +14,8 @@ const handleCallbackRouteError = (err: Error, res: Response) => {
};

router.get('/callback', async (req: Request, res: Response) => {
const oktaConfig = await getOktaConfig();

// Read the state cookie
if (!req.signedCookies[OAuthStateCookieName]) {
return handleCallbackRouteError(
Expand Down Expand Up @@ -61,11 +63,11 @@ router.get('/callback', async (req: Request, res: Response) => {
// Set the access token and ID tokens as cookies
res.cookie('GU_ACCESS_TOKEN', tokenSet.access_token, {
...oauthCookieOptions,
maxAge: ms('30m'), // Same expiry as set in Okta
maxAge: oktaConfig.maxAge * 1000, // Same expiry as set in Okta, but in ms
});
res.cookie('GU_ID_TOKEN', tokenSet.id_token, {
...oauthCookieOptions,
maxAge: ms('30m'), // Same expiry as set in Okta
maxAge: oktaConfig.maxAge * 1000, // Same expiry as set in Okta, but in ms
});

// Delete state cookie, for it is no longer needed
Expand Down

0 comments on commit 6a8acc3

Please sign in to comment.