Skip to content

Commit fcd3d26

Browse files
committed
Create V2 API to get membership pass with UIUC tenant ID
1 parent 16cf5af commit fcd3d26

File tree

5 files changed

+246
-24
lines changed

5 files changed

+246
-24
lines changed

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ 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";
6162
/** END ROUTES */
6263

6364
export const instanceId = randomUUID();
@@ -353,6 +354,12 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) {
353354
},
354355
{ prefix: "/api/v1" },
355356
);
357+
await app.register(
358+
async (api, _options) => {
359+
api.register(mobileWalletV2Route, { prefix: "/mobileWallet" });
360+
},
361+
{ prefix: "/api/v2" },
362+
);
356363
await app.register(cors, {
357364
origin: app.environmentConfig.ValidCorsOrigins,
358365
methods: ["GET", "HEAD", "POST", "PATCH", "DELETE"],

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/v2/mobileWallet.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { FastifyBaseLogger, FastifyPluginAsync } from "fastify";
2+
import {
3+
UnauthenticatedError,
4+
ValidationError,
5+
} from "../../../common/errors/index.js";
6+
import * as z from "zod/v4";
7+
import {
8+
checkPaidMembershipFromRedis,
9+
checkPaidMembershipFromTable,
10+
} from "../../functions/membership.js";
11+
import rateLimiter from "api/plugins/rateLimiter.js";
12+
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
13+
import { withTags } from "api/components/index.js";
14+
import jwt, { Algorithm } from "jsonwebtoken";
15+
import { getJwksKey } from "api/plugins/auth.js";
16+
import { issueAppleWalletMembershipCard } from "api/functions/mobileWallet.js";
17+
import { Redis } from "api/types.js";
18+
19+
const UIUC_TENANT_ID = "44467e6f-462c-4ea2-823f-7800de5434e3";
20+
const COULD_NOT_PARSE_MESSAGE = "ID token could not be parsed.";
21+
22+
export const verifyUiucIdToken = async ({
23+
idToken,
24+
redisClient,
25+
logger,
26+
}: {
27+
idToken: string | string[] | undefined;
28+
redisClient: Redis;
29+
logger: FastifyBaseLogger;
30+
}) => {
31+
if (!idToken) {
32+
throw new UnauthenticatedError({
33+
message: "ID token not found.",
34+
});
35+
}
36+
if (Array.isArray(idToken)) {
37+
throw new ValidationError({
38+
message: "Multiple tokens cannot be specified!",
39+
});
40+
}
41+
const decoded = jwt.decode(idToken, { complete: true });
42+
if (!decoded) {
43+
throw new UnauthenticatedError({
44+
message: COULD_NOT_PARSE_MESSAGE,
45+
});
46+
}
47+
const header = decoded?.header;
48+
if (!header.kid) {
49+
throw new UnauthenticatedError({
50+
message: COULD_NOT_PARSE_MESSAGE,
51+
});
52+
}
53+
const signingKey = await getJwksKey({
54+
redisClient,
55+
kid: header.kid,
56+
logger,
57+
});
58+
const verifyOptions: jwt.VerifyOptions = {
59+
algorithms: ["RS256" as Algorithm],
60+
issuer: `https://login.microsoftonline.com/${UIUC_TENANT_ID}/v2.0`,
61+
};
62+
let verifiedData;
63+
try {
64+
verifiedData = jwt.verify(idToken, signingKey, verifyOptions) as {
65+
preferred_username?: string;
66+
email?: string;
67+
name?: string;
68+
};
69+
} catch (e) {
70+
if (e instanceof Error && e.name === "TokenExpiredError") {
71+
throw new UnauthenticatedError({
72+
message: "Access token has expired.",
73+
});
74+
}
75+
if (e instanceof Error && e.name === "JsonWebTokenError") {
76+
logger.error(e);
77+
throw new UnauthenticatedError({
78+
message: COULD_NOT_PARSE_MESSAGE,
79+
});
80+
}
81+
throw e;
82+
}
83+
return verifiedData;
84+
};
85+
86+
const mobileWalletV2Route: FastifyPluginAsync = async (fastify, _options) => {
87+
fastify.register(rateLimiter, {
88+
limit: 15,
89+
duration: 30,
90+
rateLimitIdentifier: "mobileWalletV2",
91+
});
92+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
93+
"/membership",
94+
{
95+
schema: withTags(["Mobile Wallet"], {
96+
summary: "Retrieve mobile wallet pass for ACM member.",
97+
headers: z.object({
98+
"x-uiuc-id-token": z.jwt().min(1).meta({
99+
description:
100+
"An access token for the user in the UIUC Entra ID tenant.",
101+
}),
102+
}),
103+
response: {
104+
204: {
105+
description: "A mobile wallet pass has been generated.",
106+
content: {
107+
"application/vnd.apple.pkpass": {
108+
schema: z.any(),
109+
description:
110+
"A pkpass file which contains the user's ACM @ UIUC membership pass.",
111+
},
112+
},
113+
},
114+
},
115+
}),
116+
},
117+
async (request, reply) => {
118+
const idToken = request.headers["x-uiuc-id-token"];
119+
const verifiedData = await verifyUiucIdToken({
120+
idToken,
121+
redisClient: fastify.redisClient,
122+
logger: request.log,
123+
});
124+
const { preferred_username: upn, email, name } = verifiedData;
125+
if (!upn || !email || !name) {
126+
throw new UnauthenticatedError({
127+
message: COULD_NOT_PARSE_MESSAGE,
128+
});
129+
}
130+
const netId = upn.replace("@illinois.edu", "");
131+
if (netId.includes("@")) {
132+
request.log.error(
133+
`Found UPN ${upn} which cannot be turned into NetID via simple replacement.`,
134+
);
135+
throw new ValidationError({
136+
message: "ID token could not be parsed.",
137+
});
138+
}
139+
let isPaidMember = await checkPaidMembershipFromRedis(
140+
netId,
141+
fastify.redisClient,
142+
request.log,
143+
);
144+
if (isPaidMember === null) {
145+
isPaidMember = await checkPaidMembershipFromTable(
146+
netId,
147+
fastify.dynamoClient,
148+
);
149+
}
150+
151+
if (!isPaidMember) {
152+
throw new UnauthenticatedError({
153+
message: `${upn} is not a paid member.`,
154+
});
155+
}
156+
157+
const pkpass = await issueAppleWalletMembershipCard(
158+
{ smClient: fastify.secretsManagerClient },
159+
fastify.environmentConfig,
160+
fastify.runEnvironment,
161+
upn,
162+
upn,
163+
request.log,
164+
name,
165+
);
166+
await reply
167+
.header("Content-Type", "application/vnd.apple.pkpass")
168+
.send(pkpass);
169+
},
170+
);
171+
};
172+
173+
export default mobileWalletV2Route;

0 commit comments

Comments
 (0)