Skip to content

Commit 5d62df0

Browse files
committed
Get membership by UIUC access token
1 parent 40ed13c commit 5d62df0

File tree

3 files changed

+223
-2
lines changed

3 files changed

+223
-2
lines changed

src/api/routes/membership.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { illinoisNetId, withRoles, withTags } from "api/components/index.js";
3737
import { getKey, setKey } from "api/functions/redisCache.js";
3838
import { AppRoles } from "common/roles.js";
3939
import { unmarshall } from "@aws-sdk/util-dynamodb";
40+
import { verifyUiucAccessToken } from "api/functions/uin.js";
4041

4142
const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
4243
await fastify.register(rawbody, {
@@ -81,6 +82,150 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
8182
duration: 30,
8283
rateLimitIdentifier: "membership",
8384
});
85+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
86+
"/",
87+
{
88+
schema: withTags(["Membership"], {
89+
querystring: z.object({
90+
list: z.string().min(1).optional().meta({
91+
description:
92+
"Membership list to check from (defaults to ACM Paid Member list).",
93+
}),
94+
}),
95+
headers: z.object({
96+
"x-uiuc-token": z.jwt().min(1).meta({
97+
description:
98+
"An access token for the user in the UIUC Entra ID tenant.",
99+
}),
100+
}),
101+
summary:
102+
"Authenticated check ACM @ UIUC paid membership (or partner organization membership) status.",
103+
response: {
104+
200: {
105+
description: "List membership status.",
106+
content: {
107+
"application/json": {
108+
schema: z
109+
.object({
110+
netId: illinoisNetId,
111+
list: z.optional(z.string().min(1)),
112+
isPaidMember: z.boolean(),
113+
})
114+
.meta({
115+
example: {
116+
netId: "rjjones",
117+
isPaidMember: false,
118+
},
119+
}),
120+
},
121+
},
122+
},
123+
},
124+
}),
125+
},
126+
async (request, reply) => {
127+
const accessToken = request.headers["x-uiuc-token"];
128+
const verifiedData = await verifyUiucAccessToken({
129+
accessToken,
130+
logger: request.log,
131+
});
132+
const { userPrincipalName: upn, givenName, surname } = verifiedData;
133+
const netId = upn.replace("@illinois.edu", "");
134+
if (netId.includes("@")) {
135+
request.log.error(
136+
`Found UPN ${upn} which cannot be turned into NetID via simple replacement.`,
137+
);
138+
throw new ValidationError({
139+
message: "ID token could not be parsed.",
140+
});
141+
}
142+
const list = request.query.list || "acmpaid";
143+
const cacheKey = `membership:${netId}:${list}`;
144+
const result = await getKey<{ isMember: boolean }>({
145+
redisClient: fastify.redisClient,
146+
key: cacheKey,
147+
logger: request.log,
148+
});
149+
if (result) {
150+
return reply.header("X-ACM-Data-Source", "cache").send({
151+
netId,
152+
list: list === "acmpaid" ? undefined : list,
153+
isPaidMember: result.isMember,
154+
});
155+
}
156+
if (list !== "acmpaid") {
157+
const isMember = await checkExternalMembership(
158+
netId,
159+
list,
160+
fastify.dynamoClient,
161+
);
162+
await setKey({
163+
redisClient: fastify.redisClient,
164+
key: cacheKey,
165+
data: JSON.stringify({ isMember }),
166+
expiresIn: MEMBER_CACHE_SECONDS,
167+
logger: request.log,
168+
});
169+
return reply.header("X-ACM-Data-Source", "dynamo").send({
170+
netId,
171+
list,
172+
isPaidMember: isMember,
173+
});
174+
}
175+
const isDynamoMember = await checkPaidMembershipFromTable(
176+
netId,
177+
fastify.dynamoClient,
178+
);
179+
if (isDynamoMember) {
180+
await setKey({
181+
redisClient: fastify.redisClient,
182+
key: cacheKey,
183+
data: JSON.stringify({ isMember: true }),
184+
expiresIn: MEMBER_CACHE_SECONDS,
185+
logger: request.log,
186+
});
187+
return reply
188+
.header("X-ACM-Data-Source", "dynamo")
189+
.send({ netId, isPaidMember: true });
190+
}
191+
const entraIdToken = await getEntraIdToken({
192+
clients: await getAuthorizedClients(),
193+
clientId: fastify.environmentConfig.AadValidClientId,
194+
secretName: genericConfig.EntraSecretName,
195+
logger: request.log,
196+
});
197+
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
198+
const isAadMember = await checkPaidMembershipFromEntra(
199+
netId,
200+
entraIdToken,
201+
paidMemberGroup,
202+
);
203+
if (isAadMember) {
204+
await setKey({
205+
redisClient: fastify.redisClient,
206+
key: cacheKey,
207+
data: JSON.stringify({ isMember: true }),
208+
expiresIn: MEMBER_CACHE_SECONDS,
209+
logger: request.log,
210+
});
211+
reply
212+
.header("X-ACM-Data-Source", "aad")
213+
.send({ netId, isPaidMember: true });
214+
await setPaidMembershipInTable(netId, fastify.dynamoClient);
215+
return;
216+
}
217+
await setKey({
218+
redisClient: fastify.redisClient,
219+
key: cacheKey,
220+
data: JSON.stringify({ isMember: false }),
221+
expiresIn: MEMBER_CACHE_SECONDS,
222+
logger: request.log,
223+
});
224+
return reply
225+
.header("X-ACM-Data-Source", "aad")
226+
.send({ netId, isPaidMember: false });
227+
},
228+
);
84229
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
85230
"/:netId",
86231
{

tests/unit/membership.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,32 @@ const spySetPaidMembership = vi.spyOn(
3333
);
3434

3535
describe("Test membership routes", async () => {
36-
test("Test getting member", async () => {
36+
test("Test getting non-member with UIUC access token", async () => {
3737
const response = await app.inject({
3838
method: "GET",
39-
url: "/api/v1/membership/valid",
39+
url: "/api/v1/membership",
40+
headers: {
41+
"x-uiuc-token":
42+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTY3Mjc2NjAyOCwiZXhwIjoxNjc0NDk0MDI4fQ.kCak9sLJr74frSRVQp0_27BY4iBCgQSmoT3vQVWKzJg",
43+
},
44+
});
45+
expect(response.statusCode).toBe(200);
46+
const responseDataJson = (await response.json()) as EventGetResponse;
47+
expect(response.headers).toHaveProperty("x-acm-data-source");
48+
expect(response.headers["x-acm-data-source"]).toEqual("aad");
49+
expect(responseDataJson).toEqual({
50+
netId: "fjkldk99",
51+
isPaidMember: false,
52+
});
53+
});
54+
test("Test getting member with UIUC access token", async () => {
55+
const response = await app.inject({
56+
method: "GET",
57+
url: "/api/v1/membership",
58+
headers: {
59+
"x-uiuc-token":
60+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTY3Mjc2NjAyOCwiZXhwIjoxNjcyODAyMDI4fQ.P1_rB3hJ5afwiG4TWXLq6jOAcVJkvQZ2Z-ZZOnQ1dZw",
61+
},
4062
});
4163
expect(response.statusCode).toBe(200);
4264
const responseDataJson = (await response.json()) as EventGetResponse;

tests/unit/vitest.setup.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
testSecretJson,
1414
uinSecretJson,
1515
} from "./secret.testdata.js";
16+
import {
17+
UnauthenticatedError,
18+
ValidationError,
19+
} from "../../src/common/errors/index.js";
1620

1721
const ddbMock = mockClient(DynamoDBClient);
1822
const smMock = mockClient(SecretsManagerClient);
@@ -29,6 +33,56 @@ vi.mock(
2933
},
3034
);
3135

36+
vi.mock(import("../../src/api/functions/uin.js"), async (importOriginal) => {
37+
const mod = await importOriginal();
38+
return {
39+
...mod,
40+
verifyUiucAccessToken: vi.fn(
41+
async ({
42+
accessToken,
43+
logger,
44+
}: {
45+
accessToken: string | string[] | undefined;
46+
logger: unknown;
47+
}) => {
48+
if (!accessToken) {
49+
throw new UnauthenticatedError({
50+
message: "Access token not found.",
51+
});
52+
}
53+
if (Array.isArray(accessToken)) {
54+
throw new ValidationError({
55+
message: "Multiple tokens cannot be specified!",
56+
});
57+
}
58+
const validTokens = {
59+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTY3Mjc2NjAyOCwiZXhwIjoxNjc0NDk0MDI4fQ.kCak9sLJr74frSRVQp0_27BY4iBCgQSmoT3vQVWKzJg":
60+
{
61+
userPrincipalName: "[email protected]",
62+
givenName: "Infra",
63+
surname: "Testing",
64+
65+
},
66+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTY3Mjc2NjAyOCwiZXhwIjoxNjcyODAyMDI4fQ.P1_rB3hJ5afwiG4TWXLq6jOAcVJkvQZ2Z-ZZOnQ1dZw":
67+
{
68+
userPrincipalName: "[email protected]",
69+
givenName: "Infra",
70+
surname: "Testing",
71+
72+
},
73+
};
74+
if (accessToken in validTokens) {
75+
return validTokens[accessToken as keyof typeof validTokens];
76+
} else {
77+
throw new UnauthenticatedError({
78+
message: "Invalid or expired access token.",
79+
});
80+
}
81+
},
82+
),
83+
};
84+
});
85+
3286
vi.mock(
3387
import("../../src/api/functions/authorization.js"),
3488
async (importOriginal) => {

0 commit comments

Comments
 (0)