Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ clean:
build: src/ cloudformation/
yarn -D
VITE_BUILD_HASH=$(GIT_HASH) yarn build
cd src/api && npx tsx createSwagger.ts
cd src/api && npx tsx --experimental-loader=./mockLoader.mjs createSwagger.ts
cp -r src/api/resources/ dist/api/resources
rm -rf dist/lambda/sqs
sam build --template-file cloudformation/main.yml --use-container --parallel
Expand Down
20 changes: 20 additions & 0 deletions src/api/functions/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
import { EntraGroupError } from "common/errors/index.js";
import { EntraGroupActions } from "common/types/iam.js";
import { pollUntilNoError } from "./general.js";
import Redis from "ioredis";
import { getKey } from "./redisCache.js";
import { FastifyBaseLogger } from "fastify";

export const MEMBER_CACHE_SECONDS = 43200; // 12 hours

Expand All @@ -42,6 +45,23 @@ export async function checkExternalMembership(
return true;
}

export async function checkPaidMembershipFromRedis(
netId: string,
redisClient: Redis.default,
logger: FastifyBaseLogger,
) {
const cacheKey = `membership:${netId}:acmpaid`;
const result = await getKey<{ isMember: boolean }>({
redisClient,
key: cacheKey,
logger,
});
if (!result) {
return null;
}
return result.isMember;
}

export async function checkPaidMembershipFromTable(
netId: string,
dynamoClient: DynamoDBClient,
Expand Down
3 changes: 2 additions & 1 deletion src/api/functions/mobileWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RunEnvironment } from "common/roles.js";
import pino from "pino";
import { createAuditLogEntry } from "./auditLog.js";
import { Modules } from "common/modules.js";
import { FastifyBaseLogger } from "fastify";

function trim(s: string) {
return (s || "").replace(/^\s+|\s+$/g, "");
Expand All @@ -37,7 +38,7 @@ export async function issueAppleWalletMembershipCard(
runEnvironment: RunEnvironment,
email: string,
initiator: string,
logger: pino.Logger,
logger: pino.Logger | FastifyBaseLogger,
name?: string,
) {
if (!email.endsWith("@illinois.edu")) {
Expand Down
11 changes: 10 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import apiKeyRoute from "./routes/apiKey.js";
import clearSessionRoute from "./routes/clearSession.js";
import protectedRoute from "./routes/protected.js";
import eventsPlugin from "./routes/events.js";
import mobileWalletV2Route from "./routes/v2/mobileWallet.js";
import membershipV2Plugin from "./routes/v2/membership.js";
/** END ROUTES */

export const instanceId = randomUUID();
Expand Down Expand Up @@ -119,7 +121,7 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) {
title: "ACM @ UIUC Core API",
description:
"The ACM @ UIUC Core API provides services for managing chapter operations.",
version: "1.1.0",
version: "2.0.0",
contact: {
name: "ACM @ UIUC Infrastructure Team",
email: "[email protected]",
Expand Down Expand Up @@ -353,6 +355,13 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) {
},
{ prefix: "/api/v1" },
);
await app.register(
async (api, _options) => {
api.register(mobileWalletV2Route, { prefix: "/mobileWallet" });
api.register(membershipV2Plugin, { prefix: "/membership" });
},
{ prefix: "/api/v2" },
);
await app.register(cors, {
origin: app.environmentConfig.ValidCorsOrigins,
methods: ["GET", "HEAD", "POST", "PATCH", "DELETE"],
Expand Down
14 changes: 14 additions & 0 deletions src/api/mockLoader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function resolve(specifier, context, defaultResolve) {
// If the import is for a .png file
if (specifier.endsWith(".png")) {
return {
// Short-circuit the import and provide a dummy module
shortCircuit: true,
// A data URL for a valid, empty JavaScript module
url: "data:text/javascript,export default {};",
};
}

// Let Node's default loader handle all other files
return defaultResolve(specifier, context, defaultResolve);
}
2 changes: 1 addition & 1 deletion src/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,4 @@
"nodemon": "^3.1.10",
"pino-pretty": "^13.0.0"
}
}
}
67 changes: 44 additions & 23 deletions src/api/plugins/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import {
FastifyBaseLogger,
FastifyPluginAsync,
FastifyReply,
FastifyRequest,
} from "fastify";
import fp from "fastify-plugin";
import jwksClient from "jwks-rsa";
import jwt, { Algorithm, Jwt } from "jsonwebtoken";
Expand All @@ -21,6 +26,7 @@ import {
import { getGroupRoles, getUserRoles } from "../functions/authorization.js";
import { getApiKeyData, getApiKeyParts } from "api/functions/apiKey.js";
import { getKey, setKey } from "api/functions/redisCache.js";
import { Redis } from "api/types.js";

export const AUTH_CACHE_PREFIX = `authCache:`;

Expand Down Expand Up @@ -107,6 +113,41 @@ export const getUserIdentifier = (request: FastifyRequest): string | null => {
}
};

export const getJwksKey = async ({
redisClient,
kid,
logger,
}: {
redisClient: Redis;
kid: string;
logger: FastifyBaseLogger;
}) => {
let signingKey;
const cachedJwksSigningKey = await getKey<{ key: string }>({
redisClient,
key: `jwksKey:${kid}`,
logger,
});
if (cachedJwksSigningKey) {
signingKey = cachedJwksSigningKey.key;
logger.debug("Got JWKS signing key from cache.");
} else {
const client = jwksClient({
jwksUri: "https://login.microsoftonline.com/common/discovery/keys",
});
signingKey = (await client.getSigningKey(kid)).getPublicKey();
await setKey({
redisClient,
key: `jwksKey:${kid}`,
data: JSON.stringify({ key: signingKey }),
expiresIn: JWKS_CACHE_SECONDS,
logger,
});
logger.debug("Got JWKS signing key from server.");
}
return signingKey;
};

const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
const handleApiKeyAuthentication = async (
request: FastifyRequest,
Expand Down Expand Up @@ -230,31 +271,11 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
audience: `api://${AadClientId}`,
};
const { redisClient } = fastify;
const cachedJwksSigningKey = await getKey<{ key: string }>({
signingKey = await getJwksKey({
redisClient,
key: `jwksKey:${header.kid}`,
kid: header.kid,
logger: request.log,
});
if (cachedJwksSigningKey) {
signingKey = cachedJwksSigningKey.key;
request.log.debug("Got JWKS signing key from cache.");
} else {
const client = jwksClient({
jwksUri:
"https://login.microsoftonline.com/common/discovery/keys",
});
signingKey = (
await client.getSigningKey(header.kid)
).getPublicKey();
await setKey({
redisClient,
key: `jwksKey:${header.kid}`,
data: JSON.stringify({ key: signingKey }),
expiresIn: JWKS_CACHE_SECONDS,
logger: request.log,
});
request.log.debug("Got JWKS signing key from server.");
}
}

const verifiedTokenData = jwt.verify(
Expand Down
133 changes: 0 additions & 133 deletions src/api/routes/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,139 +71,6 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
duration: 30,
rateLimitIdentifier: "membership",
});
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
"/checkout/:netId",
{
schema: withTags(["Membership"], {
params: z.object({ netId: illinoisNetId }),
summary:
"Create a checkout session to purchase an ACM @ UIUC membership.",
response: {
200: {
description: "Stripe checkout link.",
content: {
"text/plain": {
schema: z.url().meta({
example:
"https://buy.stripe.com/test_14A00j9Hq9tj9ZfchM3AY0s",
}),
},
},
},
},
}),
},
async (request, reply) => {
const netId = request.params.netId.toLowerCase();
const cacheKey = `membership:${netId}:acmpaid`;
const result = await getKey<{ isMember: boolean }>({
redisClient: fastify.redisClient,
key: cacheKey,
logger: request.log,
});
if (result && result.isMember) {
throw new ValidationError({
message: `${netId} is already a paid member!`,
});
}
const isDynamoMember = await checkPaidMembershipFromTable(
netId,
fastify.dynamoClient,
);
if (isDynamoMember) {
await setKey({
redisClient: fastify.redisClient,
key: cacheKey,
data: JSON.stringify({ isMember: true }),
expiresIn: MEMBER_CACHE_SECONDS,
logger: request.log,
});
throw new ValidationError({
message: `${netId} is already a paid member!`,
});
}
const entraIdToken = await getEntraIdToken({
clients: await getAuthorizedClients(),
clientId: fastify.environmentConfig.AadValidClientId,
secretName: genericConfig.EntraSecretName,
logger: request.log,
});
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
const isAadMember = await checkPaidMembershipFromEntra(
netId,
entraIdToken,
paidMemberGroup,
);
if (isAadMember) {
await setKey({
redisClient: fastify.redisClient,
key: cacheKey,
data: JSON.stringify({ isMember: true }),
expiresIn: MEMBER_CACHE_SECONDS,
logger: request.log,
});
reply
.header("X-ACM-Data-Source", "aad")
.send({ netId, isPaidMember: true });
await setPaidMembershipInTable(netId, fastify.dynamoClient);
throw new ValidationError({
message: `${netId} is already a paid member!`,
});
}
// Once the caller becomes a member, the stripe webhook will handle changing this to true
await setKey({
redisClient: fastify.redisClient,
key: cacheKey,
data: JSON.stringify({ isMember: false }),
expiresIn: MEMBER_CACHE_SECONDS,
logger: request.log,
});
const secretApiConfig =
(await getSecretValue(
fastify.secretsManagerClient,
genericConfig.ConfigSecretName,
)) || {};
if (!secretApiConfig) {
throw new InternalServerError({
message: "Could not connect to Stripe.",
});
}
return reply.status(200).send(
await createCheckoutSession({
successUrl: "https://acm.illinois.edu/paid",
returnUrl: "https://acm.illinois.edu/membership",
customerEmail: `${netId}@illinois.edu`,
stripeApiKey: secretApiConfig.stripe_secret_key as string,
items: [
{
price: fastify.environmentConfig.PaidMemberPriceId,
quantity: 1,
},
],
customFields: [
{
key: "firstName",
label: {
type: "custom",
custom: "Member First Name",
},
type: "text",
},
{
key: "lastName",
label: {
type: "custom",
custom: "Member Last Name",
},
type: "text",
},
],
initiator: "purchase-membership",
allowPromotionCodes: true,
}),
);
},
);
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
"/:netId",
{
Expand Down
Loading
Loading