diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index 67f262c9..d74f0676 100644 --- a/src/api/functions/stripe.ts +++ b/src/api/functions/stripe.ts @@ -18,6 +18,7 @@ export type StripeCheckoutSessionCreateParams = { stripeApiKey: string; items: { price: string; quantity: number }[]; initiator: string; + metadata?: Record; allowPromotionCodes: boolean; customFields?: Stripe.Checkout.SessionCreateParams.CustomField[]; }; @@ -77,6 +78,7 @@ export const createCheckoutSession = async ({ initiator, allowPromotionCodes, customFields, + metadata, }: StripeCheckoutSessionCreateParams): Promise => { const stripe = new Stripe(stripeApiKey); const payload: Stripe.Checkout.SessionCreateParams = { @@ -90,6 +92,7 @@ export const createCheckoutSession = async ({ mode: "payment", customer_email: customerEmail, metadata: { + ...(metadata || {}), initiator, }, allow_promotion_codes: allowPromotionCodes, diff --git a/src/api/functions/uin.ts b/src/api/functions/uin.ts new file mode 100644 index 00000000..8ef3ead7 --- /dev/null +++ b/src/api/functions/uin.ts @@ -0,0 +1,162 @@ +import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb"; +import { marshall } from "@aws-sdk/util-dynamodb"; +import { hash } from "argon2"; +import { genericConfig } from "common/config.js"; +import { + BaseError, + EntraFetchError, + InternalServerError, + UnauthenticatedError, + ValidationError, +} from "common/errors/index.js"; +import { type FastifyBaseLogger } from "fastify"; + +export type HashUinInputs = { + pepper: string; + uin: string; +}; + +export type GetUserUinInputs = { + uiucAccessToken: string; + pepper: string; +}; + +export const verifyUiucAccessToken = async ({ + accessToken, + logger, +}: { + accessToken: string | string[] | undefined; + logger: FastifyBaseLogger; +}) => { + if (!accessToken) { + throw new UnauthenticatedError({ + message: "Access token not found.", + }); + } + if (Array.isArray(accessToken)) { + throw new ValidationError({ + message: "Multiple tokens cannot be specified!", + }); + } + const url = + "https://graph.microsoft.com/v1.0/me?$select=userPrincipalName,givenName,surname,mail"; + + try { + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.status === 401) { + const errorText = await response.text(); + logger.warn(`Microsoft Graph API unauthenticated response: ${errorText}`); + throw new UnauthenticatedError({ + message: "Invalid or expired access token.", + }); + } + + if (!response.ok) { + const errorText = await response.text(); + logger.error( + `Microsoft Graph API error: ${response.status} - ${errorText}`, + ); + throw new InternalServerError({ + message: "Failed to contact Microsoft Graph API.", + }); + } + + const data = (await response.json()) as { + userPrincipalName: string; + givenName: string; + surname: string; + mail: string; + }; + logger.info("Access token successfully verified with Microsoft Graph API."); + return data; + } catch (error) { + if (error instanceof BaseError) { + throw error; + } else { + logger.error(error); + throw new InternalServerError({ + message: + "An unexpected error occurred during access token verification.", + }); + } + } +}; + +export async function getUinHash({ + pepper, + uin, +}: HashUinInputs): Promise { + return hash(uin, { salt: Buffer.from(pepper) }); +} + +export async function getHashedUserUin({ + uiucAccessToken, + pepper, +}: GetUserUinInputs): Promise { + const url = `https://graph.microsoft.com/v1.0/me?$select=${genericConfig.UinExtendedAttributeName}`; + try { + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${uiucAccessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new EntraFetchError({ + message: "Failed to get user's UIN.", + email: "", + }); + } + + const data = (await response.json()) as { + [genericConfig.UinExtendedAttributeName]: string; + }; + + return await getUinHash({ + pepper, + uin: data[genericConfig.UinExtendedAttributeName], + }); + } catch (error) { + if (error instanceof EntraFetchError) { + throw error; + } + + throw new EntraFetchError({ + message: "Failed to fetch user UIN.", + email: "", + }); + } +} + +type SaveHashedUserUin = GetUserUinInputs & { + dynamoClient: DynamoDBClient; + netId: string; +}; + +export async function saveHashedUserUin({ + uiucAccessToken, + pepper, + dynamoClient, + netId, +}: SaveHashedUserUin) { + const uinHash = await getHashedUserUin({ uiucAccessToken, pepper }); + await dynamoClient.send( + new PutItemCommand({ + TableName: genericConfig.UinHashTable, + Item: marshall({ + uinHash, + netId, + updatedAt: new Date().toISOString(), + }), + }), + ); +} diff --git a/src/api/index.ts b/src/api/index.ts index 74c1ba08..3ea230d0 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -61,6 +61,7 @@ import eventsPlugin from "./routes/events.js"; import mobileWalletV2Route from "./routes/v2/mobileWallet.js"; import membershipV2Plugin from "./routes/v2/membership.js"; import { docsHtml, securitySchemes } from "./docs.js"; +import syncIdentityPlugin from "./routes/syncIdentity.js"; /** END ROUTES */ export const instanceId = randomUUID(); @@ -356,6 +357,7 @@ Otherwise, email [infra@acm.illinois.edu](mailto:infra@acm.illinois.edu) for sup ); await app.register( async (api, _options) => { + api.register(syncIdentityPlugin, { prefix: "/syncIdentity" }); api.register(protectedRoute, { prefix: "/protected" }); api.register(eventsPlugin, { prefix: "/events" }); api.register(organizationsPlugin, { prefix: "/organizations" }); diff --git a/src/api/routes/clearSession.ts b/src/api/routes/clearSession.ts index e3908231..cf4b244d 100644 --- a/src/api/routes/clearSession.ts +++ b/src/api/routes/clearSession.ts @@ -23,7 +23,6 @@ const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => { onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { - reply.status(201).send(); const username = [request.username!]; const { redisClient } = fastify; const { log: logger } = fastify; @@ -46,6 +45,7 @@ const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => { expiresIn, }); } + return reply.status(201).send(); }, ); }; diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index c8ff2304..c7b6e1f8 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -413,12 +413,8 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { event.data.object.metadata.initiator === "purchase-membership" ) { const customerEmail = event.data.object.customer_email; - const firstName = event.data.object.custom_fields.filter( - (x) => x.key === "firstName", - )[0].text?.value; - const lastName = event.data.object.custom_fields.filter( - (x) => x.key === "lastName", - )[0].text?.value; + const firstName = event.data.object.metadata.givenName; + const lastName = event.data.object.metadata.surname; if (!customerEmail) { request.log.info("No customer email found."); return reply diff --git a/src/api/routes/syncIdentity.ts b/src/api/routes/syncIdentity.ts new file mode 100644 index 00000000..eba75ba4 --- /dev/null +++ b/src/api/routes/syncIdentity.ts @@ -0,0 +1,142 @@ +import { + checkPaidMembershipFromTable, + checkPaidMembershipFromRedis, +} from "api/functions/membership.js"; +import { FastifyPluginAsync } from "fastify"; +import { ValidationError } from "common/errors/index.js"; +import rateLimiter from "api/plugins/rateLimiter.js"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; +import * as z from "zod/v4"; +import { notAuthenticatedError, withTags } from "api/components/index.js"; +import { verifyUiucAccessToken, saveHashedUserUin } from "api/functions/uin.js"; +import { getRoleCredentials } from "api/functions/sts.js"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { genericConfig, roleArns } from "common/config.js"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + getEntraIdToken, + patchUserProfile, + resolveEmailToOid, +} from "api/functions/entraId.js"; + +const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => { + const getAuthorizedClients = async () => { + if (roleArns.Entra) { + fastify.log.info( + `Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`, + ); + const credentials = await getRoleCredentials(roleArns.Entra); + const clients = { + smClient: new SecretsManagerClient({ + region: genericConfig.AwsRegion, + credentials, + }), + dynamoClient: new DynamoDBClient({ + region: genericConfig.AwsRegion, + credentials, + }), + redisClient: fastify.redisClient, + }; + fastify.log.info( + `Assumed Entra role ${roleArns.Entra} to get the Entra token.`, + ); + return clients; + } + fastify.log.debug( + "Did not assume Entra role as no env variable was present", + ); + return { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, + redisClient: fastify.redisClient, + }; + }; + const limitedRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(rateLimiter, { + limit: 5, + duration: 30, + rateLimitIdentifier: "syncIdentityPlugin", + }); + fastify.withTypeProvider().post( + "/", + { + schema: withTags(["Generic"], { + headers: z.object({ + "x-uiuc-token": z.jwt().min(1).meta({ + description: + "An access token for the user in the UIUC Entra ID tenant.", + }), + }), + summary: + "Sync the Illinois NetID account with the ACM @ UIUC account.", + response: { + 201: { + description: "The user has been synced.", + content: { + "application/json": { + schema: z.null(), + }, + }, + }, + 403: notAuthenticatedError, + }, + }), + }, + async (request, reply) => { + const accessToken = request.headers["x-uiuc-token"]; + const verifiedData = await verifyUiucAccessToken({ + accessToken, + logger: request.log, + }); + const { userPrincipalName: upn, givenName, surname } = verifiedData; + const netId = upn.replace("@illinois.edu", ""); + if (netId.includes("@")) { + request.log.error( + `Found UPN ${upn} which cannot be turned into NetID via simple replacement.`, + ); + throw new ValidationError({ + message: "ID token could not be parsed.", + }); + } + await saveHashedUserUin({ + uiucAccessToken: accessToken, + pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER, + dynamoClient: fastify.dynamoClient, + netId, + }); + let isPaidMember = await checkPaidMembershipFromRedis( + netId, + fastify.redisClient, + request.log, + ); + if (isPaidMember === null) { + isPaidMember = await checkPaidMembershipFromTable( + netId, + fastify.dynamoClient, + ); + } + if (isPaidMember) { + const username = `${netId}@illinois.edu`; + request.log.info("User is paid member, syncing profile!"); + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + logger: request.log, + }); + const oid = await resolveEmailToOid(entraIdToken, username); + await patchUserProfile(entraIdToken, username, oid, { + displayName: `${givenName} ${surname}`, + givenName, + surname, + mail: username, + }); + } + return reply.status(201).send(); + }, + ); + }; + fastify.register(limitedRoutes); +}; + +export default syncIdentityPlugin; diff --git a/src/api/routes/v2/membership.ts b/src/api/routes/v2/membership.ts index f3ef8a72..f2c481b3 100644 --- a/src/api/routes/v2/membership.ts +++ b/src/api/routes/v2/membership.ts @@ -9,15 +9,7 @@ import { createCheckoutSession } from "api/functions/stripe.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import * as z from "zod/v4"; import { notAuthenticatedError, withTags } from "api/components/index.js"; -import { verifyUiucIdToken } from "./mobileWallet.js"; - -function splitOnce(s: string, on: string) { - const [first, ...rest] = s.split(on); - return [first, rest.length > 0 ? rest.join(on) : null]; -} -function trim(s: string) { - return (s || "").replace(/^\s+|\s+$/g, ""); -} +import { verifyUiucAccessToken, saveHashedUserUin } from "api/functions/uin.js"; const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { const limitedRoutes: FastifyPluginAsync = async (fastify) => { @@ -55,13 +47,12 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { }), }, async (request, reply) => { - const idToken = request.headers["x-uiuc-token"]; - const verifiedData = await verifyUiucIdToken({ - idToken, - redisClient: fastify.redisClient, + const accessToken = request.headers["x-uiuc-token"]; + const verifiedData = await verifyUiucAccessToken({ + accessToken, logger: request.log, }); - const { preferred_username: upn, email, name } = verifiedData; + const { userPrincipalName: upn, givenName, surname } = verifiedData; const netId = upn.replace("@illinois.edu", ""); if (netId.includes("@")) { request.log.error( @@ -71,6 +62,13 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { message: "ID token could not be parsed.", }); } + request.log.debug("Saving user hashed UIN!"); + const saveHashPromise = saveHashedUserUin({ + uiucAccessToken: accessToken, + pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER, + dynamoClient: fastify.dynamoClient, + netId, + }); let isPaidMember = await checkPaidMembershipFromRedis( netId, fastify.redisClient, @@ -82,21 +80,13 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { fastify.dynamoClient, ); } + await saveHashPromise; + request.log.debug("Saved user hashed UIN!"); if (isPaidMember) { throw new ValidationError({ message: `${upn} is already a paid member.`, }); } - let firstName: string = ""; - let lastName: string = ""; - if (!name.includes(",")) { - const splitted = splitOnce(name, " "); - firstName = splitted[0] || ""; - lastName = splitted[1] || ""; - } - firstName = trim(name.split(",")[1]); - lastName = name.split(",")[0]; - return reply.status(200).send( await createCheckoutSession({ successUrl: "https://acm.illinois.edu/paid", @@ -109,30 +99,10 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { quantity: 1, }, ], - customFields: [ - { - key: "firstName", - label: { - type: "custom", - custom: "Member First Name", - }, - type: "text", - text: { - default_value: firstName, - }, - }, - { - key: "lastName", - label: { - type: "custom", - custom: "Member Last Name", - }, - type: "text", - text: { - default_value: lastName, - }, - }, - ], + metadata: { + givenName, + surname, + }, initiator: "purchase-membership", allowPromotionCodes: true, }), diff --git a/src/api/routes/v2/mobileWallet.ts b/src/api/routes/v2/mobileWallet.ts index 7a6f11f0..1fb14cd3 100644 --- a/src/api/routes/v2/mobileWallet.ts +++ b/src/api/routes/v2/mobileWallet.ts @@ -11,88 +11,9 @@ import { import rateLimiter from "api/plugins/rateLimiter.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { notAuthenticatedError, withTags } from "api/components/index.js"; -import jwt, { Algorithm } from "jsonwebtoken"; -import { getJwksKey } from "api/plugins/auth.js"; import { issueAppleWalletMembershipCard } from "api/functions/mobileWallet.js"; -import { Redis } from "api/types.js"; import { Readable } from "stream"; - -const UIUC_TENANT_ID = "44467e6f-462c-4ea2-823f-7800de5434e3"; -const COULD_NOT_PARSE_MESSAGE = "ID token could not be parsed."; - -export const verifyUiucIdToken = async ({ - idToken, - redisClient, - logger, -}: { - idToken: string | string[] | undefined; - redisClient: Redis; - logger: FastifyBaseLogger; -}) => { - if (!idToken) { - throw new UnauthenticatedError({ - message: "ID token not found.", - }); - } - if (Array.isArray(idToken)) { - throw new ValidationError({ - message: "Multiple tokens cannot be specified!", - }); - } - const decoded = jwt.decode(idToken, { complete: true }); - if (!decoded) { - throw new UnauthenticatedError({ - message: COULD_NOT_PARSE_MESSAGE, - }); - } - const header = decoded?.header; - if (!header.kid) { - throw new UnauthenticatedError({ - message: COULD_NOT_PARSE_MESSAGE, - }); - } - const signingKey = await getJwksKey({ - redisClient, - kid: header.kid, - logger, - }); - const verifyOptions: jwt.VerifyOptions = { - algorithms: ["RS256" as Algorithm], - issuer: `https://login.microsoftonline.com/${UIUC_TENANT_ID}/v2.0`, - }; - let verifiedData; - try { - verifiedData = jwt.verify(idToken, signingKey, verifyOptions) as { - preferred_username?: string; - email?: string; - name?: string; - }; - } catch (e) { - if (e instanceof Error && e.name === "TokenExpiredError") { - throw new UnauthenticatedError({ - message: "Access token has expired.", - }); - } - if (e instanceof Error && e.name === "JsonWebTokenError") { - logger.error(e); - throw new UnauthenticatedError({ - message: COULD_NOT_PARSE_MESSAGE, - }); - } - throw e; - } - const { preferred_username: upn, email, name } = verifiedData; - if (!upn || !email || !name) { - throw new UnauthenticatedError({ - message: COULD_NOT_PARSE_MESSAGE, - }); - } - return verifiedData as { - preferred_username: string; - email: string; - name: string; - }; -}; +import { verifyUiucAccessToken } from "api/functions/uin.js"; const mobileWalletV2Route: FastifyPluginAsync = async (fastify, _options) => { fastify.register(rateLimiter, { @@ -127,13 +48,12 @@ const mobileWalletV2Route: FastifyPluginAsync = async (fastify, _options) => { }), }, async (request, reply) => { - const idToken = request.headers["x-uiuc-token"]; - const verifiedData = await verifyUiucIdToken({ - idToken, - redisClient: fastify.redisClient, + const accessToken = request.headers["x-uiuc-token"]; + const verifiedData = await verifyUiucAccessToken({ + accessToken, logger: request.log, }); - const { preferred_username: upn, name } = verifiedData; + const { userPrincipalName: upn, givenName, surname } = verifiedData; const netId = upn.replace("@illinois.edu", ""); if (netId.includes("@")) { request.log.error( @@ -168,7 +88,7 @@ const mobileWalletV2Route: FastifyPluginAsync = async (fastify, _options) => { upn, upn, request.log, - name, + `${givenName} ${surname}`, ); const myStream = new Readable({ read() { diff --git a/src/common/config.ts b/src/common/config.ts index 3d5da18d..ba80d64d 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -54,6 +54,9 @@ export type GenericConfigType = { ApiKeyTable: string; ConfigSecretName: string; TestingCredentialsSecret: string; + UinHashingSecret: string; + UinExtendedAttributeName: string; + UinHashTable: string; }; type EnvironmentConfigType = { @@ -92,6 +95,9 @@ const genericConfig: GenericConfigType = { ApiKeyTable: "infra-core-api-keys", ConfigSecretName: "infra-core-api-config", TestingCredentialsSecret: "infra-core-api-testing-credentials", + UinHashingSecret: "infra-core-api-uin-pepper", + UinExtendedAttributeName: "extension_a70c2e1556954056a6a8edfb1f42f556_uiucEduUIN", + UinHashTable: "infra-core-api-uin-mapping", } as const; const environmentConfig: EnvironmentConfigType = { @@ -104,7 +110,7 @@ const environmentConfig: EnvironmentConfigType = { /^https:\/\/(?:.*\.)?acmuiuc\.pages\.dev$/, /http:\/\/localhost:\d+$/, ], - ConfigurationSecretIds: [genericConfig.TestingCredentialsSecret, genericConfig.ConfigSecretName], + ConfigurationSecretIds: [genericConfig.TestingCredentialsSecret, genericConfig.ConfigSecretName, genericConfig.UinHashingSecret], AadValidClientId: "39c28870-94e4-47ee-b4fb-affe0bf96c9f", LinkryBaseUrl: "https://core.aws.qa.acmuiuc.org", PasskitIdentifier: "pass.org.acmuiuc.qa.membership", @@ -124,7 +130,7 @@ const environmentConfig: EnvironmentConfigType = { prod: { UserFacingUrl: "https://core.acm.illinois.edu", AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] }, - ConfigurationSecretIds: [genericConfig.ConfigSecretName], + ConfigurationSecretIds: [genericConfig.ConfigSecretName, genericConfig.UinHashingSecret], ValidCorsOrigins: [ /^https:\/\/(?:.*\.)?acmuiuc-academic-web\.pages\.dev$/, /^https:\/\/(?:.*\.)?acmuiuc\.pages\.dev$/, @@ -160,6 +166,7 @@ export type SecretConfig = { stripe_links_endpoint_secret: string; redis_url: string; encryption_key: string; + UIN_HASHING_SECRET_PEPPER: string; }; export type SecretTesting = { diff --git a/terraform/modules/dynamo/main.tf b/terraform/modules/dynamo/main.tf index ee1e1b6e..b8ba1cbf 100644 --- a/terraform/modules/dynamo/main.tf +++ b/terraform/modules/dynamo/main.tf @@ -270,3 +270,17 @@ resource "aws_dynamodb_table" "cache" { enabled = true } } + +resource "aws_dynamodb_table" "app_uin_records" { + billing_mode = "PAY_PER_REQUEST" + name = "${var.ProjectId}-uin-mapping" + deletion_protection_enabled = true + hash_key = "uinHash" + point_in_time_recovery { + enabled = true + } + attribute { + name = "uinHash" + type = "S" + } +} diff --git a/terraform/modules/lambdas/main.tf b/terraform/modules/lambdas/main.tf index 4a2c15d8..61cb3752 100644 --- a/terraform/modules/lambdas/main.tf +++ b/terraform/modules/lambdas/main.tf @@ -188,7 +188,8 @@ resource "aws_iam_policy" "shared_iam_policy" { Effect = "Allow", Resource = [ "arn:aws:secretsmanager:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:secret:infra-core-api-config*", - "arn:aws:secretsmanager:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:secret:infra-core-api-testing-credentials*" + "arn:aws:secretsmanager:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:secret:infra-core-api-testing-credentials*", + "arn:aws:secretsmanager:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:secret:infra-core-api-uin-pepper*" ] }, { @@ -234,7 +235,6 @@ resource "aws_iam_policy" "shared_iam_policy" { "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-linkry", "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-linkry/index/*", "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-keys", - ] }, { @@ -266,6 +266,19 @@ resource "aws_iam_policy" "shared_iam_policy" { "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-audit-log/index/*", ] }, + { + Sid = "DynamoDBUINAccess", + Effect = "Allow", + Action = [ + "dynamodb:PutItem", + "dynamodb:DescribeTable", + "dynamodb:Query", + ], + Resource = [ + "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-uin-mapping", + "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-uin-mapping/index/*", + ] + }, { Sid = "DynamoDBStreamAccess", Effect = "Allow", diff --git a/tests/unit/secret.testdata.ts b/tests/unit/secret.testdata.ts index 1bbe83a9..8ee90ac5 100644 --- a/tests/unit/secret.testdata.ts +++ b/tests/unit/secret.testdata.ts @@ -21,6 +21,9 @@ const testSecretObject = { const secretJson = JSON.stringify(secretObject); const testSecretJson = JSON.stringify(testSecretObject); +const uinSecretJson = JSON.stringify({ + UIN_HASHING_SECRET_PEPPER: "dc1f1a24-fce5-480b-a342-e7bd34d8f8c5", +}); const jwtPayload = { aud: "custom_jwt", @@ -81,4 +84,5 @@ export { testSecretObject, jwtPayload, jwtPayloadNoGroups, + uinSecretJson, }; diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts index 4b25f969..dddae587 100644 --- a/tests/unit/vitest.setup.ts +++ b/tests/unit/vitest.setup.ts @@ -7,8 +7,12 @@ import { } from "@aws-sdk/client-secrets-manager"; import { mockClient } from "aws-sdk-client-mock"; import { marshall } from "@aws-sdk/util-dynamodb"; -import { environmentConfig, genericConfig } from "../../src/common/config.js"; -import { secretJson, testSecretJson } from "./secret.testdata.js"; +import { genericConfig } from "../../src/common/config.js"; +import { + secretJson, + testSecretJson, + uinSecretJson, +} from "./secret.testdata.js"; const ddbMock = mockClient(DynamoDBClient); const smMock = mockClient(SecretsManagerClient); @@ -40,7 +44,7 @@ vi.mock( kLkvWTYwNnJfBkIK7mBi4niXXHYNR7ygbV8utlvFxjw: allAppRoles, }; - return mockUserRoles[userEmail as any] || []; + return mockUserRoles[userEmail as keyof typeof mockUserRoles] || []; }), getGroupRoles: vi.fn(async (_, groupId) => { @@ -53,7 +57,7 @@ vi.mock( "999": [AppRoles.STRIPE_LINK_CREATOR], }; - return mockGroupRoles[groupId as any] || []; + return mockGroupRoles[groupId as keyof typeof mockGroupRoles] || []; }), clearAuthCache: vi.fn(), }; @@ -102,8 +106,16 @@ ddbMock.on(QueryCommand).callsFake((command) => { }; return Promise.resolve({ - Items: mockMembershipData[requestedEmail] - ? [marshall(mockMembershipData[requestedEmail])] + Items: mockMembershipData[ + requestedEmail as keyof typeof mockMembershipData + ] + ? [ + marshall( + mockMembershipData[ + requestedEmail as keyof typeof mockMembershipData + ], + ), + ] : [], }); } @@ -117,6 +129,9 @@ smMock.on(GetSecretValueCommand).callsFake((command) => { if (command.SecretId == genericConfig.TestingCredentialsSecret) { return Promise.resolve({ SecretString: testSecretJson }); } + if (command.SecretId == genericConfig.UinHashingSecret) { + return Promise.resolve({ SecretString: uinSecretJson }); + } return Promise.reject(new Error(`Secret ID ${command.SecretID} not mocked`)); });