diff --git a/src/api/functions/apiKey.ts b/src/api/functions/apiKey.ts index cbff5536..87c7d17a 100644 --- a/src/api/functions/apiKey.ts +++ b/src/api/functions/apiKey.ts @@ -7,8 +7,7 @@ import { DynamoDBClient, GetItemCommand, } from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "common/config.js"; -import { AUTH_DECISION_CACHE_SECONDS as API_KEY_DATA_CACHE_SECONDS } from "./authorization.js"; +import { genericConfig, GENERIC_CACHE_SECONDS } from "common/config.js"; import { unmarshall } from "@aws-sdk/util-dynamodb"; import { ApiKeyMaskedEntry, DecomposedApiKey } from "common/types/apiKey.js"; import { AvailableAuthorizationPolicy } from "common/policies/definition.js"; @@ -105,7 +104,7 @@ export const getApiKeyData = async ({ }); const result = await dynamoClient.send(getCommand); if (!result || !result.Item) { - nodeCache.set(cacheKey, null, API_KEY_DATA_CACHE_SECONDS); + nodeCache.set(cacheKey, null, GENERIC_CACHE_SECONDS); return undefined; } const unmarshalled = unmarshall(result.Item) as ApiKeyDynamoEntry; @@ -124,7 +123,7 @@ export const getApiKeyData = async ({ if (!("keyHash" in unmarshalled)) { return undefined; // bad data, don't cache it } - let cacheTime = API_KEY_DATA_CACHE_SECONDS; + let cacheTime = GENERIC_CACHE_SECONDS; if (unmarshalled.expiresAt) { const currentEpoch = Date.now(); cacheTime = min(cacheTime, unmarshalled.expiresAt - currentEpoch); diff --git a/src/api/functions/authorization.ts b/src/api/functions/authorization.ts index 06b5e5c6..0d05daa8 100644 --- a/src/api/functions/authorization.ts +++ b/src/api/functions/authorization.ts @@ -3,9 +3,6 @@ import { unmarshall } from "@aws-sdk/util-dynamodb"; import { genericConfig } from "../../common/config.js"; import { DatabaseFetchError } from "../../common/errors/index.js"; import { allAppRoles, AppRoles } from "../../common/roles.js"; -import { FastifyInstance } from "fastify"; - -export const AUTH_DECISION_CACHE_SECONDS = 180; export async function getUserRoles( dynamoClient: DynamoDBClient, diff --git a/src/api/functions/encryption.ts b/src/api/functions/encryption.ts new file mode 100644 index 00000000..894094bb --- /dev/null +++ b/src/api/functions/encryption.ts @@ -0,0 +1,81 @@ +import { DecryptionError } from "common/errors/index.js"; +import crypto, { createDecipheriv, pbkdf2Sync } from "node:crypto"; + +const VALID_PREFIX = "VALID:"; +const ITERATIONS = 100000; +const KEY_LEN = 32; +const ALGORITHM = "aes-256-gcm"; +const HASH_FUNCTION = "sha512"; + +export const INVALID_DECRYPTION_MESSAGE = + "Could not decrypt data (check that the encryption secret is correct)."; + +export const CORRUPTED_DATA_MESSAGE = "Encrypted data is corrupted."; + +export function encrypt({ + plaintext, + encryptionSecret, +}: { + plaintext: string; + encryptionSecret: string; +}) { + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); + const key = crypto.pbkdf2Sync( + encryptionSecret, + salt, + ITERATIONS, + KEY_LEN, + HASH_FUNCTION, + ); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(`${VALID_PREFIX}${plaintext}`, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return Buffer.concat([salt, iv, tag, encrypted]).toString("hex"); +} + +export function decrypt({ + cipherText, + encryptionSecret, +}: { + cipherText: string; + encryptionSecret: string; +}): string { + const data = Buffer.from(cipherText, "hex"); + const salt = data.subarray(0, 16); + const iv = data.subarray(16, 28); + const tag = data.subarray(28, 44); + const encryptedText = data.subarray(44); + + const key = pbkdf2Sync( + encryptionSecret, + salt, + ITERATIONS, + KEY_LEN, + HASH_FUNCTION, + ); + let decipher, decryptedBuffer; + try { + decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + decryptedBuffer = Buffer.concat([ + decipher.update(encryptedText), + decipher.final(), + ]); + } catch (e) { + throw new DecryptionError({ + message: INVALID_DECRYPTION_MESSAGE, + }); + } + + const candidate = decryptedBuffer.toString("utf8"); + if (candidate.substring(0, VALID_PREFIX.length) !== VALID_PREFIX) { + throw new DecryptionError({ + message: CORRUPTED_DATA_MESSAGE, + }); + } + return candidate.substring(VALID_PREFIX.length, candidate.length); +} diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index eb3a10a9..dd0d045f 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -9,6 +9,7 @@ import { } from "../../common/config.js"; import { BaseError, + DecryptionError, EntraFetchError, EntraGroupError, EntraGroupsFromEmailError, @@ -29,18 +30,32 @@ import { UserProfileData } from "common/types/msGraphApi.js"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { checkPaidMembershipFromTable } from "./membership.js"; +import { getKey, setKey } from "./redisCache.js"; +import RedisClient from "ioredis"; +import type pino from "pino"; +import { type FastifyBaseLogger } from "fastify"; function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed return groupIdPattern.test(groupId); } -export async function getEntraIdToken( - clients: { smClient: SecretsManagerClient; dynamoClient: DynamoDBClient }, - clientId: string, - scopes: string[] = ["https://graph.microsoft.com/.default"], - secretName?: string, -) { +type GetEntraIdTokenInput = { + clients: { smClient: SecretsManagerClient; redisClient: RedisClient.default }; + encryptionSecret: string; + clientId: string; + scopes?: string[]; + secretName?: string; + logger: pino.Logger | FastifyBaseLogger; +}; +export async function getEntraIdToken({ + clients, + encryptionSecret, + clientId, + scopes = ["https://graph.microsoft.com/.default"], + secretName, + logger, +}: GetEntraIdTokenInput) { const localSecretName = secretName || genericConfig.EntraSecretName; const secretApiConfig = (await getSecretValue(clients.smClient, localSecretName)) || {}; @@ -56,12 +71,15 @@ export async function getEntraIdToken( secretApiConfig.entra_id_private_key as string, "base64", ).toString("utf8"); - const cachedToken = await getItemFromCache( - clients.dynamoClient, - `entra_id_access_token_${localSecretName}_${clientId}`, - ); - if (cachedToken) { - return cachedToken.token as string; + const cacheKey = `entra_id_access_token_${localSecretName}_${clientId}`; + const cachedTokenObject = await getKey<{ token: string }>({ + redisClient: clients.redisClient, + key: cacheKey, + encryptionSecret, + logger, + }); + if (cachedTokenObject) { + return cachedTokenObject.token; } const config = { auth: { @@ -85,13 +103,14 @@ export async function getEntraIdToken( }); } date.setTime(date.getTime() - 30000); - if (result?.accessToken) { - await insertItemIntoCache( - clients.dynamoClient, - `entra_id_access_token_${localSecretName}`, - { token: result?.accessToken }, - date, - ); + if (result?.accessToken && result?.expiresOn) { + await setKey({ + redisClient: clients.redisClient, + key: cacheKey, + data: JSON.stringify({ token: result.accessToken }), + expiresIn: result.expiresOn.getTime() - new Date().getTime() - 3600, + encryptionSecret, + }); } return result?.accessToken ?? null; } catch (error) { diff --git a/src/api/functions/redisCache.ts b/src/api/functions/redisCache.ts index bf97786d..bfab2f4d 100644 --- a/src/api/functions/redisCache.ts +++ b/src/api/functions/redisCache.ts @@ -1,34 +1,92 @@ -import { type Redis } from "ioredis"; +import { DecryptionError } from "common/errors/index.js"; +import type RedisModule from "ioredis"; +import { z } from "zod"; +import { + CORRUPTED_DATA_MESSAGE, + decrypt, + encrypt, + INVALID_DECRYPTION_MESSAGE, +} from "./encryption.js"; +import type pino from "pino"; +import { type FastifyBaseLogger } from "fastify"; -export async function getRedisKey({ +export type GetFromCacheInput = { + redisClient: RedisModule.default; + key: string; + encryptionSecret?: string; + logger: pino.Logger | FastifyBaseLogger; +}; + +export type SetInCacheInput = { + redisClient: RedisModule.default; + key: string; + data: string; + expiresIn?: number; + encryptionSecret?: string; +}; + +const redisEntrySchema = z.object({ + isEncrypted: z.boolean(), + data: z.string(), +}); + +export async function getKey({ redisClient, key, - parseJson = false, -}: { - redisClient: Redis; - key: string; - parseJson?: boolean; -}) { - const resp = await redisClient.get(key); - if (!resp) { + encryptionSecret, + logger, +}: GetFromCacheInput): Promise { + const data = await redisClient.get(key); + if (!data) { return null; } - return parseJson ? (JSON.parse(resp) as T) : (resp as string); + const decoded = await redisEntrySchema.parseAsync(JSON.parse(data)); + if (!decoded.isEncrypted) { + return JSON.parse(decoded.data) as T; + } + if (!encryptionSecret) { + throw new DecryptionError({ + message: "Encrypted data found but no decryption key provided.", + }); + } + try { + const decryptedData = decrypt({ + cipherText: decoded.data, + encryptionSecret, + }); + return JSON.parse(decryptedData) as T; + } catch (e) { + if ( + e instanceof DecryptionError && + (e.message === INVALID_DECRYPTION_MESSAGE || + e.message === CORRUPTED_DATA_MESSAGE) + ) { + logger.info( + `Invalid decryption, deleting old Redis key and continuing...`, + ); + await redisClient.del(key); + return null; + } + throw e; + } } -export async function setRedisKey({ +export async function setKey({ redisClient, key, - value, - expiresSec, -}: { - redisClient: Redis; - key: string; - value: string; - expiresSec?: number; -}) { - if (expiresSec) { - return await redisClient.set(key, value, "EX", expiresSec); - } - return await redisClient.set(key, value); + encryptionSecret, + data, + expiresIn, +}: SetInCacheInput) { + const realData = encryptionSecret + ? encrypt({ plaintext: data, encryptionSecret }) + : data; + const redisPayload: z.infer = { + isEncrypted: !!encryptionSecret, + data: realData, + }; + const strRedisPayload = JSON.stringify(redisPayload); + return expiresIn + ? await redisClient.set(key, strRedisPayload, "EX", expiresIn) + : await redisClient.set(key, strRedisPayload); } diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index 157ebe07..f045484f 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -13,13 +13,14 @@ import { UnauthenticatedError, UnauthorizedError, } from "../../common/errors/index.js"; -import { SecretConfig, SecretTesting } from "../../common/config.js"; import { - AUTH_DECISION_CACHE_SECONDS, - getGroupRoles, - getUserRoles, -} from "../functions/authorization.js"; + SecretConfig, + SecretTesting, + GENERIC_CACHE_SECONDS, +} from "../../common/config.js"; +import { getGroupRoles, getUserRoles } from "../functions/authorization.js"; import { getApiKeyData, getApiKeyParts } from "api/functions/apiKey.js"; +import { getKey, setKey } from "api/functions/redisCache.js"; export function intersection(setA: Set, setB: Set): Set { const _intersection = new Set(); @@ -155,6 +156,8 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { validRoles: AppRoles[], disableApiKeyAuth: boolean, ): Promise> => { + const { redisClient } = fastify; + const encryptionSecret = fastify.secretConfig.encryption_key; const startTime = new Date().getTime(); try { if (!disableApiKeyAuth) { @@ -225,11 +228,14 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { header: decoded?.header, audience: `api://${AadClientId}`, }; - const cachedJwksSigningKey = await fastify.redisClient.get( - `jwksKey:${header.kid}`, - ); + const { redisClient } = fastify; + const cachedJwksSigningKey = await getKey<{ key: string }>({ + redisClient, + key: `jwksKey:${header.kid}`, + logger: request.log, + }); if (cachedJwksSigningKey) { - signingKey = cachedJwksSigningKey; + signingKey = cachedJwksSigningKey.key; request.log.debug("Got JWKS signing key from cache."); } else { const client = jwksClient({ @@ -239,12 +245,12 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { signingKey = ( await client.getSigningKey(header.kid) ).getPublicKey(); - await fastify.redisClient.set( - `jwksKey:${header.kid}`, - signingKey, - "EX", - JWKS_CACHE_SECONDS, - ); + await setKey({ + redisClient, + key: `jwksKey:${header.kid}`, + data: JSON.stringify({ key: signingKey }), + expiresIn: JWKS_CACHE_SECONDS, + }); request.log.debug("Got JWKS signing key from server."); } } @@ -263,11 +269,13 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { verifiedTokenData.upn?.replace("acm.illinois.edu", "illinois.edu") || verifiedTokenData.sub; const expectedRoles = new Set(validRoles); - const cachedRoles = await fastify.redisClient.get( - `authCache:${request.username}:roles`, - ); + const cachedRoles = await getKey({ + key: `authCache:${request.username}:roles`, + redisClient, + logger: request.log, + }); if (cachedRoles) { - request.userRoles = new Set(JSON.parse(cachedRoles)); + request.userRoles = new Set(cachedRoles as AppRoles[]); request.log.debug("Retrieved user roles from cache."); } else { const userRoles = new Set([] as AppRoles[]); @@ -317,12 +325,12 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { } } request.userRoles = userRoles; - fastify.redisClient.set( - `authCache:${request.username}:roles`, - JSON.stringify([...userRoles]), - "EX", - AUTH_DECISION_CACHE_SECONDS, - ); + setKey({ + key: `authCache:${request.username}:roles`, + data: JSON.stringify([...userRoles]), + redisClient, + expiresIn: GENERIC_CACHE_SECONDS, + }); request.log.debug("Retrieved user roles from database."); } if ( diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 2b8247fe..75734974 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -32,10 +32,7 @@ import { EntraGroupActions, entraProfilePatchRequest, } from "../../common/types/iam.js"; -import { - AUTH_DECISION_CACHE_SECONDS, - getGroupRoles, -} from "../functions/authorization.js"; +import { getGroupRoles } from "../functions/authorization.js"; import { getRoleCredentials } from "api/functions/sts.js"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { createAuditLogEntry } from "api/functions/auditLog.js"; @@ -43,11 +40,10 @@ import { Modules } from "common/modules.js"; import { groupId, withRoles, withTags } from "api/components/index.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; -import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; +import { AvailableSQSFunctions } from "common/types/sqsMessage.js"; import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs"; -import { v4 as uuidv4 } from "uuid"; import { randomUUID } from "crypto"; -import { getRedisKey, setRedisKey } from "api/functions/redisCache.js"; +import { getKey, setKey } from "api/functions/redisCache.js"; const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const getAuthorizedClients = async () => { @@ -65,6 +61,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { region: genericConfig.AwsRegion, credentials, }), + redisClient: fastify.redisClient, }; fastify.log.info( `Assumed Entra role ${roleArns.Entra} to get the Entra token.`, @@ -77,6 +74,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { return { smClient: fastify.secretsManagerClient, dynamoClient: fastify.dynamoClient, + redisClient: fastify.redisClient, }; }; fastify.withTypeProvider().patch( @@ -98,12 +96,13 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }); } const userOid = request.tokenPayload.oid; - const entraIdToken = await getEntraIdToken( - await getAuthorizedClients(), - fastify.environmentConfig.AadValidClientId, - undefined, - genericConfig.EntraSecretName, - ); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: fastify.secretConfig.encryption_key, + logger: request.log, + }); await patchUserProfile( entraIdToken, request.username, @@ -186,7 +185,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.nodeCache.set( `grouproles-${groupId}`, request.body.roles, - AUTH_DECISION_CACHE_SECONDS, + GENERIC_CACHE_SECONDS, ); } catch (e: unknown) { fastify.nodeCache.del(`grouproles-${groupId}`); @@ -217,10 +216,13 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }, async (request, reply) => { const emails = request.body.emails; - const entraIdToken = await getEntraIdToken( - await getAuthorizedClients(), - fastify.environmentConfig.AadValidClientId, - ); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: fastify.secretConfig.encryption_key, + logger: request.log, + }); if (!entraIdToken) { throw new InternalServerError({ message: "Could not get Entra ID token to perform task.", @@ -310,10 +312,13 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { group: groupId, }); } - const entraIdToken = await getEntraIdToken( - await getAuthorizedClients(), - fastify.environmentConfig.AadValidClientId, - ); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: fastify.secretConfig.encryption_key, + logger: request.log, + }); const groupMetadataPromise = getGroupMetadata(entraIdToken, groupId); const addResults = await Promise.allSettled( request.body.add.map((email) => @@ -554,12 +559,13 @@ No action is required from you at this time. group: groupId, }); } - const entraIdToken = await getEntraIdToken( - await getAuthorizedClients(), - fastify.environmentConfig.AadValidReadOnlyClientId, - undefined, - genericConfig.EntraReadOnlySecretName, - ); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: fastify.secretConfig.encryption_key, + logger: request.log, + }); const response = await listGroupMembers(entraIdToken, groupId); reply.status(200).send(response); }, @@ -576,17 +582,18 @@ No action is required from you at this time. onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { - const entraIdToken = await getEntraIdToken( - await getAuthorizedClients(), - fastify.environmentConfig.AadValidClientId, - undefined, - genericConfig.EntraSecretName, - ); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: fastify.secretConfig.encryption_key, + logger: request.log, + }); const { redisClient } = fastify; const key = `entra_manageable_groups_${fastify.environmentConfig.EntraServicePrincipalId}`; - const redisResponse = await getRedisKey< - { displayName: string; id: string }[] - >({ redisClient, key, parseJson: true }); + const redisResponse = await getKey<{ displayName: string; id: string }[]>( + { redisClient, key, logger: request.log }, + ); if (redisResponse) { request.log.debug("Got manageable groups from Redis cache."); return reply.status(200).send(redisResponse); @@ -605,11 +612,11 @@ No action is required from you at this time. request.log.debug( "Got manageable groups from Entra ID, setting to cache.", ); - await setRedisKey({ + await setKey({ redisClient, key, - value: JSON.stringify(freshData), - expiresSec: GENERIC_CACHE_SECONDS, + data: JSON.stringify(freshData), + expiresIn: GENERIC_CACHE_SECONDS, }); return reply.status(200).send(freshData); }, diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index b9cb3dc0..4e6bf45b 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -51,6 +51,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { region: genericConfig.AwsRegion, credentials, }), + redisClient: fastify.redisClient, }; fastify.log.info( `Assumed Entra role ${roleArns.Entra} to get the Entra token.`, @@ -63,6 +64,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { return { smClient: fastify.secretsManagerClient, dynamoClient: fastify.dynamoClient, + redisClient: fastify.redisClient, }; }; const limitedRoutes: FastifyPluginAsync = async (fastify) => { @@ -106,10 +108,13 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { message: `${netId} is already a paid member!`, }); } - const entraIdToken = await getEntraIdToken( - await getAuthorizedClients(), - fastify.environmentConfig.AadValidClientId, - ); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: fastify.secretConfig.encryption_key, + logger: request.log, + }); const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId; const isAadMember = await checkPaidMembershipFromEntra( netId, @@ -224,10 +229,13 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { .header("X-ACM-Data-Source", "dynamo") .send({ netId, isPaidMember: true }); } - const entraIdToken = await getEntraIdToken( - await getAuthorizedClients(), - fastify.environmentConfig.AadValidClientId, - ); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: fastify.secretConfig.encryption_key, + logger: request.log, + }); const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId; const isAadMember = await checkPaidMembershipFromEntra( netId, diff --git a/src/api/sqs/handlers/emailMembershipPassHandler.ts b/src/api/sqs/handlers/emailMembershipPassHandler.ts index a48b044c..f08e4a64 100644 --- a/src/api/sqs/handlers/emailMembershipPassHandler.ts +++ b/src/api/sqs/handlers/emailMembershipPassHandler.ts @@ -4,12 +4,19 @@ import { runEnvironment, SQSHandlerFunction, } from "../index.js"; -import { environmentConfig, genericConfig } from "common/config.js"; -import { getAuthorizedClients } from "../utils.js"; +import { + environmentConfig, + genericConfig, + SecretConfig, +} from "common/config.js"; +import { getAuthorizedClients, getSecretConfig } from "../utils.js"; import { getEntraIdToken, getUserProfile } from "api/functions/entraId.js"; import { issueAppleWalletMembershipCard } from "api/functions/mobileWallet.js"; import { generateMembershipEmailCommand } from "api/functions/ses.js"; import { SESClient } from "@aws-sdk/client-ses"; +import RedisModule from "ioredis"; + +let secretConfig: SecretConfig; export const emailMembershipPassHandler: SQSHandlerFunction< AvailableSQSFunctions.EmailMembershipPass @@ -17,10 +24,17 @@ export const emailMembershipPassHandler: SQSHandlerFunction< const email = payload.email; const commonConfig = { region: genericConfig.AwsRegion }; const clients = await getAuthorizedClients(logger, commonConfig); - const entraIdToken = await getEntraIdToken( - clients, - currentEnvironmentConfig.AadValidClientId, - ); + if (!secretConfig) { + secretConfig = await getSecretConfig({ logger, commonConfig }); + } + const redisClient = new RedisModule.default(secretConfig.redis_url); + const entraIdToken = await getEntraIdToken({ + clients: { ...clients, redisClient }, + clientId: currentEnvironmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: secretConfig.encryption_key, + logger, + }); const userProfile = await getUserProfile(entraIdToken, email); const pkpass = await issueAppleWalletMembershipCard( clients, diff --git a/src/api/sqs/handlers/provisionNewMember.ts b/src/api/sqs/handlers/provisionNewMember.ts index 1e487cf3..d51464e4 100644 --- a/src/api/sqs/handlers/provisionNewMember.ts +++ b/src/api/sqs/handlers/provisionNewMember.ts @@ -5,13 +5,16 @@ import { getUserProfile, } from "../../../api/functions/entraId.js"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "../../../common/config.js"; +import { genericConfig, SecretConfig } from "../../../common/config.js"; import { setPaidMembership } from "api/functions/membership.js"; import { createAuditLogEntry } from "api/functions/auditLog.js"; import { Modules } from "common/modules.js"; -import { getAuthorizedClients } from "../utils.js"; +import { getAuthorizedClients, getSecretConfig } from "../utils.js"; import { emailMembershipPassHandler } from "./emailMembershipPassHandler.js"; +import Redis from "ioredis"; + +let secretConfig: SecretConfig; export const provisionNewMemberHandler: SQSHandlerFunction< AvailableSQSFunctions.ProvisionNewMember @@ -19,10 +22,17 @@ export const provisionNewMemberHandler: SQSHandlerFunction< const { email } = payload; const commonConfig = { region: genericConfig.AwsRegion }; const clients = await getAuthorizedClients(logger, commonConfig); - const entraToken = await getEntraIdToken( - clients, - currentEnvironmentConfig.AadValidClientId, - ); + if (!secretConfig) { + secretConfig = await getSecretConfig({ logger, commonConfig }); + } + const redisClient = new Redis.default(secretConfig.redis_url); + const entraToken = await getEntraIdToken({ + clients: { ...clients, redisClient }, + clientId: currentEnvironmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + encryptionSecret: secretConfig.encryption_key, + logger, + }); logger.info("Got authorized clients and Entra ID token."); const { updated } = await setPaidMembership({ netId: email.replace("@illinois.edu", ""), diff --git a/src/api/sqs/index.ts b/src/api/sqs/index.ts index 8f755edc..fbf00e6b 100644 --- a/src/api/sqs/index.ts +++ b/src/api/sqs/index.ts @@ -42,7 +42,6 @@ const handlers: SQSFunctionPayloadTypes = { }; export const runEnvironment = process.env.RunEnvironment as RunEnvironment; export const currentEnvironmentConfig = environmentConfig[runEnvironment]; - const restrictedQueues: Record = { "infra-core-api-sqs-sales": [AvailableSQSFunctions.SendSaleEmail], }; diff --git a/src/api/sqs/utils.ts b/src/api/sqs/utils.ts index 90d260bd..8d5a68d3 100644 --- a/src/api/sqs/utils.ts +++ b/src/api/sqs/utils.ts @@ -1,8 +1,10 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { getRoleCredentials } from "api/functions/sts.js"; -import { genericConfig, roleArns } from "common/config.js"; +import { genericConfig, roleArns, SecretConfig } from "common/config.js"; import pino from "pino"; +import { currentEnvironmentConfig } from "./index.js"; +import { getSecretValue } from "api/plugins/auth.js"; export const getAuthorizedClients = async ( logger: pino.Logger, @@ -32,3 +34,26 @@ export const getAuthorizedClients = async ( dynamoClient: new DynamoDBClient(commonConfig), }; }; + +export const getSecretConfig = async ({ + logger, + commonConfig: { region }, +}: { + logger: pino.Logger; + commonConfig: { region: string }; +}) => { + const smClient = new SecretsManagerClient({ region }); + logger.debug( + `Getting secrets: ${JSON.stringify(currentEnvironmentConfig.ConfigurationSecretIds)}.`, + ); + const allSecrets = await Promise.all( + currentEnvironmentConfig.ConfigurationSecretIds.map((secretName) => + getSecretValue(smClient, secretName), + ), + ); + const secretConfig = allSecrets.reduce( + (acc, currentSecret) => ({ ...acc, ...currentSecret }), + {}, + ) as SecretConfig; + return secretConfig; +}; diff --git a/src/common/config.ts b/src/common/config.ts index 859441cd..5eec05d5 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -8,7 +8,7 @@ type ValueOrArray = T | ArrayOfValueOrArray; type AzureRoleMapping = Record; -export const GENERIC_CACHE_SECONDS = 120; +export const GENERIC_CACHE_SECONDS = 300; export type ConfigType = { UserFacingUrl: string; @@ -159,6 +159,7 @@ export type SecretConfig = { stripe_endpoint_secret: string; stripe_links_endpoint_secret: string; redis_url: string; + encryption_key: string; }; export type SecretTesting = { diff --git a/src/common/errors/index.ts b/src/common/errors/index.ts index a2f7f58e..7e2f400e 100644 --- a/src/common/errors/index.ts +++ b/src/common/errors/index.ts @@ -214,25 +214,25 @@ export class EntraGroupError extends BaseError<"EntraGroupError"> { } export class EntraGroupsFromEmailError extends BaseError<"EntraGroupsFromEmailError"> { - email: string; - constructor({ - code, - message, - email - }: { - code?: number; - message?: string; - email: string - }) { - super({ - name: "EntraGroupsFromEmailError", - id: 309, //TODO: What should this be? - message: message || `Could not fetch the groups for user ${email}.`, - httpStatusCode: code || 500 - }); - this.email = email; - } - }; + email: string; + constructor({ + code, + message, + email + }: { + code?: number; + message?: string; + email: string + }) { + super({ + name: "EntraGroupsFromEmailError", + id: 309, //TODO: What should this be? + message: message || `Could not fetch the groups for user ${email}.`, + httpStatusCode: code || 500 + }); + this.email = email; + } +}; export class EntraFetchError extends BaseError<"EntraFetchError"> { email: string; @@ -259,3 +259,46 @@ export class EntraPatchError extends BaseError<"EntraPatchError"> { this.email = email; } } + +export abstract class InternalError extends Error { + public name: T; + + public id: number; + + public message: string; + + + constructor({ name, id, message }: Omit, "httpStatusCode">) { + super(message || name || "Error"); + this.name = name; + this.id = id; + this.message = message; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } + + toString() { + return `Error ${this.id} (${this.name}): ${this.message}\n\n${this.stack}`; + } +} + +export class EncryptionError extends InternalError<"EncryptionError"> { + constructor({ message }: { message?: string; }) { + super({ + name: "EncryptionError", + id: 601, + message: message || "Could not encrypt data.", + }); + } +} + +export class DecryptionError extends InternalError<"DecryptionError"> { + constructor({ message }: { message?: string; }) { + super({ + name: "DecryptionError", + id: 602, + message: message || "Could not decrypt data.", + }); + } +} diff --git a/tests/unit/functions/apiKey.test.ts b/tests/unit/functions/apiKey.test.ts index 1e8474d9..58744536 100644 --- a/tests/unit/functions/apiKey.test.ts +++ b/tests/unit/functions/apiKey.test.ts @@ -23,7 +23,7 @@ const countOccurrencesOfChar = (s: string, char: string): number => { return count; } -describe("Audit Log tests", () => { +describe("API key tests", () => { test("API key is successfully created and validated", async () => { const { apiKey, hashedKey, keyId } = await createApiKey(); expect(apiKey.slice(0, 8)).toEqual("acmuiuc_"); diff --git a/tests/unit/functions/encryption.test.ts b/tests/unit/functions/encryption.test.ts new file mode 100644 index 00000000..36d32b53 --- /dev/null +++ b/tests/unit/functions/encryption.test.ts @@ -0,0 +1,29 @@ +import { randomUUID } from "crypto"; +import { describe, expect, test } from "vitest"; +import { decrypt, encrypt } from "../../../src/api/functions/encryption.js"; +import { DecryptionError } from "../../../src/common/errors/index.js"; + +describe("Encryption tests", () => { + test("Encryption matches decryption", () => { + const plaintext = randomUUID(); + const encryptionSecret = randomUUID(); + const cipherText = encrypt({ plaintext, encryptionSecret }); + expect(cipherText === plaintext).toBe(false); + const decryptedText = decrypt({ cipherText, encryptionSecret }); + expect(decryptedText === plaintext).toBe(true); + }) + test("Invalid decryption key throws an error", () => { + const plaintext = randomUUID(); + const encryptionSecret = randomUUID(); + const cipherText = encrypt({ plaintext, encryptionSecret }); + expect(cipherText === plaintext).toBe(false); + expect(() => decrypt({ cipherText, encryptionSecret: randomUUID() })).toThrow(DecryptionError); + }) + test("Corrupted ciphertext throws an error", () => { + const plaintext = randomUUID(); + const encryptionSecret = randomUUID(); + const cipherText = `abc${encrypt({ plaintext, encryptionSecret })}`; + expect(cipherText === plaintext).toBe(false); + expect(() => decrypt({ cipherText, encryptionSecret: randomUUID() })).toThrow(DecryptionError); + }) +})