Skip to content

Commit 437fad4

Browse files
authored
Mobile Wallet Membership Pass (#44)
* tooling updates * update gitignore * functionality * fix route integration? * fix? * weird hack around build process * fix lockfile * add tests * mock something * testing * fix ses mock * add one more live test * use moment-timezone build
1 parent ba0d6b1 commit 437fad4

29 files changed

+1356
-43
lines changed

Diff for: .gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,4 @@ __pycache__
141141
/playwright-report/
142142
/blob-report/
143143
/playwright/.cache/
144+
dist_devel/

Diff for: Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ clean:
4848
rm -rf src/ui/node_modules/
4949
rm -rf dist/
5050
rm -rf dist_ui/
51+
rm -rf dist_devel/
5152

5253
build: src/ cloudformation/ docs/
5354
yarn -D
5455
VITE_BUILD_HASH=$(GIT_HASH) yarn build
56+
cp -r src/api/resources/ dist/api/resources
5557
sam build --template-file cloudformation/main.yml
5658

5759
local:

Diff for: cloudformation/iam.yml

+18-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Parameters:
1010
LambdaFunctionName:
1111
Type: String
1212
AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$
13+
SesEmailDomain:
14+
Type: String
1315
Resources:
1416
ApiLambdaIAMRole:
1517
Type: AWS::IAM::Role
@@ -24,6 +26,21 @@ Resources:
2426
Service:
2527
- lambda.amazonaws.com
2628
Policies:
29+
- PolicyDocument:
30+
Version: '2012-10-17'
31+
Statement:
32+
- Action:
33+
- ses:SendEmail
34+
- ses:SendRawEmail
35+
Effect: Allow
36+
Resource: "*"
37+
Condition:
38+
StringEquals:
39+
ses:FromAddress: !Sub "membership@${SesEmailDomain}"
40+
ForAllValues:StringLike:
41+
ses:Recipients:
42+
- "*@illinois.edu"
43+
PolicyName: ses-membership
2744
- PolicyDocument:
2845
Version: '2012-10-17'
2946
Statement:
@@ -85,4 +102,4 @@ Outputs:
85102
Value:
86103
Fn::GetAtt:
87104
- ApiLambdaIAMRole
88-
- Arn
105+
- Arn

Diff for: cloudformation/main.yml

+4-21
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ Mappings:
3232
General:
3333
dev:
3434
LogRetentionDays: 7
35+
SesDomain: "aws.qa.acmuiuc.org"
3536
prod:
3637
LogRetentionDays: 365
38+
SesDomain: "acm.illinois.edu"
3739
ApiGwConfig:
3840
dev:
3941
ApiCertificateArn: arn:aws:acm:us-east-1:427040638965:certificate/63ccdf0b-d2b5-44f0-b589-eceffb935c23
@@ -71,6 +73,7 @@ Resources:
7173
Parameters:
7274
RunEnvironment: !Ref RunEnvironment
7375
LambdaFunctionName: !Sub ${ApplicationPrefix}-lambda
76+
SesEmailDomain: !FindInMap [General, !Ref RunEnvironment, SesDomain]
7477

7578
AppLogGroups:
7679
Type: AWS::Serverless::Application
@@ -120,29 +123,9 @@ Resources:
120123
Type: AWS::Serverless::Function
121124
DependsOn:
122125
- AppLogGroups
123-
Metadata:
124-
BuildMethod: esbuild
125-
BuildProperties:
126-
Format: esm
127-
Minify: true
128-
OutExtension:
129-
- .js=.mjs
130-
Target: "es2022"
131-
Sourcemap: false
132-
EntryPoints:
133-
- api/lambda.js
134-
External:
135-
- aws-sdk
136-
Banner:
137-
- js=import path from 'path';
138-
import { fileURLToPath } from 'url';
139-
import { createRequire as topLevelCreateRequire } from 'module';
140-
const require = topLevelCreateRequire(import.meta.url);
141-
const __filename = fileURLToPath(import.meta.url);
142-
const __dirname = path.dirname(__filename);
143126
Properties:
144127
Architectures: [arm64]
145-
CodeUri: ../dist
128+
CodeUri: ../dist/lambda
146129
AutoPublishAlias: live
147130
Runtime: nodejs22.x
148131
Description: !Sub "${ApplicationFriendlyName} API Lambda"

Diff for: generate_jwt.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const payload = {
1818
groups: ["0"],
1919
idp: "https://login.microsoftonline.com",
2020
ipaddr: "192.168.1.1",
21-
name: "John Doe",
21+
name: "Doe, John",
2222
oid: "00000000-0000-0000-0000-000000000000",
2323
rh: "rh-value",
2424
scp: "user_impersonation",

Diff for: package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"scripts": {
1212
"build": "yarn workspaces run build && yarn lockfile-manage",
1313
"dev": "concurrently --names 'api,ui' 'yarn workspace infra-core-api run dev' 'yarn workspace infra-core-ui run dev'",
14-
"lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/ && cp src/api/package.json dist/ && rm package-lock.json",
14+
"lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/lambda/ && cp src/api/package.lambda.json dist/lambda/package.json && rm package-lock.json",
1515
"prettier": "yarn workspaces run prettier && prettier --check tests/**/*.ts",
1616
"prettier:write": "yarn workspaces run prettier:write && prettier --write tests/**/*.ts",
1717
"lint": "yarn workspaces run lint",

Diff for: src/api/build.js

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import esbuild from "esbuild";
2+
import { resolve } from "path";
3+
4+
esbuild
5+
.build({
6+
entryPoints: ["api/lambda.js"], // Entry file
7+
bundle: true,
8+
format: "esm",
9+
minify: true,
10+
outdir: "../../dist/lambda/",
11+
outExtension: { ".js": ".mjs" },
12+
loader: {
13+
".png": "file",
14+
".pkpass": "file",
15+
".json": "file",
16+
}, // File loaders
17+
target: "es2022", // Target ES2022
18+
sourcemap: false,
19+
platform: "node",
20+
external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify"],
21+
alias: {
22+
'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js')
23+
},
24+
banner: {
25+
js: `
26+
import path from 'path';
27+
import { fileURLToPath } from 'url';
28+
import { createRequire as topLevelCreateRequire } from 'module';
29+
const require = topLevelCreateRequire(import.meta.url);
30+
const __filename = fileURLToPath(import.meta.url);
31+
const __dirname = path.dirname(__filename);
32+
`.trim(),
33+
}, // Banner for compatibility with CommonJS
34+
})
35+
.then(() => console.log("Build completed successfully!"))
36+
.catch((error) => {
37+
console.error("Build failed:", error);
38+
process.exit(1);
39+
});

Diff for: src/api/esbuild.config.js

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { build, context } from 'esbuild';
2+
import { readFileSync } from 'fs';
3+
import { resolve } from 'path';
4+
5+
const isWatching = !!process.argv.includes('--watch')
6+
const nodePackage = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
7+
8+
const buildOptions = {
9+
entryPoints: [resolve(process.cwd(), 'index.ts')],
10+
outfile: resolve(process.cwd(), '../', '../', 'dist_devel', 'index.js'),
11+
bundle: true,
12+
platform: 'node',
13+
format: 'esm',
14+
external: [
15+
Object.keys(nodePackage.dependencies ?? {}),
16+
Object.keys(nodePackage.peerDependencies ?? {}),
17+
Object.keys(nodePackage.devDependencies ?? {}),
18+
].flat(),
19+
loader: {
20+
'.png': 'file', // Add this line to specify a loader for .png files
21+
},
22+
alias: {
23+
'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js')
24+
},
25+
banner: {
26+
js: `
27+
import path from 'path';
28+
import { fileURLToPath } from 'url';
29+
import { createRequire as topLevelCreateRequire } from 'module';
30+
const require = topLevelCreateRequire(import.meta.url);
31+
const __filename = fileURLToPath(import.meta.url);
32+
const __dirname = path.dirname(__filename);
33+
`.trim(),
34+
}, // Banner for compatibility with CommonJS
35+
};
36+
37+
if (isWatching) {
38+
context(buildOptions).then(ctx => {
39+
if (isWatching) {
40+
ctx.watch();
41+
} else {
42+
ctx.rebuild();
43+
}
44+
});
45+
} else {
46+
build(buildOptions)
47+
}

Diff for: src/api/functions/entraId.ts

+46
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../../common/config.js";
88
import {
99
BaseError,
10+
EntraFetchError,
1011
EntraGroupError,
1112
EntraInvitationError,
1213
InternalServerError,
@@ -19,6 +20,7 @@ import {
1920
EntraInvitationResponse,
2021
} from "../../common/types/iam.js";
2122
import { FastifyInstance } from "fastify";
23+
import { UserProfileDataBase } from "common/types/msGraphApi.js";
2224

2325
function validateGroupId(groupId: string): boolean {
2426
const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed
@@ -351,3 +353,47 @@ export async function listGroupMembers(
351353
});
352354
}
353355
}
356+
357+
/**
358+
* Retrieves the profile of a user from Entra ID.
359+
* @param token - Entra ID token authorized to perform this action.
360+
* @param userId - The user ID to fetch the profile for.
361+
* @throws {EntraUserError} If fetching the user profile fails.
362+
* @returns {Promise<UserProfileDataBase>} The user's profile information.
363+
*/
364+
export async function getUserProfile(
365+
token: string,
366+
email: string,
367+
): Promise<UserProfileDataBase> {
368+
const userId = await resolveEmailToOid(token, email);
369+
try {
370+
const url = `https://graph.microsoft.com/v1.0/users/${userId}?$select=userPrincipalName,givenName,surname,displayName,otherMails,mail`;
371+
const response = await fetch(url, {
372+
method: "GET",
373+
headers: {
374+
Authorization: `Bearer ${token}`,
375+
"Content-Type": "application/json",
376+
},
377+
});
378+
379+
if (!response.ok) {
380+
const errorData = (await response.json()) as {
381+
error?: { message?: string };
382+
};
383+
throw new EntraFetchError({
384+
message: errorData?.error?.message ?? response.statusText,
385+
email,
386+
});
387+
}
388+
return (await response.json()) as UserProfileDataBase;
389+
} catch (error) {
390+
if (error instanceof EntraFetchError) {
391+
throw error;
392+
}
393+
394+
throw new EntraFetchError({
395+
message: error instanceof Error ? error.message : String(error),
396+
email,
397+
});
398+
}
399+
}

Diff for: src/api/functions/membership.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { FastifyBaseLogger, FastifyInstance } from "fastify";
2+
3+
export async function checkPaidMembership(
4+
endpoint: string,
5+
log: FastifyBaseLogger,
6+
netId: string,
7+
) {
8+
const membershipApiPayload = (await (
9+
await fetch(`${endpoint}?netId=${netId}`)
10+
).json()) as { netId: string; isPaidMember: boolean };
11+
log.trace(`Got Membership API Payload for ${netId}: ${membershipApiPayload}`);
12+
try {
13+
return membershipApiPayload["isPaidMember"];
14+
} catch (e: any) {
15+
log.error(`Failed to get response from membership API: ${e.toString()}`);
16+
throw e;
17+
}
18+
}

0 commit comments

Comments
 (0)