Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
13 changes: 13 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
max_line_length = off
trim_trailing_whitespace = false
19 changes: 19 additions & 0 deletions cloudformation/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,25 @@ Resources:
Projection:
ProjectionType: ALL

CacheRecordsTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: "Retain"
Properties:
BillingMode: 'PAY_PER_REQUEST'
TableName: infra-core-api-cache
DeletionProtectionEnabled: true
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: false
AttributeDefinitions:
- AttributeName: primaryKey
AttributeType: S
KeySchema:
- AttributeName: primaryKey
KeyType: HASH
TimeToLiveSpecification:
AttributeName: "expireAt"
Enabled: true

AppApiGateway:
Type: AWS::Serverless::Api
DependsOn:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@aws-sdk/client-dynamodb": "^3.624.0",
"@aws-sdk/client-secrets-manager": "^3.624.0",
"@aws-sdk/util-dynamodb": "^3.624.0",
"@azure/msal-node": "^2.16.0",
"@fastify/auth": "^4.6.1",
"@fastify/aws-lambda": "^4.1.0",
"@fastify/cors": "^9.0.1",
Expand Down
15 changes: 12 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ export type ConfigType = {
};

type GenericConfigType = {
DynamoTableName: string;
EventsDynamoTableName: string;
CacheDynamoTableName: string;
ConfigSecretName: string;
UpcomingEventThresholdSeconds: number;
AwsRegion: string;
EntraTenantId: string;
MerchStorePurchasesTableName: string;
TicketPurchasesTableName: string;
};
Expand All @@ -30,10 +32,12 @@ type EnvironmentConfigType = {
};

const genericConfig: GenericConfigType = {
DynamoTableName: "infra-core-api-events",
EventsDynamoTableName: "infra-core-api-events",
CacheDynamoTableName: "infra-core-api-cache",
ConfigSecretName: "infra-core-api-config",
UpcomingEventThresholdSeconds: 1800, // 30 mins
AwsRegion: process.env.AWS_REGION || "us-east-1",
EntraTenantId: "c8d9148f-9a59-4db3-827d-42ea0c2b6e2e",
MerchStorePurchasesTableName: "infra-merchstore-purchase-history",
TicketPurchasesTableName: "infra-events-tickets",
} as const;
Expand Down Expand Up @@ -62,7 +66,10 @@ const environmentConfig: EnvironmentConfigType = {
GroupRoleMapping: {
"48591dbc-cdcb-4544-9f63-e6b92b067e33": allAppRoles, // Infra Chairs
"ff49e948-4587-416b-8224-65147540d5fc": allAppRoles, // Officers
"ad81254b-4eeb-4c96-8191-3acdce9194b1": [AppRoles.EVENTS_MANAGER], // Exec
"ad81254b-4eeb-4c96-8191-3acdce9194b1": [
AppRoles.EVENTS_MANAGER,
AppRoles.SSO_INVITE_USER,
], // Exec
},
AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] },
ValidCorsOrigins: [
Expand All @@ -79,6 +86,8 @@ export type SecretConfig = {
jwt_key?: string;
discord_guild_id: string;
discord_bot_token: string;
entra_id_private_key: string;
entra_id_thumbprint: string;
};

export { genericConfig, environmentConfig };
10 changes: 10 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@
}
}

export class EntraInvitationError extends BaseError<"EntraInvitationError"> {
email: string;
constructor({ message, email }: { message?: string; email: string }) {
super({
name: "EntraInvitationError",
id: 108,
message: message || "Could not invite user to Entra ID.",
httpStatusCode: 500,
});
this.email = email;
export class TicketNotFoundError extends BaseError<"TicketNotFoundError"> {
constructor({ message }: { message?: string }) {
super({
Expand All @@ -144,3 +154,3 @@
});
}
}
56 changes: 56 additions & 0 deletions src/functions/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
DynamoDBClient,
PutItemCommand,
QueryCommand,
} from "@aws-sdk/client-dynamodb";
import { genericConfig } from "../config.js";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";

const dynamoClient = new DynamoDBClient({
region: genericConfig.AwsRegion,
});

export async function getItemFromCache(
key: string,
): Promise<null | Record<string, string | number>> {
const currentTime = Math.floor(Date.now() / 1000);
const { Items } = await dynamoClient.send(
new QueryCommand({
TableName: genericConfig.CacheDynamoTableName,
KeyConditionExpression: "#pk = :pk",
FilterExpression: "#ea > :ea",
ExpressionAttributeNames: {
"#pk": "primaryKey",
"#ea": "expireAt",
},
ExpressionAttributeValues: marshall({
":pk": key,
":ea": currentTime,
}),
}),
);
if (!Items || Items.length == 0) {
return null;
}
const item = unmarshall(Items[0]);
return item;
}

export async function insertItemIntoCache(
key: string,
value: Record<string, string | number>,
expireAt: Date,
) {
const item = {
primaryKey: key,
expireAt: Math.floor(expireAt.getTime() / 1000),
...value,
};

await dynamoClient.send(
new PutItemCommand({
TableName: genericConfig.CacheDynamoTableName,
Item: marshall(item),
}),
);
}
122 changes: 122 additions & 0 deletions src/functions/entraId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { genericConfig } from "../config.js";
import { EntraInvitationError, InternalServerError } from "../errors/index.js";
import { getSecretValue } from "../plugins/auth.js";
import { ConfidentialClientApplication } from "@azure/msal-node";
import { getItemFromCache, insertItemIntoCache } from "./cache.js";

interface EntraInvitationResponse {
status: number;
data?: Record<string, string>;
error?: {
message: string;
code?: string;
};
}
export async function getEntraIdToken(
clientId: string,
scopes: string[] = ["https://graph.microsoft.com/.default"],
) {
const secretApiConfig =
(await getSecretValue(genericConfig.ConfigSecretName)) || {};
if (
!secretApiConfig.entra_id_private_key ||
!secretApiConfig.entra_id_thumbprint
) {
throw new InternalServerError({
message: "Could not find Entra ID credentials.",
});
}
const decodedPrivateKey = Buffer.from(
secretApiConfig.entra_id_private_key as string,
"base64",
).toString("utf8");
const cachedToken = await getItemFromCache("entra_id_access_token");
if (cachedToken) {
return cachedToken["token"] as string;
}
const config = {
auth: {
clientId: clientId,
authority: `https://login.microsoftonline.com/${genericConfig.EntraTenantId}`,
clientCertificate: {
thumbprint: (secretApiConfig.entra_id_thumbprint as string) || "",
privateKey: decodedPrivateKey,
},
},
};
const cca = new ConfidentialClientApplication(config);
try {
const result = await cca.acquireTokenByClientCredential({
scopes,
});
const date = result?.expiresOn;
if (!date) {
throw new InternalServerError({
message: `Failed to acquire token: token has no expiry field.`,
});
}
date.setTime(date.getTime() - 30000);
if (result?.accessToken) {
await insertItemIntoCache(
"entra_id_access_token",
{ token: result?.accessToken },
date,
);
}
return result?.accessToken ?? null;
} catch (error) {
throw new InternalServerError({
message: `Failed to acquire token: ${error}`,
});
}
}

/**
* Adds a user to the tenant by sending an invitation to their email
* @param email - The email address of the user to invite
* @throws {InternalServerError} If the invitation fails
* @returns {Promise<boolean>} True if the invitation was successful
*/
export async function addToTenant(token: string, email: string) {
email = email.toLowerCase().replace(/\s/g, "");
if (!email.endsWith("@illinois.edu")) {
throw new EntraInvitationError({
email,
message: "User's domain must be illinois.edu to be invited.",
});
}
try {
const body = {
invitedUserEmailAddress: email,
inviteRedirectUrl: "https://acm.illinois.edu",
};
const url = "https://graph.microsoft.com/v1.0/invitations";
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});

if (!response.ok) {
const errorData = (await response.json()) as EntraInvitationResponse;
throw new EntraInvitationError({
message: errorData.error?.message || response.statusText,
email,
});
}

return { success: true, email };
} catch (error) {
if (error instanceof EntraInvitationError) {
throw error;
}

throw new EntraInvitationError({
message: error instanceof Error ? error.message : String(error),
email,
});
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import organizationsPlugin from "./routes/organizations.js";
import icalPlugin from "./routes/ics.js";
import vendingPlugin from "./routes/vending.js";
import * as dotenv from "dotenv";
import ssoManagementRoute from "./routes/sso.js";
import ticketsPlugin from "./routes/tickets.js";
dotenv.config();

Expand Down Expand Up @@ -72,6 +73,7 @@ async function init() {
api.register(eventsPlugin, { prefix: "/events" });
api.register(organizationsPlugin, { prefix: "/organizations" });
api.register(icalPlugin, { prefix: "/ical" });
api.register(ssoManagementRoute, { prefix: "/sso" });
api.register(ticketsPlugin, { prefix: "/tickets" });
if (app.runEnvironment === "dev") {
api.register(vendingPlugin, { prefix: "/vending" });
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
UnauthenticatedError,
UnauthorizedError,
} from "../errors/index.js";
import { genericConfig } from "../config.js";
import { genericConfig, SecretConfig } from "../config.js";

function intersection<T>(setA: Set<T>, setB: Set<T>): Set<T> {
const _intersection = new Set<T>();
Expand Down Expand Up @@ -57,7 +57,7 @@ const smClient = new SecretsManagerClient({

export const getSecretValue = async (
secretId: string,
): Promise<Record<string, string | number | boolean> | null> => {
): Promise<Record<string, string | number | boolean> | null | SecretConfig> => {
const data = await smClient.send(
new GetSecretValueCommand({ SecretId: secretId }),
);
Expand Down
1 change: 1 addition & 0 deletions src/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const runEnvironments = ["dev", "prod"] as const;
export type RunEnvironment = (typeof runEnvironments)[number];
export enum AppRoles {
EVENTS_MANAGER = "manage:events",
SSO_INVITE_USER = "invite:sso",
TICKET_SCANNER = "scan:tickets",
}
export const allAppRoles = Object.values(AppRoles).filter(
Expand Down
14 changes: 7 additions & 7 deletions src/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
if (userProvidedId) {
const response = await dynamoClient.send(
new GetItemCommand({
TableName: genericConfig.DynamoTableName,
TableName: genericConfig.EventsDynamoTableName,
Key: { id: { S: userProvidedId } },
}),
);
Expand All @@ -130,7 +130,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
};
await dynamoClient.send(
new PutItemCommand({
TableName: genericConfig.DynamoTableName,
TableName: genericConfig.EventsDynamoTableName,
Item: marshall(entry),
}),
);
Expand All @@ -143,14 +143,14 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
// restore original DB status if Discord fails.
await dynamoClient.send(
new DeleteItemCommand({
TableName: genericConfig.DynamoTableName,
TableName: genericConfig.EventsDynamoTableName,
Key: { id: { S: entryUUID } },
}),
);
if (userProvidedId) {
await dynamoClient.send(
new PutItemCommand({
TableName: genericConfig.DynamoTableName,
TableName: genericConfig.EventsDynamoTableName,
Item: originalEvent,
}),
);
Expand Down Expand Up @@ -194,7 +194,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
try {
const response = await dynamoClient.send(
new QueryCommand({
TableName: genericConfig.DynamoTableName,
TableName: genericConfig.EventsDynamoTableName,
KeyConditionExpression: "#id = :id",
ExpressionAttributeNames: {
"#id": "id",
Expand Down Expand Up @@ -237,7 +237,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
try {
await dynamoClient.send(
new DeleteItemCommand({
TableName: genericConfig.DynamoTableName,
TableName: genericConfig.EventsDynamoTableName,
Key: marshall({ id }),
}),
);
Expand Down Expand Up @@ -274,7 +274,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
const upcomingOnly = request.query?.upcomingOnly || false;
try {
const response = await dynamoClient.send(
new ScanCommand({ TableName: genericConfig.DynamoTableName }),
new ScanCommand({ TableName: genericConfig.EventsDynamoTableName }),
);
const items = response.Items?.map((item) => unmarshall(item));
const currentTimeChicago = moment().tz("America/Chicago");
Expand Down
Loading
Loading