Skip to content

Commit 5b5bc7a

Browse files
committed
save uin on membership checkout and handle firstname/lastname transparently
1 parent ff128dd commit 5b5bc7a

File tree

6 files changed

+188
-40
lines changed

6 files changed

+188
-40
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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
2+
import { marshall } from "@aws-sdk/util-dynamodb";
13
import { hash } from "argon2";
24
import { genericConfig } from "common/config.js";
35
import {
@@ -134,3 +136,26 @@ export async function getHashedUserUin({
134136
});
135137
}
136138
}
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+
}),
159+
}),
160+
);
161+
}

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 [[email protected]](mailto:[email protected]) 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/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;

src/api/routes/v2/membership.ts

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,7 @@ import { createCheckoutSession } from "api/functions/stripe.js";
99
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
1010
import * as z from "zod/v4";
1111
import { notAuthenticatedError, withTags } from "api/components/index.js";
12-
import { verifyUiucAccessToken, getHashedUserUin } from "api/functions/uin.js";
13-
14-
function splitOnce(s: string, on: string) {
15-
const [first, ...rest] = s.split(on);
16-
return [first, rest.length > 0 ? rest.join(on) : null];
17-
}
18-
function trim(s: string) {
19-
return (s || "").replace(/^\s+|\s+$/g, "");
20-
}
12+
import { verifyUiucAccessToken, saveHashedUserUin } from "api/functions/uin.js";
2113

2214
const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
2315
const limitedRoutes: FastifyPluginAsync = async (fastify) => {
@@ -70,6 +62,13 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
7062
message: "ID token could not be parsed.",
7163
});
7264
}
65+
request.log.debug("Saving user hashed UIN!");
66+
const saveHashPromise = saveHashedUserUin({
67+
uiucAccessToken: accessToken,
68+
pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER,
69+
dynamoClient: fastify.dynamoClient,
70+
netId,
71+
});
7372
let isPaidMember = await checkPaidMembershipFromRedis(
7473
netId,
7574
fastify.redisClient,
@@ -81,12 +80,13 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
8180
fastify.dynamoClient,
8281
);
8382
}
83+
await saveHashPromise;
84+
request.log.debug("Saved user hashed UIN!");
8485
if (isPaidMember) {
8586
throw new ValidationError({
8687
message: `${upn} is already a paid member.`,
8788
});
8889
}
89-
9090
return reply.status(200).send(
9191
await createCheckoutSession({
9292
successUrl: "https://acm.illinois.edu/paid",
@@ -99,30 +99,10 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
9999
quantity: 1,
100100
},
101101
],
102-
customFields: [
103-
{
104-
key: "firstName",
105-
label: {
106-
type: "custom",
107-
custom: "Member First Name",
108-
},
109-
type: "text",
110-
text: {
111-
default_value: givenName,
112-
},
113-
},
114-
{
115-
key: "lastName",
116-
label: {
117-
type: "custom",
118-
custom: "Member Last Name",
119-
},
120-
type: "text",
121-
text: {
122-
default_value: surname,
123-
},
124-
},
125-
],
102+
metadata: {
103+
givenName,
104+
surname,
105+
},
126106
initiator: "purchase-membership",
127107
allowPromotionCodes: true,
128108
}),

0 commit comments

Comments
 (0)