Skip to content

Commit d984a40

Browse files
authored
Get UIUC access tokens (#230)
1 parent f97e226 commit d984a40

File tree

13 files changed

+399
-151
lines changed

13 files changed

+399
-151
lines changed

src/api/functions/stripe.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type StripeCheckoutSessionCreateParams = {
1818
stripeApiKey: string;
1919
items: { price: string; quantity: number }[];
2020
initiator: string;
21+
metadata?: Record<string, string>;
2122
allowPromotionCodes: boolean;
2223
customFields?: Stripe.Checkout.SessionCreateParams.CustomField[];
2324
};
@@ -77,6 +78,7 @@ export const createCheckoutSession = async ({
7778
initiator,
7879
allowPromotionCodes,
7980
customFields,
81+
metadata,
8082
}: StripeCheckoutSessionCreateParams): Promise<string> => {
8183
const stripe = new Stripe(stripeApiKey);
8284
const payload: Stripe.Checkout.SessionCreateParams = {
@@ -90,6 +92,7 @@ export const createCheckoutSession = async ({
9092
mode: "payment",
9193
customer_email: customerEmail,
9294
metadata: {
95+
...(metadata || {}),
9396
initiator,
9497
},
9598
allow_promotion_codes: allowPromotionCodes,

src/api/functions/uin.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
2+
import { marshall } from "@aws-sdk/util-dynamodb";
3+
import { hash } from "argon2";
4+
import { genericConfig } from "common/config.js";
5+
import {
6+
BaseError,
7+
EntraFetchError,
8+
InternalServerError,
9+
UnauthenticatedError,
10+
ValidationError,
11+
} from "common/errors/index.js";
12+
import { type FastifyBaseLogger } from "fastify";
13+
14+
export type HashUinInputs = {
15+
pepper: string;
16+
uin: string;
17+
};
18+
19+
export type GetUserUinInputs = {
20+
uiucAccessToken: string;
21+
pepper: string;
22+
};
23+
24+
export const verifyUiucAccessToken = async ({
25+
accessToken,
26+
logger,
27+
}: {
28+
accessToken: string | string[] | undefined;
29+
logger: FastifyBaseLogger;
30+
}) => {
31+
if (!accessToken) {
32+
throw new UnauthenticatedError({
33+
message: "Access token not found.",
34+
});
35+
}
36+
if (Array.isArray(accessToken)) {
37+
throw new ValidationError({
38+
message: "Multiple tokens cannot be specified!",
39+
});
40+
}
41+
const url =
42+
"https://graph.microsoft.com/v1.0/me?$select=userPrincipalName,givenName,surname,mail";
43+
44+
try {
45+
const response = await fetch(url, {
46+
method: "GET",
47+
headers: {
48+
Authorization: `Bearer ${accessToken}`,
49+
"Content-Type": "application/json",
50+
},
51+
});
52+
53+
if (response.status === 401) {
54+
const errorText = await response.text();
55+
logger.warn(`Microsoft Graph API unauthenticated response: ${errorText}`);
56+
throw new UnauthenticatedError({
57+
message: "Invalid or expired access token.",
58+
});
59+
}
60+
61+
if (!response.ok) {
62+
const errorText = await response.text();
63+
logger.error(
64+
`Microsoft Graph API error: ${response.status} - ${errorText}`,
65+
);
66+
throw new InternalServerError({
67+
message: "Failed to contact Microsoft Graph API.",
68+
});
69+
}
70+
71+
const data = (await response.json()) as {
72+
userPrincipalName: string;
73+
givenName: string;
74+
surname: string;
75+
mail: string;
76+
};
77+
logger.info("Access token successfully verified with Microsoft Graph API.");
78+
return data;
79+
} catch (error) {
80+
if (error instanceof BaseError) {
81+
throw error;
82+
} else {
83+
logger.error(error);
84+
throw new InternalServerError({
85+
message:
86+
"An unexpected error occurred during access token verification.",
87+
});
88+
}
89+
}
90+
};
91+
92+
export async function getUinHash({
93+
pepper,
94+
uin,
95+
}: HashUinInputs): Promise<string> {
96+
return hash(uin, { salt: Buffer.from(pepper) });
97+
}
98+
99+
export async function getHashedUserUin({
100+
uiucAccessToken,
101+
pepper,
102+
}: GetUserUinInputs): Promise<string> {
103+
const url = `https://graph.microsoft.com/v1.0/me?$select=${genericConfig.UinExtendedAttributeName}`;
104+
try {
105+
const response = await fetch(url, {
106+
method: "GET",
107+
headers: {
108+
Authorization: `Bearer ${uiucAccessToken}`,
109+
"Content-Type": "application/json",
110+
},
111+
});
112+
113+
if (!response.ok) {
114+
throw new EntraFetchError({
115+
message: "Failed to get user's UIN.",
116+
email: "",
117+
});
118+
}
119+
120+
const data = (await response.json()) as {
121+
[genericConfig.UinExtendedAttributeName]: string;
122+
};
123+
124+
return await getUinHash({
125+
pepper,
126+
uin: data[genericConfig.UinExtendedAttributeName],
127+
});
128+
} catch (error) {
129+
if (error instanceof EntraFetchError) {
130+
throw error;
131+
}
132+
133+
throw new EntraFetchError({
134+
message: "Failed to fetch user UIN.",
135+
email: "",
136+
});
137+
}
138+
}
139+
140+
type SaveHashedUserUin = GetUserUinInputs & {
141+
dynamoClient: DynamoDBClient;
142+
netId: string;
143+
};
144+
145+
export async function saveHashedUserUin({
146+
uiucAccessToken,
147+
pepper,
148+
dynamoClient,
149+
netId,
150+
}: SaveHashedUserUin) {
151+
const uinHash = await getHashedUserUin({ uiucAccessToken, pepper });
152+
await dynamoClient.send(
153+
new PutItemCommand({
154+
TableName: genericConfig.UinHashTable,
155+
Item: marshall({
156+
uinHash,
157+
netId,
158+
updatedAt: new Date().toISOString(),
159+
}),
160+
}),
161+
);
162+
}

src/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import eventsPlugin from "./routes/events.js";
6161
import mobileWalletV2Route from "./routes/v2/mobileWallet.js";
6262
import membershipV2Plugin from "./routes/v2/membership.js";
6363
import { docsHtml, securitySchemes } from "./docs.js";
64+
import syncIdentityPlugin from "./routes/syncIdentity.js";
6465
/** END ROUTES */
6566

6667
export const instanceId = randomUUID();
@@ -356,6 +357,7 @@ Otherwise, email [infra@acm.illinois.edu](mailto:infra@acm.illinois.edu) for sup
356357
);
357358
await app.register(
358359
async (api, _options) => {
360+
api.register(syncIdentityPlugin, { prefix: "/syncIdentity" });
359361
api.register(protectedRoute, { prefix: "/protected" });
360362
api.register(eventsPlugin, { prefix: "/events" });
361363
api.register(organizationsPlugin, { prefix: "/organizations" });

src/api/routes/clearSession.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => {
2323
onRequest: fastify.authorizeFromSchema,
2424
},
2525
async (request, reply) => {
26-
reply.status(201).send();
2726
const username = [request.username!];
2827
const { redisClient } = fastify;
2928
const { log: logger } = fastify;
@@ -46,6 +45,7 @@ const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => {
4645
expiresIn,
4746
});
4847
}
48+
return reply.status(201).send();
4949
},
5050
);
5151
};

src/api/routes/membership.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -413,12 +413,8 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
413413
event.data.object.metadata.initiator === "purchase-membership"
414414
) {
415415
const customerEmail = event.data.object.customer_email;
416-
const firstName = event.data.object.custom_fields.filter(
417-
(x) => x.key === "firstName",
418-
)[0].text?.value;
419-
const lastName = event.data.object.custom_fields.filter(
420-
(x) => x.key === "lastName",
421-
)[0].text?.value;
416+
const firstName = event.data.object.metadata.givenName;
417+
const lastName = event.data.object.metadata.surname;
422418
if (!customerEmail) {
423419
request.log.info("No customer email found.");
424420
return reply

src/api/routes/syncIdentity.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
checkPaidMembershipFromTable,
3+
checkPaidMembershipFromRedis,
4+
} from "api/functions/membership.js";
5+
import { FastifyPluginAsync } from "fastify";
6+
import { ValidationError } from "common/errors/index.js";
7+
import rateLimiter from "api/plugins/rateLimiter.js";
8+
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
9+
import * as z from "zod/v4";
10+
import { notAuthenticatedError, withTags } from "api/components/index.js";
11+
import { verifyUiucAccessToken, saveHashedUserUin } from "api/functions/uin.js";
12+
import { getRoleCredentials } from "api/functions/sts.js";
13+
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
14+
import { genericConfig, roleArns } from "common/config.js";
15+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
16+
import {
17+
getEntraIdToken,
18+
patchUserProfile,
19+
resolveEmailToOid,
20+
} from "api/functions/entraId.js";
21+
22+
const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
23+
const getAuthorizedClients = async () => {
24+
if (roleArns.Entra) {
25+
fastify.log.info(
26+
`Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`,
27+
);
28+
const credentials = await getRoleCredentials(roleArns.Entra);
29+
const clients = {
30+
smClient: new SecretsManagerClient({
31+
region: genericConfig.AwsRegion,
32+
credentials,
33+
}),
34+
dynamoClient: new DynamoDBClient({
35+
region: genericConfig.AwsRegion,
36+
credentials,
37+
}),
38+
redisClient: fastify.redisClient,
39+
};
40+
fastify.log.info(
41+
`Assumed Entra role ${roleArns.Entra} to get the Entra token.`,
42+
);
43+
return clients;
44+
}
45+
fastify.log.debug(
46+
"Did not assume Entra role as no env variable was present",
47+
);
48+
return {
49+
smClient: fastify.secretsManagerClient,
50+
dynamoClient: fastify.dynamoClient,
51+
redisClient: fastify.redisClient,
52+
};
53+
};
54+
const limitedRoutes: FastifyPluginAsync = async (fastify) => {
55+
await fastify.register(rateLimiter, {
56+
limit: 5,
57+
duration: 30,
58+
rateLimitIdentifier: "syncIdentityPlugin",
59+
});
60+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
61+
"/",
62+
{
63+
schema: withTags(["Generic"], {
64+
headers: z.object({
65+
"x-uiuc-token": z.jwt().min(1).meta({
66+
description:
67+
"An access token for the user in the UIUC Entra ID tenant.",
68+
}),
69+
}),
70+
summary:
71+
"Sync the Illinois NetID account with the ACM @ UIUC account.",
72+
response: {
73+
201: {
74+
description: "The user has been synced.",
75+
content: {
76+
"application/json": {
77+
schema: z.null(),
78+
},
79+
},
80+
},
81+
403: notAuthenticatedError,
82+
},
83+
}),
84+
},
85+
async (request, reply) => {
86+
const accessToken = request.headers["x-uiuc-token"];
87+
const verifiedData = await verifyUiucAccessToken({
88+
accessToken,
89+
logger: request.log,
90+
});
91+
const { userPrincipalName: upn, givenName, surname } = verifiedData;
92+
const netId = upn.replace("@illinois.edu", "");
93+
if (netId.includes("@")) {
94+
request.log.error(
95+
`Found UPN ${upn} which cannot be turned into NetID via simple replacement.`,
96+
);
97+
throw new ValidationError({
98+
message: "ID token could not be parsed.",
99+
});
100+
}
101+
await saveHashedUserUin({
102+
uiucAccessToken: accessToken,
103+
pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER,
104+
dynamoClient: fastify.dynamoClient,
105+
netId,
106+
});
107+
let isPaidMember = await checkPaidMembershipFromRedis(
108+
netId,
109+
fastify.redisClient,
110+
request.log,
111+
);
112+
if (isPaidMember === null) {
113+
isPaidMember = await checkPaidMembershipFromTable(
114+
netId,
115+
fastify.dynamoClient,
116+
);
117+
}
118+
if (isPaidMember) {
119+
const username = `${netId}@illinois.edu`;
120+
request.log.info("User is paid member, syncing profile!");
121+
const entraIdToken = await getEntraIdToken({
122+
clients: await getAuthorizedClients(),
123+
clientId: fastify.environmentConfig.AadValidClientId,
124+
secretName: genericConfig.EntraSecretName,
125+
logger: request.log,
126+
});
127+
const oid = await resolveEmailToOid(entraIdToken, username);
128+
await patchUserProfile(entraIdToken, username, oid, {
129+
displayName: `${givenName} ${surname}`,
130+
givenName,
131+
surname,
132+
mail: username,
133+
});
134+
}
135+
return reply.status(201).send();
136+
},
137+
);
138+
};
139+
fastify.register(limitedRoutes);
140+
};
141+
142+
export default syncIdentityPlugin;

0 commit comments

Comments
 (0)