Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/api/functions/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type StripeCheckoutSessionCreateParams = {
stripeApiKey: string;
items: { price: string; quantity: number }[];
initiator: string;
metadata?: Record<string, string>;
allowPromotionCodes: boolean;
customFields?: Stripe.Checkout.SessionCreateParams.CustomField[];
};
Expand Down Expand Up @@ -77,6 +78,7 @@ export const createCheckoutSession = async ({
initiator,
allowPromotionCodes,
customFields,
metadata,
}: StripeCheckoutSessionCreateParams): Promise<string> => {
const stripe = new Stripe(stripeApiKey);
const payload: Stripe.Checkout.SessionCreateParams = {
Expand All @@ -90,6 +92,7 @@ export const createCheckoutSession = async ({
mode: "payment",
customer_email: customerEmail,
metadata: {
...(metadata || {}),
initiator,
},
allow_promotion_codes: allowPromotionCodes,
Expand Down
162 changes: 162 additions & 0 deletions src/api/functions/uin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
import { hash } from "argon2";
import { genericConfig } from "common/config.js";
import {
BaseError,
EntraFetchError,
InternalServerError,
UnauthenticatedError,
ValidationError,
} from "common/errors/index.js";
import { type FastifyBaseLogger } from "fastify";

export type HashUinInputs = {
pepper: string;
uin: string;
};

export type GetUserUinInputs = {
uiucAccessToken: string;
pepper: string;
};

export const verifyUiucAccessToken = async ({
accessToken,
logger,
}: {
accessToken: string | string[] | undefined;
logger: FastifyBaseLogger;
}) => {
if (!accessToken) {
throw new UnauthenticatedError({
message: "Access token not found.",
});
}
if (Array.isArray(accessToken)) {
throw new ValidationError({
message: "Multiple tokens cannot be specified!",
});
}
const url =
"https://graph.microsoft.com/v1.0/me?$select=userPrincipalName,givenName,surname,mail";

try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});

if (response.status === 401) {
const errorText = await response.text();
logger.warn(`Microsoft Graph API unauthenticated response: ${errorText}`);
throw new UnauthenticatedError({
message: "Invalid or expired access token.",
});
}

if (!response.ok) {
const errorText = await response.text();
logger.error(
`Microsoft Graph API error: ${response.status} - ${errorText}`,
);
throw new InternalServerError({
message: "Failed to contact Microsoft Graph API.",
});
}

const data = (await response.json()) as {
userPrincipalName: string;
givenName: string;
surname: string;
mail: string;
};
logger.info("Access token successfully verified with Microsoft Graph API.");
return data;
} catch (error) {
if (error instanceof BaseError) {
throw error;
} else {
logger.error(error);
throw new InternalServerError({
message:
"An unexpected error occurred during access token verification.",
});
}
}
};

export async function getUinHash({
pepper,
uin,
}: HashUinInputs): Promise<string> {
return hash(uin, { salt: Buffer.from(pepper) });
}

export async function getHashedUserUin({
uiucAccessToken,
pepper,
}: GetUserUinInputs): Promise<string> {
const url = `https://graph.microsoft.com/v1.0/me?$select=${genericConfig.UinExtendedAttributeName}`;
try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${uiucAccessToken}`,
"Content-Type": "application/json",
},
});

if (!response.ok) {
throw new EntraFetchError({
message: "Failed to get user's UIN.",
email: "",
});
}

const data = (await response.json()) as {
[genericConfig.UinExtendedAttributeName]: string;
};

return await getUinHash({
pepper,
uin: data[genericConfig.UinExtendedAttributeName],
});
} catch (error) {
if (error instanceof EntraFetchError) {
throw error;
}

throw new EntraFetchError({
message: "Failed to fetch user UIN.",
email: "",
});
}
}

type SaveHashedUserUin = GetUserUinInputs & {
dynamoClient: DynamoDBClient;
netId: string;
};

export async function saveHashedUserUin({
uiucAccessToken,
pepper,
dynamoClient,
netId,
}: SaveHashedUserUin) {
const uinHash = await getHashedUserUin({ uiucAccessToken, pepper });
await dynamoClient.send(
new PutItemCommand({
TableName: genericConfig.UinHashTable,
Item: marshall({
uinHash,
netId,
updatedAt: new Date().toISOString(),
}),
}),
);
}
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import eventsPlugin from "./routes/events.js";
import mobileWalletV2Route from "./routes/v2/mobileWallet.js";
import membershipV2Plugin from "./routes/v2/membership.js";
import { docsHtml, securitySchemes } from "./docs.js";
import syncIdentityPlugin from "./routes/syncIdentity.js";
/** END ROUTES */

export const instanceId = randomUUID();
Expand Down Expand Up @@ -356,6 +357,7 @@ Otherwise, email [[email protected]](mailto:[email protected]) for sup
);
await app.register(
async (api, _options) => {
api.register(syncIdentityPlugin, { prefix: "/syncIdentity" });
api.register(protectedRoute, { prefix: "/protected" });
api.register(eventsPlugin, { prefix: "/events" });
api.register(organizationsPlugin, { prefix: "/organizations" });
Expand Down
2 changes: 1 addition & 1 deletion src/api/routes/clearSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => {
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
reply.status(201).send();
const username = [request.username!];
const { redisClient } = fastify;
const { log: logger } = fastify;
Expand All @@ -46,6 +45,7 @@ const clearSessionPlugin: FastifyPluginAsync = async (fastify, _options) => {
expiresIn,
});
}
return reply.status(201).send();
},
);
};
Expand Down
8 changes: 2 additions & 6 deletions src/api/routes/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,12 +413,8 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
event.data.object.metadata.initiator === "purchase-membership"
) {
const customerEmail = event.data.object.customer_email;
const firstName = event.data.object.custom_fields.filter(
(x) => x.key === "firstName",
)[0].text?.value;
const lastName = event.data.object.custom_fields.filter(
(x) => x.key === "lastName",
)[0].text?.value;
const firstName = event.data.object.metadata.givenName;
const lastName = event.data.object.metadata.surname;
if (!customerEmail) {
request.log.info("No customer email found.");
return reply
Expand Down
142 changes: 142 additions & 0 deletions src/api/routes/syncIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
checkPaidMembershipFromTable,
checkPaidMembershipFromRedis,
} from "api/functions/membership.js";
import { FastifyPluginAsync } from "fastify";
import { ValidationError } from "common/errors/index.js";
import rateLimiter from "api/plugins/rateLimiter.js";
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
import * as z from "zod/v4";
import { notAuthenticatedError, withTags } from "api/components/index.js";
import { verifyUiucAccessToken, saveHashedUserUin } from "api/functions/uin.js";
import { getRoleCredentials } from "api/functions/sts.js";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import { genericConfig, roleArns } from "common/config.js";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
getEntraIdToken,
patchUserProfile,
resolveEmailToOid,
} from "api/functions/entraId.js";

const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
const getAuthorizedClients = async () => {
if (roleArns.Entra) {
fastify.log.info(
`Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`,
);
const credentials = await getRoleCredentials(roleArns.Entra);
const clients = {
smClient: new SecretsManagerClient({
region: genericConfig.AwsRegion,
credentials,
}),
dynamoClient: new DynamoDBClient({
region: genericConfig.AwsRegion,
credentials,
}),
redisClient: fastify.redisClient,
};
fastify.log.info(
`Assumed Entra role ${roleArns.Entra} to get the Entra token.`,
);
return clients;
}
fastify.log.debug(
"Did not assume Entra role as no env variable was present",
);
return {
smClient: fastify.secretsManagerClient,
dynamoClient: fastify.dynamoClient,
redisClient: fastify.redisClient,
};
};
const limitedRoutes: FastifyPluginAsync = async (fastify) => {
await fastify.register(rateLimiter, {
limit: 5,
duration: 30,
rateLimitIdentifier: "syncIdentityPlugin",
});
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
"/",
{
schema: withTags(["Generic"], {
headers: z.object({
"x-uiuc-token": z.jwt().min(1).meta({
description:
"An access token for the user in the UIUC Entra ID tenant.",
}),
}),
summary:
"Sync the Illinois NetID account with the ACM @ UIUC account.",
response: {
201: {
description: "The user has been synced.",
content: {
"application/json": {
schema: z.null(),
},
},
},
403: notAuthenticatedError,
},
}),
},
async (request, reply) => {
const accessToken = request.headers["x-uiuc-token"];
const verifiedData = await verifyUiucAccessToken({
accessToken,
logger: request.log,
});
const { userPrincipalName: upn, givenName, surname } = verifiedData;
const netId = upn.replace("@illinois.edu", "");
if (netId.includes("@")) {
request.log.error(
`Found UPN ${upn} which cannot be turned into NetID via simple replacement.`,
);
throw new ValidationError({
message: "ID token could not be parsed.",
});
}
await saveHashedUserUin({
uiucAccessToken: accessToken,
pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER,
dynamoClient: fastify.dynamoClient,
netId,
});
let isPaidMember = await checkPaidMembershipFromRedis(
netId,
fastify.redisClient,
request.log,
);
if (isPaidMember === null) {
isPaidMember = await checkPaidMembershipFromTable(
netId,
fastify.dynamoClient,
);
}
if (isPaidMember) {
const username = `${netId}@illinois.edu`;
request.log.info("User is paid member, syncing profile!");
const entraIdToken = await getEntraIdToken({
clients: await getAuthorizedClients(),
clientId: fastify.environmentConfig.AadValidClientId,
secretName: genericConfig.EntraSecretName,
logger: request.log,
});
const oid = await resolveEmailToOid(entraIdToken, username);
await patchUserProfile(entraIdToken, username, oid, {
displayName: `${givenName} ${surname}`,
givenName,
surname,
mail: username,
});
}
return reply.status(201).send();
},
);
};
fastify.register(limitedRoutes);
};

export default syncIdentityPlugin;
Loading
Loading