Skip to content

Commit 36db592

Browse files
authored
Use central redis cache function for IAM (#183)
Also enables optional encryption for redis cache content (ex: storing tokens)
1 parent 96fbf62 commit 36db592

File tree

16 files changed

+456
-158
lines changed

16 files changed

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

src/api/functions/entraId.ts

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "../../common/config.js";
1010
import {
1111
BaseError,
12+
DecryptionError,
1213
EntraFetchError,
1314
EntraGroupError,
1415
EntraGroupsFromEmailError,
@@ -29,18 +30,32 @@ import { UserProfileData } from "common/types/msGraphApi.js";
2930
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
3031
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
3132
import { checkPaidMembershipFromTable } from "./membership.js";
33+
import { getKey, setKey } from "./redisCache.js";
34+
import RedisClient from "ioredis";
35+
import type pino from "pino";
36+
import { type FastifyBaseLogger } from "fastify";
3237

3338
function validateGroupId(groupId: string): boolean {
3439
const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed
3540
return groupIdPattern.test(groupId);
3641
}
3742

38-
export async function getEntraIdToken(
39-
clients: { smClient: SecretsManagerClient; dynamoClient: DynamoDBClient },
40-
clientId: string,
41-
scopes: string[] = ["https://graph.microsoft.com/.default"],
42-
secretName?: string,
43-
) {
43+
type GetEntraIdTokenInput = {
44+
clients: { smClient: SecretsManagerClient; redisClient: RedisClient.default };
45+
encryptionSecret: string;
46+
clientId: string;
47+
scopes?: string[];
48+
secretName?: string;
49+
logger: pino.Logger | FastifyBaseLogger;
50+
};
51+
export async function getEntraIdToken({
52+
clients,
53+
encryptionSecret,
54+
clientId,
55+
scopes = ["https://graph.microsoft.com/.default"],
56+
secretName,
57+
logger,
58+
}: GetEntraIdTokenInput) {
4459
const localSecretName = secretName || genericConfig.EntraSecretName;
4560
const secretApiConfig =
4661
(await getSecretValue(clients.smClient, localSecretName)) || {};
@@ -56,12 +71,15 @@ export async function getEntraIdToken(
5671
secretApiConfig.entra_id_private_key as string,
5772
"base64",
5873
).toString("utf8");
59-
const cachedToken = await getItemFromCache(
60-
clients.dynamoClient,
61-
`entra_id_access_token_${localSecretName}_${clientId}`,
62-
);
63-
if (cachedToken) {
64-
return cachedToken.token as string;
74+
const cacheKey = `entra_id_access_token_${localSecretName}_${clientId}`;
75+
const cachedTokenObject = await getKey<{ token: string }>({
76+
redisClient: clients.redisClient,
77+
key: cacheKey,
78+
encryptionSecret,
79+
logger,
80+
});
81+
if (cachedTokenObject) {
82+
return cachedTokenObject.token;
6583
}
6684
const config = {
6785
auth: {
@@ -85,13 +103,14 @@ export async function getEntraIdToken(
85103
});
86104
}
87105
date.setTime(date.getTime() - 30000);
88-
if (result?.accessToken) {
89-
await insertItemIntoCache(
90-
clients.dynamoClient,
91-
`entra_id_access_token_${localSecretName}`,
92-
{ token: result?.accessToken },
93-
date,
94-
);
106+
if (result?.accessToken && result?.expiresOn) {
107+
await setKey({
108+
redisClient: clients.redisClient,
109+
key: cacheKey,
110+
data: JSON.stringify({ token: result.accessToken }),
111+
expiresIn: result.expiresOn.getTime() - new Date().getTime() - 3600,
112+
encryptionSecret,
113+
});
95114
}
96115
return result?.accessToken ?? null;
97116
} catch (error) {

src/api/functions/redisCache.ts

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,92 @@
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 {
5+
CORRUPTED_DATA_MESSAGE,
6+
decrypt,
7+
encrypt,
8+
INVALID_DECRYPTION_MESSAGE,
9+
} from "./encryption.js";
10+
import type pino from "pino";
11+
import { type FastifyBaseLogger } from "fastify";
212

3-
export async function getRedisKey<T>({
13+
export type GetFromCacheInput = {
14+
redisClient: RedisModule.default;
15+
key: string;
16+
encryptionSecret?: string;
17+
logger: pino.Logger | FastifyBaseLogger;
18+
};
19+
20+
export type SetInCacheInput = {
21+
redisClient: RedisModule.default;
22+
key: string;
23+
data: string;
24+
expiresIn?: number;
25+
encryptionSecret?: string;
26+
};
27+
28+
const redisEntrySchema = z.object({
29+
isEncrypted: z.boolean(),
30+
data: z.string(),
31+
});
32+
33+
export async function getKey<T extends object>({
434
redisClient,
535
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) {
36+
encryptionSecret,
37+
logger,
38+
}: GetFromCacheInput): Promise<T | null> {
39+
const data = await redisClient.get(key);
40+
if (!data) {
1441
return null;
1542
}
16-
return parseJson ? (JSON.parse(resp) as T) : (resp as string);
43+
const decoded = await redisEntrySchema.parseAsync(JSON.parse(data));
44+
if (!decoded.isEncrypted) {
45+
return JSON.parse(decoded.data) as T;
46+
}
47+
if (!encryptionSecret) {
48+
throw new DecryptionError({
49+
message: "Encrypted data found but no decryption key provided.",
50+
});
51+
}
52+
try {
53+
const decryptedData = decrypt({
54+
cipherText: decoded.data,
55+
encryptionSecret,
56+
});
57+
return JSON.parse(decryptedData) as T;
58+
} catch (e) {
59+
if (
60+
e instanceof DecryptionError &&
61+
(e.message === INVALID_DECRYPTION_MESSAGE ||
62+
e.message === CORRUPTED_DATA_MESSAGE)
63+
) {
64+
logger.info(
65+
`Invalid decryption, deleting old Redis key and continuing...`,
66+
);
67+
await redisClient.del(key);
68+
return null;
69+
}
70+
throw e;
71+
}
1772
}
1873

19-
export async function setRedisKey({
74+
export async function setKey({
2075
redisClient,
2176
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);
77+
encryptionSecret,
78+
data,
79+
expiresIn,
80+
}: SetInCacheInput) {
81+
const realData = encryptionSecret
82+
? encrypt({ plaintext: data, encryptionSecret })
83+
: data;
84+
const redisPayload: z.infer<typeof redisEntrySchema> = {
85+
isEncrypted: !!encryptionSecret,
86+
data: realData,
87+
};
88+
const strRedisPayload = JSON.stringify(redisPayload);
89+
return expiresIn
90+
? await redisClient.set(key, strRedisPayload, "EX", expiresIn)
91+
: await redisClient.set(key, strRedisPayload);
3492
}

0 commit comments

Comments
 (0)