Skip to content

Commit 996be8c

Browse files
committed
use central redis cache for IAM
1 parent 96fbf62 commit 996be8c

File tree

10 files changed

+270
-91
lines changed

10 files changed

+270
-91
lines changed

src/api/functions/apiKey.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import {
77
DynamoDBClient,
88
GetItemCommand,
99
} from "@aws-sdk/client-dynamodb";
10-
import { genericConfig } from "common/config.js";
11-
import { AUTH_DECISION_CACHE_SECONDS as API_KEY_DATA_CACHE_SECONDS } from "./authorization.js";
10+
import { genericConfig, GENERIC_CACHE_SECONDS } from "common/config.js";
1211
import { unmarshall } from "@aws-sdk/util-dynamodb";
1312
import { ApiKeyMaskedEntry, DecomposedApiKey } from "common/types/apiKey.js";
1413
import { AvailableAuthorizationPolicy } from "common/policies/definition.js";
@@ -105,7 +104,7 @@ export const getApiKeyData = async ({
105104
});
106105
const result = await dynamoClient.send(getCommand);
107106
if (!result || !result.Item) {
108-
nodeCache.set(cacheKey, null, API_KEY_DATA_CACHE_SECONDS);
107+
nodeCache.set(cacheKey, null, GENERIC_CACHE_SECONDS);
109108
return undefined;
110109
}
111110
const unmarshalled = unmarshall(result.Item) as ApiKeyDynamoEntry;
@@ -124,7 +123,7 @@ export const getApiKeyData = async ({
124123
if (!("keyHash" in unmarshalled)) {
125124
return undefined; // bad data, don't cache it
126125
}
127-
let cacheTime = API_KEY_DATA_CACHE_SECONDS;
126+
let cacheTime = GENERIC_CACHE_SECONDS;
128127
if (unmarshalled.expiresAt) {
129128
const currentEpoch = Date.now();
130129
cacheTime = min(cacheTime, unmarshalled.expiresAt - currentEpoch);

src/api/functions/authorization.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ import { unmarshall } from "@aws-sdk/util-dynamodb";
33
import { genericConfig } from "../../common/config.js";
44
import { DatabaseFetchError } from "../../common/errors/index.js";
55
import { allAppRoles, AppRoles } from "../../common/roles.js";
6-
import { FastifyInstance } from "fastify";
7-
8-
export const AUTH_DECISION_CACHE_SECONDS = 180;
96

107
export async function getUserRoles(
118
dynamoClient: DynamoDBClient,

src/api/functions/encryption.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { DecryptionError } from "common/errors/index.js";
2+
import crypto, { createDecipheriv, pbkdf2Sync } from "node:crypto";
3+
4+
const VALID_PREFIX = "VALID:";
5+
const ITERATIONS = 100000;
6+
const KEY_LEN = 32;
7+
const ALGORITHM = "aes-256-gcm";
8+
const HASH_FUNCTION = "sha512";
9+
10+
export function encrypt({
11+
plaintext,
12+
encryptionSecret,
13+
}: {
14+
plaintext: string;
15+
encryptionSecret: string;
16+
}) {
17+
const salt = crypto.randomBytes(16);
18+
const iv = crypto.randomBytes(12);
19+
const key = crypto.pbkdf2Sync(
20+
encryptionSecret,
21+
salt,
22+
ITERATIONS,
23+
KEY_LEN,
24+
HASH_FUNCTION,
25+
);
26+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
27+
const encrypted = Buffer.concat([
28+
cipher.update(`${VALID_PREFIX}${plaintext}`, "utf8"),
29+
cipher.final(),
30+
]);
31+
const tag = cipher.getAuthTag();
32+
return Buffer.concat([salt, iv, tag, encrypted]).toString("hex");
33+
}
34+
35+
export function decrypt({
36+
cipherText,
37+
encryptionSecret,
38+
}: {
39+
cipherText: string;
40+
encryptionSecret: string;
41+
}): string {
42+
const data = Buffer.from(cipherText, "hex");
43+
const salt = data.subarray(0, 16);
44+
const iv = data.subarray(16, 28);
45+
const tag = data.subarray(28, 44);
46+
const encryptedText = data.subarray(44);
47+
48+
const key = pbkdf2Sync(
49+
encryptionSecret,
50+
salt,
51+
ITERATIONS,
52+
KEY_LEN,
53+
HASH_FUNCTION,
54+
);
55+
let decipher, decryptedBuffer;
56+
try {
57+
decipher = createDecipheriv(ALGORITHM, key, iv);
58+
decipher.setAuthTag(tag);
59+
decryptedBuffer = Buffer.concat([
60+
decipher.update(encryptedText),
61+
decipher.final(),
62+
]);
63+
} catch (e) {
64+
throw new DecryptionError({
65+
message:
66+
"Could not decrypt data (check that the encryption secret is correct).",
67+
});
68+
}
69+
70+
const candidate = decryptedBuffer.toString("utf8");
71+
if (candidate.substring(0, VALID_PREFIX.length) !== VALID_PREFIX) {
72+
throw new DecryptionError({
73+
message: "Encrypted data is corrupted.",
74+
});
75+
}
76+
return candidate.substring(VALID_PREFIX.length, candidate.length);
77+
}

src/api/functions/redisCache.ts

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,65 @@
1-
import { type Redis } from "ioredis";
1+
import { DecryptionError } from "common/errors/index.js";
2+
import type RedisModule from "ioredis";
3+
import { z } from "zod";
4+
import { decrypt, encrypt } from "./encryption.js";
25

3-
export async function getRedisKey<T>({
6+
export type GetFromCacheInput = {
7+
redisClient: RedisModule.default;
8+
key: string;
9+
encryptionSecret?: string;
10+
};
11+
12+
export type SetInCacheInput = {
13+
redisClient: RedisModule.default;
14+
key: string;
15+
data: string;
16+
expiresIn?: number;
17+
encryptionSecret?: string;
18+
};
19+
20+
const redisEntrySchema = z.object({
21+
isEncrypted: z.boolean(),
22+
data: z.string(),
23+
});
24+
25+
export async function getKey<T extends object>({
426
redisClient,
527
key,
6-
parseJson = false,
7-
}: {
8-
redisClient: Redis;
9-
key: string;
10-
parseJson?: boolean;
11-
}) {
12-
const resp = await redisClient.get(key);
13-
if (!resp) {
28+
encryptionSecret,
29+
}: GetFromCacheInput): Promise<T | null> {
30+
const data = await redisClient.get(key);
31+
if (!data) {
1432
return null;
1533
}
16-
return parseJson ? (JSON.parse(resp) as T) : (resp as string);
34+
const decoded = await redisEntrySchema.parseAsync(JSON.parse(data));
35+
if (!decoded.isEncrypted) {
36+
return JSON.parse(decoded.data) as T;
37+
}
38+
if (!encryptionSecret) {
39+
throw new DecryptionError({
40+
message: "Encrypted data found but no decryption key provided.",
41+
});
42+
}
43+
const decryptedData = decrypt({ cipherText: decoded.data, encryptionSecret });
44+
return JSON.parse(decryptedData) as T;
1745
}
1846

19-
export async function setRedisKey({
47+
export async function setKey({
2048
redisClient,
2149
key,
22-
value,
23-
expiresSec,
24-
}: {
25-
redisClient: Redis;
26-
key: string;
27-
value: string;
28-
expiresSec?: number;
29-
}) {
30-
if (expiresSec) {
31-
return await redisClient.set(key, value, "EX", expiresSec);
32-
}
33-
return await redisClient.set(key, value);
50+
encryptionSecret,
51+
data,
52+
expiresIn,
53+
}: SetInCacheInput) {
54+
const realData = encryptionSecret
55+
? encrypt({ plaintext: data, encryptionSecret })
56+
: data;
57+
const redisPayload: z.infer<typeof redisEntrySchema> = {
58+
isEncrypted: !!encryptionSecret,
59+
data: realData,
60+
};
61+
const strRedisPayload = JSON.stringify(redisPayload);
62+
return expiresIn
63+
? await redisClient.set(key, strRedisPayload, "EX", expiresIn)
64+
: await redisClient.set(key, strRedisPayload);
3465
}

src/api/plugins/auth.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import {
1313
UnauthenticatedError,
1414
UnauthorizedError,
1515
} from "../../common/errors/index.js";
16-
import { SecretConfig, SecretTesting } from "../../common/config.js";
1716
import {
18-
AUTH_DECISION_CACHE_SECONDS,
19-
getGroupRoles,
20-
getUserRoles,
21-
} from "../functions/authorization.js";
17+
SecretConfig,
18+
SecretTesting,
19+
GENERIC_CACHE_SECONDS,
20+
} from "../../common/config.js";
21+
import { getGroupRoles, getUserRoles } from "../functions/authorization.js";
2222
import { getApiKeyData, getApiKeyParts } from "api/functions/apiKey.js";
23+
import { getKey, setKey } from "api/functions/redisCache.js";
2324

2425
export function intersection<T>(setA: Set<T>, setB: Set<T>): Set<T> {
2526
const _intersection = new Set<T>();
@@ -155,6 +156,8 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
155156
validRoles: AppRoles[],
156157
disableApiKeyAuth: boolean,
157158
): Promise<Set<AppRoles>> => {
159+
const { redisClient } = fastify;
160+
const encryptionSecret = fastify.secretConfig.encryption_key;
158161
const startTime = new Date().getTime();
159162
try {
160163
if (!disableApiKeyAuth) {
@@ -225,11 +228,13 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
225228
header: decoded?.header,
226229
audience: `api://${AadClientId}`,
227230
};
228-
const cachedJwksSigningKey = await fastify.redisClient.get(
229-
`jwksKey:${header.kid}`,
230-
);
231+
const { redisClient } = fastify;
232+
const cachedJwksSigningKey = await getKey<{ key: string }>({
233+
redisClient,
234+
key: `jwksKey:${header.kid}`,
235+
});
231236
if (cachedJwksSigningKey) {
232-
signingKey = cachedJwksSigningKey;
237+
signingKey = cachedJwksSigningKey.key;
233238
request.log.debug("Got JWKS signing key from cache.");
234239
} else {
235240
const client = jwksClient({
@@ -239,12 +244,12 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
239244
signingKey = (
240245
await client.getSigningKey(header.kid)
241246
).getPublicKey();
242-
await fastify.redisClient.set(
243-
`jwksKey:${header.kid}`,
244-
signingKey,
245-
"EX",
246-
JWKS_CACHE_SECONDS,
247-
);
247+
await setKey({
248+
redisClient,
249+
key: `jwksKey:${header.kid}`,
250+
data: JSON.stringify({ key: signingKey }),
251+
expiresIn: JWKS_CACHE_SECONDS,
252+
});
248253
request.log.debug("Got JWKS signing key from server.");
249254
}
250255
}
@@ -263,11 +268,12 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
263268
verifiedTokenData.upn?.replace("acm.illinois.edu", "illinois.edu") ||
264269
verifiedTokenData.sub;
265270
const expectedRoles = new Set(validRoles);
266-
const cachedRoles = await fastify.redisClient.get(
267-
`authCache:${request.username}:roles`,
268-
);
271+
const cachedRoles = await getKey<string[]>({
272+
key: `authCache:${request.username}:roles`,
273+
redisClient,
274+
});
269275
if (cachedRoles) {
270-
request.userRoles = new Set(JSON.parse(cachedRoles));
276+
request.userRoles = new Set(cachedRoles as AppRoles[]);
271277
request.log.debug("Retrieved user roles from cache.");
272278
} else {
273279
const userRoles = new Set([] as AppRoles[]);
@@ -317,12 +323,12 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
317323
}
318324
}
319325
request.userRoles = userRoles;
320-
fastify.redisClient.set(
321-
`authCache:${request.username}:roles`,
322-
JSON.stringify([...userRoles]),
323-
"EX",
324-
AUTH_DECISION_CACHE_SECONDS,
325-
);
326+
setKey({
327+
key: `authCache:${request.username}:roles`,
328+
data: JSON.stringify([...userRoles]),
329+
redisClient,
330+
expiresIn: GENERIC_CACHE_SECONDS,
331+
});
326332
request.log.debug("Retrieved user roles from database.");
327333
}
328334
if (

src/api/routes/iam.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,18 @@ import {
3232
EntraGroupActions,
3333
entraProfilePatchRequest,
3434
} from "../../common/types/iam.js";
35-
import {
36-
AUTH_DECISION_CACHE_SECONDS,
37-
getGroupRoles,
38-
} from "../functions/authorization.js";
35+
import { getGroupRoles } from "../functions/authorization.js";
3936
import { getRoleCredentials } from "api/functions/sts.js";
4037
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
4138
import { createAuditLogEntry } from "api/functions/auditLog.js";
4239
import { Modules } from "common/modules.js";
4340
import { groupId, withRoles, withTags } from "api/components/index.js";
4441
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
4542
import { z } from "zod";
46-
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
43+
import { AvailableSQSFunctions } from "common/types/sqsMessage.js";
4744
import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs";
48-
import { v4 as uuidv4 } from "uuid";
4945
import { randomUUID } from "crypto";
50-
import { getRedisKey, setRedisKey } from "api/functions/redisCache.js";
46+
import { getKey, setKey } from "api/functions/redisCache.js";
5147

5248
const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
5349
const getAuthorizedClients = async () => {
@@ -186,7 +182,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
186182
fastify.nodeCache.set(
187183
`grouproles-${groupId}`,
188184
request.body.roles,
189-
AUTH_DECISION_CACHE_SECONDS,
185+
GENERIC_CACHE_SECONDS,
190186
);
191187
} catch (e: unknown) {
192188
fastify.nodeCache.del(`grouproles-${groupId}`);
@@ -584,9 +580,9 @@ No action is required from you at this time.
584580
);
585581
const { redisClient } = fastify;
586582
const key = `entra_manageable_groups_${fastify.environmentConfig.EntraServicePrincipalId}`;
587-
const redisResponse = await getRedisKey<
588-
{ displayName: string; id: string }[]
589-
>({ redisClient, key, parseJson: true });
583+
const redisResponse = await getKey<{ displayName: string; id: string }[]>(
584+
{ redisClient, key },
585+
);
590586
if (redisResponse) {
591587
request.log.debug("Got manageable groups from Redis cache.");
592588
return reply.status(200).send(redisResponse);
@@ -605,11 +601,11 @@ No action is required from you at this time.
605601
request.log.debug(
606602
"Got manageable groups from Entra ID, setting to cache.",
607603
);
608-
await setRedisKey({
604+
await setKey({
609605
redisClient,
610606
key,
611-
value: JSON.stringify(freshData),
612-
expiresSec: GENERIC_CACHE_SECONDS,
607+
data: JSON.stringify(freshData),
608+
expiresIn: GENERIC_CACHE_SECONDS,
613609
});
614610
return reply.status(200).send(freshData);
615611
},

src/common/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ type ValueOrArray<T> = T | ArrayOfValueOrArray<T>;
88

99
type AzureRoleMapping = Record<string, readonly AppRoles[]>;
1010

11-
export const GENERIC_CACHE_SECONDS = 120;
11+
export const GENERIC_CACHE_SECONDS = 300;
1212

1313
export type ConfigType = {
1414
UserFacingUrl: string;
@@ -159,6 +159,7 @@ export type SecretConfig = {
159159
stripe_endpoint_secret: string;
160160
stripe_links_endpoint_secret: string;
161161
redis_url: string;
162+
encryption_key: string;
162163
};
163164

164165
export type SecretTesting = {

0 commit comments

Comments
 (0)