Skip to content

Commit a009171

Browse files
authored
Entra ID support in Core API (#21)
* create cfn template for cache table * create entra ID module * create a route to invite users * fix existing unit tests * add basic tests * fix merge conflicts * fix unused import
1 parent 1cbeb7b commit a009171

File tree

16 files changed

+459
-14
lines changed

16 files changed

+459
-14
lines changed

.editorconfig

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Editor configuration, see http://editorconfig.org
2+
root = true
3+
4+
[*]
5+
charset = utf-8
6+
indent_style = space
7+
indent_size = 2
8+
insert_final_newline = true
9+
trim_trailing_whitespace = true
10+
11+
[*.md]
12+
max_line_length = off
13+
trim_trailing_whitespace = false

cloudformation/main.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,25 @@ Resources:
130130
Projection:
131131
ProjectionType: ALL
132132

133+
CacheRecordsTable:
134+
Type: 'AWS::DynamoDB::Table'
135+
DeletionPolicy: "Retain"
136+
Properties:
137+
BillingMode: 'PAY_PER_REQUEST'
138+
TableName: infra-core-api-cache
139+
DeletionProtectionEnabled: true
140+
PointInTimeRecoverySpecification:
141+
PointInTimeRecoveryEnabled: false
142+
AttributeDefinitions:
143+
- AttributeName: primaryKey
144+
AttributeType: S
145+
KeySchema:
146+
- AttributeName: primaryKey
147+
KeyType: HASH
148+
TimeToLiveSpecification:
149+
AttributeName: "expireAt"
150+
Enabled: true
151+
133152
AppApiGateway:
134153
Type: AWS::Serverless::Api
135154
DependsOn:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@aws-sdk/client-dynamodb": "^3.624.0",
5555
"@aws-sdk/client-secrets-manager": "^3.624.0",
5656
"@aws-sdk/util-dynamodb": "^3.624.0",
57+
"@azure/msal-node": "^2.16.1",
5758
"@fastify/auth": "^4.6.1",
5859
"@fastify/aws-lambda": "^4.1.0",
5960
"@fastify/cors": "^9.0.1",

src/config.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ export type ConfigType = {
1717
};
1818

1919
type GenericConfigType = {
20-
DynamoTableName: string;
20+
EventsDynamoTableName: string;
21+
CacheDynamoTableName: string;
2122
ConfigSecretName: string;
2223
UpcomingEventThresholdSeconds: number;
2324
AwsRegion: string;
25+
EntraTenantId: string;
2426
MerchStorePurchasesTableName: string;
2527
TicketPurchasesTableName: string;
2628
};
@@ -30,10 +32,12 @@ type EnvironmentConfigType = {
3032
};
3133

3234
const genericConfig: GenericConfigType = {
33-
DynamoTableName: "infra-core-api-events",
35+
EventsDynamoTableName: "infra-core-api-events",
36+
CacheDynamoTableName: "infra-core-api-cache",
3437
ConfigSecretName: "infra-core-api-config",
3538
UpcomingEventThresholdSeconds: 1800, // 30 mins
3639
AwsRegion: process.env.AWS_REGION || "us-east-1",
40+
EntraTenantId: "c8d9148f-9a59-4db3-827d-42ea0c2b6e2e",
3741
MerchStorePurchasesTableName: "infra-merchstore-purchase-history",
3842
TicketPurchasesTableName: "infra-events-tickets",
3943
} as const;
@@ -62,7 +66,10 @@ const environmentConfig: EnvironmentConfigType = {
6266
GroupRoleMapping: {
6367
"48591dbc-cdcb-4544-9f63-e6b92b067e33": allAppRoles, // Infra Chairs
6468
"ff49e948-4587-416b-8224-65147540d5fc": allAppRoles, // Officers
65-
"ad81254b-4eeb-4c96-8191-3acdce9194b1": [AppRoles.EVENTS_MANAGER], // Exec
69+
"ad81254b-4eeb-4c96-8191-3acdce9194b1": [
70+
AppRoles.EVENTS_MANAGER,
71+
AppRoles.SSO_INVITE_USER,
72+
], // Exec
6673
},
6774
AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] },
6875
ValidCorsOrigins: [
@@ -79,6 +86,8 @@ export type SecretConfig = {
7986
jwt_key?: string;
8087
discord_guild_id: string;
8188
discord_bot_token: string;
89+
entra_id_private_key: string;
90+
entra_id_thumbprint: string;
8291
};
8392

8493
export { genericConfig, environmentConfig };

src/errors/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ export class DiscordEventError extends BaseError<"DiscordEventError"> {
123123
}
124124
}
125125

126+
export class EntraInvitationError extends BaseError<"EntraInvitationError"> {
127+
email: string;
128+
constructor({ message, email }: { message?: string; email: string }) {
129+
super({
130+
name: "EntraInvitationError",
131+
id: 108,
132+
message: message || "Could not invite user to Entra ID.",
133+
httpStatusCode: 500,
134+
});
135+
this.email = email;
136+
}
137+
}
138+
126139
export class TicketNotFoundError extends BaseError<"TicketNotFoundError"> {
127140
constructor({ message }: { message?: string }) {
128141
super({

src/functions/cache.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
DynamoDBClient,
3+
PutItemCommand,
4+
QueryCommand,
5+
} from "@aws-sdk/client-dynamodb";
6+
import { genericConfig } from "../config.js";
7+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
8+
9+
const dynamoClient = new DynamoDBClient({
10+
region: genericConfig.AwsRegion,
11+
});
12+
13+
export async function getItemFromCache(
14+
key: string,
15+
): Promise<null | Record<string, string | number>> {
16+
const currentTime = Math.floor(Date.now() / 1000);
17+
const { Items } = await dynamoClient.send(
18+
new QueryCommand({
19+
TableName: genericConfig.CacheDynamoTableName,
20+
KeyConditionExpression: "#pk = :pk",
21+
FilterExpression: "#ea > :ea",
22+
ExpressionAttributeNames: {
23+
"#pk": "primaryKey",
24+
"#ea": "expireAt",
25+
},
26+
ExpressionAttributeValues: marshall({
27+
":pk": key,
28+
":ea": currentTime,
29+
}),
30+
}),
31+
);
32+
if (!Items || Items.length == 0) {
33+
return null;
34+
}
35+
const item = unmarshall(Items[0]);
36+
return item;
37+
}
38+
39+
export async function insertItemIntoCache(
40+
key: string,
41+
value: Record<string, string | number>,
42+
expireAt: Date,
43+
) {
44+
const item = {
45+
primaryKey: key,
46+
expireAt: Math.floor(expireAt.getTime() / 1000),
47+
...value,
48+
};
49+
50+
await dynamoClient.send(
51+
new PutItemCommand({
52+
TableName: genericConfig.CacheDynamoTableName,
53+
Item: marshall(item),
54+
}),
55+
);
56+
}

src/functions/entraId.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { genericConfig } from "../config.js";
2+
import { EntraInvitationError, InternalServerError } from "../errors/index.js";
3+
import { getSecretValue } from "../plugins/auth.js";
4+
import { ConfidentialClientApplication } from "@azure/msal-node";
5+
import { getItemFromCache, insertItemIntoCache } from "./cache.js";
6+
7+
interface EntraInvitationResponse {
8+
status: number;
9+
data?: Record<string, string>;
10+
error?: {
11+
message: string;
12+
code?: string;
13+
};
14+
}
15+
export async function getEntraIdToken(
16+
clientId: string,
17+
scopes: string[] = ["https://graph.microsoft.com/.default"],
18+
) {
19+
const secretApiConfig =
20+
(await getSecretValue(genericConfig.ConfigSecretName)) || {};
21+
if (
22+
!secretApiConfig.entra_id_private_key ||
23+
!secretApiConfig.entra_id_thumbprint
24+
) {
25+
throw new InternalServerError({
26+
message: "Could not find Entra ID credentials.",
27+
});
28+
}
29+
const decodedPrivateKey = Buffer.from(
30+
secretApiConfig.entra_id_private_key as string,
31+
"base64",
32+
).toString("utf8");
33+
const cachedToken = await getItemFromCache("entra_id_access_token");
34+
if (cachedToken) {
35+
return cachedToken["token"] as string;
36+
}
37+
const config = {
38+
auth: {
39+
clientId: clientId,
40+
authority: `https://login.microsoftonline.com/${genericConfig.EntraTenantId}`,
41+
clientCertificate: {
42+
thumbprint: (secretApiConfig.entra_id_thumbprint as string) || "",
43+
privateKey: decodedPrivateKey,
44+
},
45+
},
46+
};
47+
const cca = new ConfidentialClientApplication(config);
48+
try {
49+
const result = await cca.acquireTokenByClientCredential({
50+
scopes,
51+
});
52+
const date = result?.expiresOn;
53+
if (!date) {
54+
throw new InternalServerError({
55+
message: `Failed to acquire token: token has no expiry field.`,
56+
});
57+
}
58+
date.setTime(date.getTime() - 30000);
59+
if (result?.accessToken) {
60+
await insertItemIntoCache(
61+
"entra_id_access_token",
62+
{ token: result?.accessToken },
63+
date,
64+
);
65+
}
66+
return result?.accessToken ?? null;
67+
} catch (error) {
68+
throw new InternalServerError({
69+
message: `Failed to acquire token: ${error}`,
70+
});
71+
}
72+
}
73+
74+
/**
75+
* Adds a user to the tenant by sending an invitation to their email
76+
* @param email - The email address of the user to invite
77+
* @throws {InternalServerError} If the invitation fails
78+
* @returns {Promise<boolean>} True if the invitation was successful
79+
*/
80+
export async function addToTenant(token: string, email: string) {
81+
email = email.toLowerCase().replace(/\s/g, "");
82+
if (!email.endsWith("@illinois.edu")) {
83+
throw new EntraInvitationError({
84+
email,
85+
message: "User's domain must be illinois.edu to be invited.",
86+
});
87+
}
88+
try {
89+
const body = {
90+
invitedUserEmailAddress: email,
91+
inviteRedirectUrl: "https://acm.illinois.edu",
92+
};
93+
const url = "https://graph.microsoft.com/v1.0/invitations";
94+
const response = await fetch(url, {
95+
method: "POST",
96+
headers: {
97+
Authorization: `Bearer ${token}`,
98+
"Content-Type": "application/json",
99+
},
100+
body: JSON.stringify(body),
101+
});
102+
103+
if (!response.ok) {
104+
const errorData = (await response.json()) as EntraInvitationResponse;
105+
throw new EntraInvitationError({
106+
message: errorData.error?.message || response.statusText,
107+
email,
108+
});
109+
}
110+
111+
return { success: true, email };
112+
} catch (error) {
113+
if (error instanceof EntraInvitationError) {
114+
throw error;
115+
}
116+
117+
throw new EntraInvitationError({
118+
message: error instanceof Error ? error.message : String(error),
119+
email,
120+
});
121+
}
122+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import organizationsPlugin from "./routes/organizations.js";
1515
import icalPlugin from "./routes/ics.js";
1616
import vendingPlugin from "./routes/vending.js";
1717
import * as dotenv from "dotenv";
18+
import ssoManagementRoute from "./routes/sso.js";
1819
import ticketsPlugin from "./routes/tickets.js";
1920
dotenv.config();
2021

@@ -72,6 +73,7 @@ async function init() {
7273
api.register(eventsPlugin, { prefix: "/events" });
7374
api.register(organizationsPlugin, { prefix: "/organizations" });
7475
api.register(icalPlugin, { prefix: "/ical" });
76+
api.register(ssoManagementRoute, { prefix: "/sso" });
7577
api.register(ticketsPlugin, { prefix: "/tickets" });
7678
if (app.runEnvironment === "dev") {
7779
api.register(vendingPlugin, { prefix: "/vending" });

src/plugins/auth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
UnauthenticatedError,
1414
UnauthorizedError,
1515
} from "../errors/index.js";
16-
import { genericConfig } from "../config.js";
16+
import { genericConfig, SecretConfig } from "../config.js";
1717

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

5858
export const getSecretValue = async (
5959
secretId: string,
60-
): Promise<Record<string, string | number | boolean> | null> => {
60+
): Promise<Record<string, string | number | boolean> | null | SecretConfig> => {
6161
const data = await smClient.send(
6262
new GetSecretValueCommand({ SecretId: secretId }),
6363
);

src/roles.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const runEnvironments = ["dev", "prod"] as const;
33
export type RunEnvironment = (typeof runEnvironments)[number];
44
export enum AppRoles {
55
EVENTS_MANAGER = "manage:events",
6+
SSO_INVITE_USER = "invite:sso",
67
TICKET_SCANNER = "scan:tickets",
78
}
89
export const allAppRoles = Object.values(AppRoles).filter(

0 commit comments

Comments
 (0)