Skip to content

Commit 80db2c9

Browse files
authored
Support UIUC tenant authentication for website interfaces (#212)
v2 APIs for getting netid for checkout and apple wallet from NetID ID token
1 parent 16cf5af commit 80db2c9

File tree

10 files changed

+439
-160
lines changed

10 files changed

+439
-160
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ clean:
5959
build: src/ cloudformation/
6060
yarn -D
6161
VITE_BUILD_HASH=$(GIT_HASH) yarn build
62-
cd src/api && npx tsx createSwagger.ts
62+
cd src/api && npx tsx --experimental-loader=./mockLoader.mjs createSwagger.ts
6363
cp -r src/api/resources/ dist/api/resources
6464
rm -rf dist/lambda/sqs
6565
sam build --template-file cloudformation/main.yml --use-container --parallel

src/api/functions/membership.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import {
1616
import { EntraGroupError } from "common/errors/index.js";
1717
import { EntraGroupActions } from "common/types/iam.js";
1818
import { pollUntilNoError } from "./general.js";
19+
import Redis from "ioredis";
20+
import { getKey } from "./redisCache.js";
21+
import { FastifyBaseLogger } from "fastify";
1922

2023
export const MEMBER_CACHE_SECONDS = 43200; // 12 hours
2124

@@ -42,6 +45,23 @@ export async function checkExternalMembership(
4245
return true;
4346
}
4447

48+
export async function checkPaidMembershipFromRedis(
49+
netId: string,
50+
redisClient: Redis.default,
51+
logger: FastifyBaseLogger,
52+
) {
53+
const cacheKey = `membership:${netId}:acmpaid`;
54+
const result = await getKey<{ isMember: boolean }>({
55+
redisClient,
56+
key: cacheKey,
57+
logger,
58+
});
59+
if (!result) {
60+
return null;
61+
}
62+
return result.isMember;
63+
}
64+
4565
export async function checkPaidMembershipFromTable(
4666
netId: string,
4767
dynamoClient: DynamoDBClient,

src/api/functions/mobileWallet.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { RunEnvironment } from "common/roles.js";
1919
import pino from "pino";
2020
import { createAuditLogEntry } from "./auditLog.js";
2121
import { Modules } from "common/modules.js";
22+
import { FastifyBaseLogger } from "fastify";
2223

2324
function trim(s: string) {
2425
return (s || "").replace(/^\s+|\s+$/g, "");
@@ -37,7 +38,7 @@ export async function issueAppleWalletMembershipCard(
3738
runEnvironment: RunEnvironment,
3839
email: string,
3940
initiator: string,
40-
logger: pino.Logger,
41+
logger: pino.Logger | FastifyBaseLogger,
4142
name?: string,
4243
) {
4344
if (!email.endsWith("@illinois.edu")) {

src/api/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import apiKeyRoute from "./routes/apiKey.js";
5858
import clearSessionRoute from "./routes/clearSession.js";
5959
import protectedRoute from "./routes/protected.js";
6060
import eventsPlugin from "./routes/events.js";
61+
import mobileWalletV2Route from "./routes/v2/mobileWallet.js";
62+
import membershipV2Plugin from "./routes/v2/membership.js";
6163
/** END ROUTES */
6264

6365
export const instanceId = randomUUID();
@@ -119,7 +121,7 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) {
119121
title: "ACM @ UIUC Core API",
120122
description:
121123
"The ACM @ UIUC Core API provides services for managing chapter operations.",
122-
version: "1.1.0",
124+
version: "2.0.0",
123125
contact: {
124126
name: "ACM @ UIUC Infrastructure Team",
125127
email: "infra@acm.illinois.edu",
@@ -353,6 +355,13 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) {
353355
},
354356
{ prefix: "/api/v1" },
355357
);
358+
await app.register(
359+
async (api, _options) => {
360+
api.register(mobileWalletV2Route, { prefix: "/mobileWallet" });
361+
api.register(membershipV2Plugin, { prefix: "/membership" });
362+
},
363+
{ prefix: "/api/v2" },
364+
);
356365
await app.register(cors, {
357366
origin: app.environmentConfig.ValidCorsOrigins,
358367
methods: ["GET", "HEAD", "POST", "PATCH", "DELETE"],

src/api/mockLoader.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function resolve(specifier, context, defaultResolve) {
2+
// If the import is for a .png file
3+
if (specifier.endsWith(".png")) {
4+
return {
5+
// Short-circuit the import and provide a dummy module
6+
shortCircuit: true,
7+
// A data URL for a valid, empty JavaScript module
8+
url: "data:text/javascript,export default {};",
9+
};
10+
}
11+
12+
// Let Node's default loader handle all other files
13+
return defaultResolve(specifier, context, defaultResolve);
14+
}

src/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,4 @@
7171
"nodemon": "^3.1.10",
7272
"pino-pretty": "^13.0.0"
7373
}
74-
}
74+
}

src/api/plugins/auth.ts

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
1+
import {
2+
FastifyBaseLogger,
3+
FastifyPluginAsync,
4+
FastifyReply,
5+
FastifyRequest,
6+
} from "fastify";
27
import fp from "fastify-plugin";
38
import jwksClient from "jwks-rsa";
49
import jwt, { Algorithm, Jwt } from "jsonwebtoken";
@@ -21,6 +26,7 @@ import {
2126
import { getGroupRoles, getUserRoles } from "../functions/authorization.js";
2227
import { getApiKeyData, getApiKeyParts } from "api/functions/apiKey.js";
2328
import { getKey, setKey } from "api/functions/redisCache.js";
29+
import { Redis } from "api/types.js";
2430

2531
export const AUTH_CACHE_PREFIX = `authCache:`;
2632

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

116+
export const getJwksKey = async ({
117+
redisClient,
118+
kid,
119+
logger,
120+
}: {
121+
redisClient: Redis;
122+
kid: string;
123+
logger: FastifyBaseLogger;
124+
}) => {
125+
let signingKey;
126+
const cachedJwksSigningKey = await getKey<{ key: string }>({
127+
redisClient,
128+
key: `jwksKey:${kid}`,
129+
logger,
130+
});
131+
if (cachedJwksSigningKey) {
132+
signingKey = cachedJwksSigningKey.key;
133+
logger.debug("Got JWKS signing key from cache.");
134+
} else {
135+
const client = jwksClient({
136+
jwksUri: "https://login.microsoftonline.com/common/discovery/keys",
137+
});
138+
signingKey = (await client.getSigningKey(kid)).getPublicKey();
139+
await setKey({
140+
redisClient,
141+
key: `jwksKey:${kid}`,
142+
data: JSON.stringify({ key: signingKey }),
143+
expiresIn: JWKS_CACHE_SECONDS,
144+
logger,
145+
});
146+
logger.debug("Got JWKS signing key from server.");
147+
}
148+
return signingKey;
149+
};
150+
110151
const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
111152
const handleApiKeyAuthentication = async (
112153
request: FastifyRequest,
@@ -230,31 +271,11 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
230271
audience: `api://${AadClientId}`,
231272
};
232273
const { redisClient } = fastify;
233-
const cachedJwksSigningKey = await getKey<{ key: string }>({
274+
signingKey = await getJwksKey({
234275
redisClient,
235-
key: `jwksKey:${header.kid}`,
276+
kid: header.kid,
236277
logger: request.log,
237278
});
238-
if (cachedJwksSigningKey) {
239-
signingKey = cachedJwksSigningKey.key;
240-
request.log.debug("Got JWKS signing key from cache.");
241-
} else {
242-
const client = jwksClient({
243-
jwksUri:
244-
"https://login.microsoftonline.com/common/discovery/keys",
245-
});
246-
signingKey = (
247-
await client.getSigningKey(header.kid)
248-
).getPublicKey();
249-
await setKey({
250-
redisClient,
251-
key: `jwksKey:${header.kid}`,
252-
data: JSON.stringify({ key: signingKey }),
253-
expiresIn: JWKS_CACHE_SECONDS,
254-
logger: request.log,
255-
});
256-
request.log.debug("Got JWKS signing key from server.");
257-
}
258279
}
259280

260281
const verifiedTokenData = jwt.verify(

src/api/routes/membership.ts

Lines changed: 0 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -71,139 +71,6 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
7171
duration: 30,
7272
rateLimitIdentifier: "membership",
7373
});
74-
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
75-
"/checkout/:netId",
76-
{
77-
schema: withTags(["Membership"], {
78-
params: z.object({ netId: illinoisNetId }),
79-
summary:
80-
"Create a checkout session to purchase an ACM @ UIUC membership.",
81-
response: {
82-
200: {
83-
description: "Stripe checkout link.",
84-
content: {
85-
"text/plain": {
86-
schema: z.url().meta({
87-
example:
88-
"https://buy.stripe.com/test_14A00j9Hq9tj9ZfchM3AY0s",
89-
}),
90-
},
91-
},
92-
},
93-
},
94-
}),
95-
},
96-
async (request, reply) => {
97-
const netId = request.params.netId.toLowerCase();
98-
const cacheKey = `membership:${netId}:acmpaid`;
99-
const result = await getKey<{ isMember: boolean }>({
100-
redisClient: fastify.redisClient,
101-
key: cacheKey,
102-
logger: request.log,
103-
});
104-
if (result && result.isMember) {
105-
throw new ValidationError({
106-
message: `${netId} is already a paid member!`,
107-
});
108-
}
109-
const isDynamoMember = await checkPaidMembershipFromTable(
110-
netId,
111-
fastify.dynamoClient,
112-
);
113-
if (isDynamoMember) {
114-
await setKey({
115-
redisClient: fastify.redisClient,
116-
key: cacheKey,
117-
data: JSON.stringify({ isMember: true }),
118-
expiresIn: MEMBER_CACHE_SECONDS,
119-
logger: request.log,
120-
});
121-
throw new ValidationError({
122-
message: `${netId} is already a paid member!`,
123-
});
124-
}
125-
const entraIdToken = await getEntraIdToken({
126-
clients: await getAuthorizedClients(),
127-
clientId: fastify.environmentConfig.AadValidClientId,
128-
secretName: genericConfig.EntraSecretName,
129-
logger: request.log,
130-
});
131-
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
132-
const isAadMember = await checkPaidMembershipFromEntra(
133-
netId,
134-
entraIdToken,
135-
paidMemberGroup,
136-
);
137-
if (isAadMember) {
138-
await setKey({
139-
redisClient: fastify.redisClient,
140-
key: cacheKey,
141-
data: JSON.stringify({ isMember: true }),
142-
expiresIn: MEMBER_CACHE_SECONDS,
143-
logger: request.log,
144-
});
145-
reply
146-
.header("X-ACM-Data-Source", "aad")
147-
.send({ netId, isPaidMember: true });
148-
await setPaidMembershipInTable(netId, fastify.dynamoClient);
149-
throw new ValidationError({
150-
message: `${netId} is already a paid member!`,
151-
});
152-
}
153-
// Once the caller becomes a member, the stripe webhook will handle changing this to true
154-
await setKey({
155-
redisClient: fastify.redisClient,
156-
key: cacheKey,
157-
data: JSON.stringify({ isMember: false }),
158-
expiresIn: MEMBER_CACHE_SECONDS,
159-
logger: request.log,
160-
});
161-
const secretApiConfig =
162-
(await getSecretValue(
163-
fastify.secretsManagerClient,
164-
genericConfig.ConfigSecretName,
165-
)) || {};
166-
if (!secretApiConfig) {
167-
throw new InternalServerError({
168-
message: "Could not connect to Stripe.",
169-
});
170-
}
171-
return reply.status(200).send(
172-
await createCheckoutSession({
173-
successUrl: "https://acm.illinois.edu/paid",
174-
returnUrl: "https://acm.illinois.edu/membership",
175-
customerEmail: `${netId}@illinois.edu`,
176-
stripeApiKey: secretApiConfig.stripe_secret_key as string,
177-
items: [
178-
{
179-
price: fastify.environmentConfig.PaidMemberPriceId,
180-
quantity: 1,
181-
},
182-
],
183-
customFields: [
184-
{
185-
key: "firstName",
186-
label: {
187-
type: "custom",
188-
custom: "Member First Name",
189-
},
190-
type: "text",
191-
},
192-
{
193-
key: "lastName",
194-
label: {
195-
type: "custom",
196-
custom: "Member Last Name",
197-
},
198-
type: "text",
199-
},
200-
],
201-
initiator: "purchase-membership",
202-
allowPromotionCodes: true,
203-
}),
204-
);
205-
},
206-
);
20774
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
20875
"/:netId",
20976
{

0 commit comments

Comments
 (0)