Skip to content

Commit 4edad13

Browse files
authored
Merge branch 'main' into siglead-management
2 parents 3db099b + 36db592 commit 4edad13

26 files changed

+710
-247
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/discord.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ import moment from "moment-timezone";
1313

1414
import { FastifyBaseLogger } from "fastify";
1515
import { DiscordEventError } from "../../common/errors/index.js";
16-
import { getSecretValue } from "../plugins/auth.js";
17-
import { genericConfig, SecretConfig } from "../../common/config.js";
18-
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
16+
import { type SecretConfig } from "../../common/config.js";
1917

2018
// https://stackoverflow.com/a/3809435/5684541
2119
// https://calendar-buff.acmuiuc.pages.dev/calendar?id=dd7af73a-3df6-4e12-b228-0d2dac34fda7&date=2024-08-30
@@ -26,7 +24,7 @@ export type IUpdateDiscord = EventPostRequest & { id: string };
2624
const urlRegex = /https:\/\/[a-z0-9.-]+\/calendar\?id=([a-f0-9-]+)/;
2725

2826
export const updateDiscord = async (
29-
secretApiConfig: SecretConfig,
27+
config: { botToken: string; guildId: string },
3028
event: IUpdateDiscord,
3129
actor: string,
3230
isDelete: boolean = false,
@@ -36,7 +34,7 @@ export const updateDiscord = async (
3634
let payload: GuildScheduledEventCreateOptions | null = null;
3735
client.once(Events.ClientReady, async (readyClient: Client<true>) => {
3836
logger.debug(`Logged in as ${readyClient.user.tag}`);
39-
const guildID = secretApiConfig.discord_guild_id;
37+
const guildID = config.guildId;
4038
const guild = await client.guilds.fetch(guildID?.toString() || "");
4139
const discordEvents = await guild.scheduledEvents.fetch();
4240
const snowflakeMeetingLookup = discordEvents.reduce(
@@ -110,7 +108,7 @@ export const updateDiscord = async (
110108
return payload;
111109
});
112110

113-
const token = secretApiConfig.discord_bot_token;
111+
const token = config.botToken;
114112

115113
if (!token) {
116114
logger.error("No Discord bot token found in secrets!");

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: 84 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
export 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}`,
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) {
@@ -508,6 +527,52 @@ export async function isUserInGroup(
508527
}
509528
}
510529

530+
/**
531+
* Fetches the ID and display name of groups owned by a specific service principal.
532+
* @param token - An Entra ID token authorized to read service principal information.
533+
*/
534+
export async function getServicePrincipalOwnedGroups(
535+
token: string,
536+
servicePrincipal: string,
537+
): Promise<{ id: string; displayName: string }[]> {
538+
try {
539+
// Selects only group objects and retrieves just their id and displayName
540+
const url = `https://graph.microsoft.com/v1.0/servicePrincipals/${servicePrincipal}/ownedObjects/microsoft.graph.group?$select=id,displayName`;
541+
542+
const response = await fetch(url, {
543+
method: "GET",
544+
headers: {
545+
Authorization: `Bearer ${token}`,
546+
"Content-Type": "application/json",
547+
},
548+
});
549+
550+
if (response.ok) {
551+
const data = (await response.json()) as {
552+
value: { id: string; displayName: string }[];
553+
};
554+
return data.value;
555+
}
556+
557+
const errorData = (await response.json()) as {
558+
error?: { message?: string };
559+
};
560+
throw new EntraFetchError({
561+
message: errorData?.error?.message ?? response.statusText,
562+
email: `sp:${servicePrincipal}`,
563+
});
564+
} catch (error) {
565+
if (error instanceof BaseError) {
566+
throw error;
567+
}
568+
const message = error instanceof Error ? error.message : String(error);
569+
throw new EntraFetchError({
570+
message,
571+
email: `sp:${servicePrincipal}`,
572+
});
573+
}
574+
}
575+
511576
export async function listGroupIDsByEmail(
512577
token: string,
513578
email: string,

src/api/functions/redisCache.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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";
12+
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>({
34+
redisClient,
35+
key,
36+
encryptionSecret,
37+
logger,
38+
}: GetFromCacheInput): Promise<T | null> {
39+
const data = await redisClient.get(key);
40+
if (!data) {
41+
return null;
42+
}
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+
}
72+
}
73+
74+
export async function setKey({
75+
redisClient,
76+
key,
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);
92+
}

0 commit comments

Comments
 (0)