diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b4e30165 --- /dev/null +++ b/.editorconfig @@ -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 \ No newline at end of file diff --git a/cloudformation/main.yml b/cloudformation/main.yml index bbe01c02..46a55c9a 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -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: diff --git a/package.json b/package.json index 88ed6ddf..0ce01ace 100644 --- a/package.json +++ b/package.json @@ -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.1", "@fastify/auth": "^4.6.1", "@fastify/aws-lambda": "^4.1.0", "@fastify/cors": "^9.0.1", diff --git a/src/config.ts b/src/config.ts index 8bd25a05..ede801e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; }; @@ -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; @@ -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: [ @@ -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 }; diff --git a/src/errors/index.ts b/src/errors/index.ts index 7e05339f..38e1bea6 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -123,6 +123,19 @@ export class DiscordEventError extends BaseError<"DiscordEventError"> { } } +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({ diff --git a/src/functions/cache.ts b/src/functions/cache.ts new file mode 100644 index 00000000..b3a053eb --- /dev/null +++ b/src/functions/cache.ts @@ -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> { + 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, + expireAt: Date, +) { + const item = { + primaryKey: key, + expireAt: Math.floor(expireAt.getTime() / 1000), + ...value, + }; + + await dynamoClient.send( + new PutItemCommand({ + TableName: genericConfig.CacheDynamoTableName, + Item: marshall(item), + }), + ); +} diff --git a/src/functions/entraId.ts b/src/functions/entraId.ts new file mode 100644 index 00000000..709f3798 --- /dev/null +++ b/src/functions/entraId.ts @@ -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; + 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} 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, + }); + } +} diff --git a/src/index.ts b/src/index.ts index cdab0802..683b5929 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); @@ -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" }); diff --git a/src/plugins/auth.ts b/src/plugins/auth.ts index 988c4f6e..c55bb5d3 100644 --- a/src/plugins/auth.ts +++ b/src/plugins/auth.ts @@ -13,7 +13,7 @@ import { UnauthenticatedError, UnauthorizedError, } from "../errors/index.js"; -import { genericConfig } from "../config.js"; +import { genericConfig, SecretConfig } from "../config.js"; function intersection(setA: Set, setB: Set): Set { const _intersection = new Set(); @@ -57,7 +57,7 @@ const smClient = new SecretsManagerClient({ export const getSecretValue = async ( secretId: string, -): Promise | null> => { +): Promise | null | SecretConfig> => { const data = await smClient.send( new GetSecretValueCommand({ SecretId: secretId }), ); diff --git a/src/roles.ts b/src/roles.ts index af077f23..476cfa7d 100644 --- a/src/roles.ts +++ b/src/roles.ts @@ -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( diff --git a/src/routes/events.ts b/src/routes/events.ts index 1bd49983..e6ad22ea 100644 --- a/src/routes/events.ts +++ b/src/routes/events.ts @@ -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 } }, }), ); @@ -130,7 +130,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { }; await dynamoClient.send( new PutItemCommand({ - TableName: genericConfig.DynamoTableName, + TableName: genericConfig.EventsDynamoTableName, Item: marshall(entry), }), ); @@ -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, }), ); @@ -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", @@ -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 }), }), ); @@ -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"); diff --git a/src/routes/ics.ts b/src/routes/ics.ts index cff787a1..aa1f6cc7 100644 --- a/src/routes/ics.ts +++ b/src/routes/ics.ts @@ -42,7 +42,7 @@ const icalPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.get("/:host?", async (request, reply) => { const host = (request.params as Record).host; let queryParams: QueryCommandInput = { - TableName: genericConfig.DynamoTableName, + TableName: genericConfig.EventsDynamoTableName, }; let response; if (host) { diff --git a/src/routes/sso.ts b/src/routes/sso.ts new file mode 100644 index 00000000..4c622da9 --- /dev/null +++ b/src/routes/sso.ts @@ -0,0 +1,75 @@ +import { FastifyPluginAsync } from "fastify"; +import { AppRoles } from "../roles.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { addToTenant, getEntraIdToken } from "../functions/entraId.js"; +import { EntraInvitationError, InternalServerError } from "../errors/index.js"; + +const invitePostRequestSchema = z.object({ + emails: z.array(z.string()), +}); +export type InviteUserPostRequest = z.infer; + +const invitePostResponseSchema = zodToJsonSchema( + z.object({ + success: z.array(z.object({ email: z.string() })).optional(), + failure: z + .array(z.object({ email: z.string(), message: z.string() })) + .optional(), + }), +); + +const ssoManagementRoute: FastifyPluginAsync = async (fastify, _options) => { + fastify.post<{ Body: InviteUserPostRequest }>( + "/inviteUsers", + { + schema: { + response: { 200: invitePostResponseSchema }, + }, + preValidation: async (request, reply) => { + await fastify.zodValidateBody(request, reply, invitePostRequestSchema); + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.SSO_INVITE_USER]); + }, + }, + async (request, reply) => { + const emails = request.body.emails; + const entraIdToken = await getEntraIdToken( + fastify.environmentConfig.AadValidClientId, + ); + if (!entraIdToken) { + throw new InternalServerError({ + message: "Could not get Entra ID token to perform task.", + }); + } + const response: Record[]> = { + success: [], + failure: [], + }; + const results = await Promise.allSettled( + emails.map((email) => addToTenant(entraIdToken, email)), + ); + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === "fulfilled") { + response.success.push({ email: emails[i] }); + } else { + if (result.reason instanceof EntraInvitationError) { + response.failure.push({ + email: emails[i], + message: result.reason.message, + }); + } + } + } + let statusCode = 201; + if (response.success.length === 0) { + statusCode = 500; + } + reply.status(statusCode).send(response); + }, + ); +}; + +export default ssoManagementRoute; diff --git a/tests/unit/entraInviteUser.test.ts b/tests/unit/entraInviteUser.test.ts new file mode 100644 index 00000000..59a04ab7 --- /dev/null +++ b/tests/unit/entraInviteUser.test.ts @@ -0,0 +1,113 @@ +import { afterAll, expect, test, beforeEach, vi } from "vitest"; +import { mockClient } from "aws-sdk-client-mock"; +import init from "../../src/index.js"; +import { createJwt } from "./auth.test.js"; +import { secretJson, secretObject } from "./secret.testdata.js"; +import supertest from "supertest"; +import { describe } from "node:test"; +import { + GetSecretValueCommand, + SecretsManagerClient, +} from "@aws-sdk/client-secrets-manager"; + +vi.mock("../../src/functions/entraId.js", () => { + return { + ...vi.importActual("../../src/functions/entraId.js"), + getEntraIdToken: vi.fn().mockImplementation(async () => { + return "ey.test.token"; + }), + addToTenant: vi.fn().mockImplementation(async (email) => { + console.log("FUCK", email); + return { success: true, email: "testing@illinois.edu" }; + }), + }; +}); + +import { addToTenant, getEntraIdToken } from "../../src/functions/entraId.js"; +import { EntraInvitationError } from "../../src/errors/index.js"; + +const smMock = mockClient(SecretsManagerClient); +const jwt_secret = secretObject["jwt_key"]; + +vi.stubEnv("JwtSigningKey", jwt_secret); + +const app = await init(); + +describe("Test Microsoft Entra ID user invitation", () => { + test("Emails must end in @illinois.edu.", async () => { + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + const testJwt = createJwt(); + await app.ready(); + + const response = await supertest(app.server) + .post("/api/v1/sso/inviteUsers") + .set("authorization", `Bearer ${testJwt}`) + .send({ + emails: ["someone@testing.acmuiuc.org"], + }); + expect(response.statusCode).toBe(500); + expect(getEntraIdToken).toHaveBeenCalled(); + expect(addToTenant).toHaveBeenCalled(); + }); + test("Happy path", async () => { + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + const testJwt = createJwt(); + await app.ready(); + + const response = await supertest(app.server) + .post("/api/v1/sso/inviteUsers") + .set("authorization", `Bearer ${testJwt}`) + .send({ + emails: ["someone@illinois.edu"], + }); + expect(response.statusCode).toBe(201); + expect(getEntraIdToken).toHaveBeenCalled(); + expect(addToTenant).toHaveBeenCalled(); + }); + test("Happy path", async () => { + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + const testJwt = createJwt(); + await app.ready(); + + const response = await supertest(app.server) + .post("/api/v1/sso/inviteUsers") + .set("authorization", `Bearer ${testJwt}`) + .send({ + emails: ["someone@illinois.edu"], + }); + expect(response.statusCode).toBe(201); + expect(getEntraIdToken).toHaveBeenCalled(); + expect(addToTenant).toHaveBeenCalled(); + }); + afterAll(async () => { + await app.close(); + vi.useRealTimers(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + // Re-implement the mock + (getEntraIdToken as any).mockImplementation(async () => { + return "ey.test.token"; + }); + (addToTenant as any).mockImplementation( + async (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.", + }); + } + return { success: true, email: "testing@illinois.edu" }; + }, + ); + }); +}); diff --git a/tests/unit/secret.testdata.ts b/tests/unit/secret.testdata.ts index a0670ecc..bdb0beb1 100644 --- a/tests/unit/secret.testdata.ts +++ b/tests/unit/secret.testdata.ts @@ -4,6 +4,8 @@ const secretObject = { jwt_key: "somethingreallysecret", discord_guild_id: "12345", discord_bot_token: "12345", + entra_id_private_key: "", + entra_id_thumbprint: "", } as SecretConfig & { jwt_key: string }; const secretJson = JSON.stringify(secretObject); diff --git a/yarn.lock b/yarn.lock index f2fbe031..d3091991 100644 --- a/yarn.lock +++ b/yarn.lock @@ -525,6 +525,20 @@ "@smithy/types" "^3.3.0" tslib "^2.6.2" +"@azure/msal-common@14.16.0": + version "14.16.0" + resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.16.0.tgz#f3470fcaec788dbe50859952cd499340bda23d7a" + integrity sha512-1KOZj9IpcDSwpNiQNjt0jDYZpQvNZay7QAEi/5DLubay40iGYtLzya/jbjRPLyOTZhEKyL1MzPuw2HqBCjceYA== + +"@azure/msal-node@^2.16.1": + version "2.16.1" + resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.16.1.tgz#89828832e8e6c8a88cecc4ef6d8d4e4352116b77" + integrity sha512-1NEFpTmMMT2A7RnZuvRl/hUmJU+GLPjh+ShyIqPktG2PvSd2yvPnzGd/BxIBAAvJG5nr9lH4oYcQXepDbaE7fg== + dependencies: + "@azure/msal-common" "14.16.0" + jsonwebtoken "^9.0.0" + uuid "^8.3.0" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.24.7": version "7.24.7" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz" @@ -4033,7 +4047,7 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" -jsonwebtoken@^9.0.2: +jsonwebtoken@^9.0.0, jsonwebtoken@^9.0.2: version "9.0.2" resolved "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz" integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== @@ -5673,6 +5687,11 @@ uuid@^3.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^8.3.0: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz"