Skip to content

Commit 0514e36

Browse files
author
Raphael Kabo
committed
use Zod to parse Okta responses
1 parent ebad1fb commit 0514e36

File tree

2 files changed

+125
-48
lines changed

2 files changed

+125
-48
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,79 @@
11
import { joinUrl } from '@guardian/libs';
22
import type { NextFunction, Request, Response } from 'express';
3+
import { z } from 'zod';
34
import {
45
clearIdentityLocalState,
56
getIdentityLocalState,
67
setIdentityLocalState,
78
} from '@/server/IdentityLocalState';
89
import { OAuthAccessTokenCookieName } from '@/server/oauth';
10+
import type { OktaConfig } from '@/server/oktaConfig';
911
import { getConfig as getOktaConfig } from '@/server/oktaConfig';
1012
import { requiresSignin } from '../../shared/requiresSignin';
1113

1214
declare const CYPRESS: string;
1315

14-
type OktaUserInfo = {
15-
legacy_identity_id: string;
16-
email: string;
17-
name: string;
16+
const OktaUserInfo = z.object({
17+
legacy_identity_id: z.optional(z.string()),
18+
email: z.optional(z.string()),
19+
name: z.optional(z.string()),
20+
});
21+
22+
interface OktaValidationResponse {
23+
ok: boolean;
24+
valid: boolean;
25+
userId?: string;
26+
displayName?: string;
27+
email?: string;
28+
}
29+
30+
const validateWithOkta = async ({
31+
oktaConfig,
32+
accessToken,
33+
}: {
34+
oktaConfig: OktaConfig;
35+
accessToken: string;
36+
}): Promise<OktaValidationResponse> => {
37+
const issuerUrl = joinUrl(
38+
oktaConfig.orgUrl,
39+
'/oauth2/',
40+
oktaConfig.authServerId,
41+
);
42+
43+
try {
44+
const oktaResponse = await fetch(`${issuerUrl}/v1/userinfo/`, {
45+
headers: {
46+
'Content-Type': 'application/json',
47+
Authorization: `Bearer ${accessToken}`,
48+
},
49+
});
50+
if (oktaResponse.status === 200) {
51+
// Valid token
52+
const oktaUserInfo = OktaUserInfo.parse(await oktaResponse.json());
53+
return {
54+
ok: true,
55+
valid: true,
56+
userId: oktaUserInfo.legacy_identity_id,
57+
displayName: oktaUserInfo.name,
58+
email: oktaUserInfo.email,
59+
};
60+
} else if ([401, 403].includes(oktaResponse.status)) {
61+
console.error(
62+
`OAuth / Serverside Validation Middleware / Error: invalid token.`,
63+
);
64+
return { ok: true, valid: false };
65+
} else {
66+
console.error(
67+
`OAuth / Serverside Validation Middleware / Error: unexpected status code from Okta: ${oktaResponse.status}`,
68+
);
69+
return { ok: false, valid: false };
70+
}
71+
} catch (error) {
72+
console.error(
73+
`OAuth / Serverside Validation Middleware / Error: ${error.message}`,
74+
);
75+
return { ok: false, valid: false };
76+
}
1877
};
1978

2079
export const withOktaSeverSideValidation = async (
@@ -27,66 +86,83 @@ export const withOktaSeverSideValidation = async (
2786
}
2887
const oktaConfig = await getOktaConfig();
2988
if (!oktaConfig.useOkta) {
30-
console.log('Okta disabled, skipping Okta server side validation...');
89+
/**
90+
* OKTA NOT ENABLED
91+
*
92+
* If Okta is disabled, we will still run this middleware, but
93+
* it won't be able to do anyting so we just pass through.
94+
*/
3195
return next();
3296
}
3397

34-
console.log('Validating token server side ...');
3598
const signinRequired = requiresSignin(req.originalUrl);
3699

37100
const locallyValidatedIdentityData = getIdentityLocalState(res);
38101
const accessToken = req.signedCookies[OAuthAccessTokenCookieName];
39102
if (!accessToken || !locallyValidatedIdentityData?.userId) {
40103
if (signinRequired) {
104+
/**
105+
* NO TOKEN OR USER - SIGN IN REQUIRED
106+
*
107+
* This is unexpected and should be impossible because the withIdentity
108+
* middleware should have run first and not allowed the request to get
109+
* here. Return a 500.
110+
*/
41111
console.error(
42-
'error: no access token or user in request for a sign-in required endpoint! this should have failed local validation',
112+
`OAuth / Serverside Validation Middleware / Error: no access token or user in request for a sign-in required endpoint! This should have failed local validation.`,
43113
);
44114
return res.sendStatus(500);
45115
} else {
116+
/**
117+
* NO TOKEN OR USER - SIGN IN NOT REQUIRED
118+
*
119+
* This is expected - continue.
120+
*/
46121
return next();
47122
}
48123
}
49124

50-
const issuerUrl = joinUrl(
51-
oktaConfig.orgUrl,
52-
'/oauth2/',
53-
oktaConfig.authServerId,
54-
);
55-
56-
const oktaResponse = await fetch(`${issuerUrl}/v1/userinfo/`, {
57-
method: 'GET',
58-
headers: {
59-
'Content-Type': 'application/json',
60-
Authorization: `Bearer ${accessToken}`,
61-
},
125+
const oktaValidationResponse = await validateWithOkta({
126+
oktaConfig,
127+
accessToken,
62128
});
63129

64-
console.log(`okta response status: ${oktaResponse.status}`);
65-
if (oktaResponse.status == 200) {
66-
//valid token
67-
const oktaUserInfo = (await oktaResponse.json()) as OktaUserInfo;
68-
// Refresh the local state from the Okta response.
69-
setIdentityLocalState(res, {
70-
// This is always 'signedInRecently' because we've just checked
71-
// the token is valid with Okta, and it's only valid for 30 minutes.
72-
signInStatus: 'signedInRecently',
73-
userId: oktaUserInfo.legacy_identity_id,
74-
displayName: oktaUserInfo.name,
75-
email: oktaUserInfo.email,
76-
});
77-
return next();
78-
} else if ([401, 403].includes(oktaResponse.status)) {
79-
//invalid token
130+
if (!oktaValidationResponse.ok) {
131+
/**
132+
* UNEXPECTED ERROR
133+
*/
134+
return res.sendStatus(500);
135+
}
136+
137+
if (!oktaValidationResponse.valid) {
138+
/**
139+
* INVALID TOKEN
140+
*
141+
* Clear the local state. If the endpoint requires sign-in,
142+
* return a 401 which can be handled down the line. Otherwise
143+
* continue (the user will see the page as if they are not
144+
* signed in).
145+
*/
80146
clearIdentityLocalState(res);
81147
if (signinRequired) {
82148
return res.sendStatus(401);
83149
}
84150
return next();
85-
} else {
86-
//unexpected return status
87-
console.error(
88-
`unexpected status from okta userinfo ${oktaResponse.status}`,
89-
);
90-
return res.sendStatus(500);
91151
}
152+
153+
/**
154+
* VALID TOKEN
155+
*
156+
* Update the local state with the latest user info from Okta.
157+
*/
158+
setIdentityLocalState(res, {
159+
// This is always 'signedInRecently' because we've just checked
160+
// the token is valid with Okta, and it's only valid for 30 minutes.
161+
signInStatus: 'signedinrecently',
162+
userId: oktaValidationResponse.userId,
163+
displayName: oktaValidationResponse.displayName,
164+
email: oktaValidationResponse.email,
165+
});
166+
167+
return next();
92168
};

server/oauth.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import crypto from 'crypto';
22
import { joinUrl } from '@guardian/libs';
3-
import type { JwtClaims } from '@okta/jwt-verifier';
43
import OktaJwtVerifier from '@okta/jwt-verifier';
54
import type { CookieOptions, Request, Response } from 'express';
65
import ms from 'ms';
76
import type { Client, IssuerMetadata } from 'openid-client';
87
import { generators, Issuer } from 'openid-client';
8+
import { z } from 'zod';
99
import { conf } from '@/server/config';
1010
import { setIdentityLocalState } from '@/server/IdentityLocalState';
1111
import type { OktaConfig } from '@/server/oktaConfig';
@@ -26,11 +26,12 @@ export const oauthCookieOptions: CookieOptions = {
2626
httpOnly: true,
2727
};
2828

29-
interface IdTokenClaims extends JwtClaims {
30-
legacy_identity_id: string;
31-
name: string;
32-
email: string;
33-
}
29+
// Zod schema for the claims we expect to find in the ID token
30+
const IdTokenClaims = z.object({
31+
legacy_identity_id: z.optional(z.string()),
32+
name: z.optional(z.string()),
33+
email: z.optional(z.string()),
34+
});
3435

3536
/**
3637
* @function getOktaOrgUrl
@@ -359,7 +360,7 @@ export const setLocalStateFromIdTokenOrUserCookie = (
359360
legacy_identity_id: userId,
360361
name: displayName,
361362
email,
362-
} = idToken?.claims as IdTokenClaims;
363+
} = IdTokenClaims.parse(idToken?.claims || {});
363364
setIdentityLocalState(res, {
364365
signInStatus: hasIdTokenOrUserCookie ? 'signedInRecently' : undefined,
365366
userId,

0 commit comments

Comments
 (0)