Skip to content

Apple Wallet pass issuer support #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,4 @@ __pycache__
/playwright-report/
/blob-report/
/playwright/.cache/
dist_devel/
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ clean:
build: src/ cloudformation/ docs/
yarn -D
VITE_BUILD_HASH=$(GIT_HASH) yarn build
cp -r src/api/resources/ dist/api/resources
sam build --template-file cloudformation/main.yml

local:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ This repository is split into multiple parts:
You will need node>=22 installed, as well as the AWS CLI and the AWS SAM CLI. The best way to work with all of this is to open the environment in a container within your IDE (VS Code should prompt you to do so: use "Clone in Container" for best performance). This container will have all needed software installed.

Then, run `make install` to install all packages, and `make local` to start the UI and API servers! The UI will be accessible on `http://localhost:5173/` and the API on `http://localhost:8080/`.

See the [README for the API server](src/api/README.md) as well.
20 changes: 19 additions & 1 deletion cloudformation/iam.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Parameters:
LambdaFunctionName:
Type: String
AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$
SesEmailDomain:
Type: String

Resources:
ApiLambdaIAMRole:
Type: AWS::IAM::Role
Expand All @@ -24,6 +27,21 @@ Resources:
Service:
- lambda.amazonaws.com
Policies:
- PolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- ses:SendEmail
- ses:SendRawEmail
Effect: Allow
Resource: "*"
Condition:
StringEquals:
ses:FromAddress: !Sub "membership@${SesEmailDomain}"
ForAllValues:StringLike:
ses:Recipients:
- "*@illinois.edu"
PolicyName: ses-membership
- PolicyDocument:
Version: '2012-10-17'
Statement:
Expand Down Expand Up @@ -85,4 +103,4 @@ Outputs:
Value:
Fn::GetAtt:
- ApiLambdaIAMRole
- Arn
- Arn
7 changes: 7 additions & 0 deletions cloudformation/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ Mappings:
General:
dev:
LogRetentionDays: 7
SesDomain: "aws.qa.acmuiuc.org"
prod:
LogRetentionDays: 365
SesDomain: "acm.illinois.edu"
ApiGwConfig:
dev:
ApiCertificateArn: arn:aws:acm:us-east-1:427040638965:certificate/63ccdf0b-d2b5-44f0-b589-eceffb935c23
Expand Down Expand Up @@ -71,6 +73,7 @@ Resources:
Parameters:
RunEnvironment: !Ref RunEnvironment
LambdaFunctionName: !Sub ${ApplicationPrefix}-lambda
SesEmailDomain: !FindInMap [General, !Ref RunEnvironment, SesDomain]

AppLogGroups:
Type: AWS::Serverless::Application
Expand Down Expand Up @@ -127,6 +130,10 @@ Resources:
Minify: true
OutExtension:
- .js=.mjs
Loader:
- .png=file
- .pkpass=file
- .json=file
Target: "es2022"
Sourcemap: false
EntryPoints:
Expand Down
2 changes: 1 addition & 1 deletion generate_jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const payload = {
groups: ["0"],
idp: "https://login.microsoftonline.com",
ipaddr: "192.168.1.1",
name: "John Doe",
name: "Singh, Dev",
oid: "00000000-0000-0000-0000-000000000000",
rh: "rh-value",
scp: "user_impersonation",
Expand Down
34 changes: 34 additions & 0 deletions src/api/esbuild.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { build, context } from 'esbuild';
import { readFileSync } from 'fs';
import { resolve } from 'path';

const isWatching = !!process.argv.includes('--watch')
const nodePackage = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));

const buildOptions = {
entryPoints: [resolve(process.cwd(), 'index.ts')],
outfile: resolve(process.cwd(), '../', '../', 'dist_devel', 'index.js'),
bundle: true,
platform: 'node',
format: 'esm',
external: [
Object.keys(nodePackage.dependencies ?? {}),
Object.keys(nodePackage.peerDependencies ?? {}),
Object.keys(nodePackage.devDependencies ?? {}),
].flat(),
loader: {
'.png': 'file', // Add this line to specify a loader for .png files
},
};

if (isWatching) {
context(buildOptions).then(ctx => {
if (isWatching) {
ctx.watch();
} else {
ctx.rebuild();
}
});
} else {
build(buildOptions)
}
16 changes: 13 additions & 3 deletions src/api/functions/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { type EventPostRequest } from "../routes/events.js";
import moment from "moment-timezone";

import { FastifyBaseLogger } from "fastify";
import { DiscordEventError } from "../../common/errors/index.js";
import {
DiscordEventError,
InternalServerError,
} from "../../common/errors/index.js";
import { getSecretValue } from "../plugins/auth.js";
import { genericConfig } from "../../common/config.js";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
Expand All @@ -30,8 +33,15 @@ export const updateDiscord = async (
isDelete: boolean = false,
logger: FastifyBaseLogger,
): Promise<null | GuildScheduledEventCreateOptions> => {
const secretApiConfig =
(await getSecretValue(smClient, genericConfig.ConfigSecretName)) || {};
const secretApiConfig = await getSecretValue(
smClient,
genericConfig.ConfigSecretName,
);
if (!secretApiConfig) {
throw new InternalServerError({
message: "Could not find credentials for Discord.",
});
}
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
let payload: GuildScheduledEventCreateOptions | null = null;

Expand Down
58 changes: 52 additions & 6 deletions src/api/functions/entraId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
officersGroupTestingId,
} from "../../common/config.js";
import {
BaseError,

Check warning on line 9 in src/api/functions/entraId.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'BaseError' is defined but never used. Allowed unused vars must match /^_/u
EntraFetchError,
EntraGroupError,
EntraInvitationError,
InternalServerError,
Expand All @@ -19,6 +20,7 @@
EntraInvitationResponse,
} from "../../common/types/iam.js";
import { FastifyInstance } from "fastify";
import { UserProfileDataBase } from "common/types/msGraphApi.js";

function validateGroupId(groupId: string): boolean {
const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed
Expand All @@ -30,12 +32,12 @@
clientId: string,
scopes: string[] = ["https://graph.microsoft.com/.default"],
) {
const secretApiConfig =
(await getSecretValue(
fastify.secretsManagerClient,
genericConfig.ConfigSecretName,
)) || {};
const secretApiConfig = await getSecretValue(
fastify.secretsManagerClient,
genericConfig.ConfigSecretName,
);
if (
!secretApiConfig ||
!secretApiConfig.entra_id_private_key ||
!secretApiConfig.entra_id_thumbprint
) {
Expand Down Expand Up @@ -178,7 +180,7 @@
};

if (!data.value || data.value.length === 0) {
throw new Error(`No user found with email: ${email}`);
throw new EntraFetchError({ message: "No user found with email", email });
}

return data.value[0].id;
Expand Down Expand Up @@ -351,3 +353,47 @@
});
}
}

/**
* Retrieves the profile of a user from Entra ID.
* @param token - Entra ID token authorized to perform this action.
* @param userId - The user ID to fetch the profile for.
* @throws {EntraUserError} If fetching the user profile fails.
* @returns {Promise<UserProfileDataBase>} The user's profile information.
*/
export async function getUserProfile(
token: string,
email: string,
): Promise<UserProfileDataBase> {
const userId = await resolveEmailToOid(token, email);
try {
const url = `https://graph.microsoft.com/v1.0/users/${userId}?$select=userPrincipalName,givenName,surname,displayName,otherMails,mail`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorData = (await response.json()) as {
error?: { message?: string };
};
throw new EntraFetchError({
message: errorData?.error?.message ?? response.statusText,
email,
});
}
return (await response.json()) as UserProfileDataBase;
} catch (error) {
if (error instanceof EntraFetchError) {
throw error;
}

throw new EntraFetchError({
message: error instanceof Error ? error.message : String(error),
email,
});
}
}
18 changes: 18 additions & 0 deletions src/api/functions/membership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { FastifyBaseLogger, FastifyInstance } from "fastify";

Check warning on line 1 in src/api/functions/membership.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'FastifyInstance' is defined but never used. Allowed unused vars must match /^_/u

export async function checkPaidMembership(
endpoint: string,
log: FastifyBaseLogger,
netId: string,
) {
const membershipApiPayload = (await (
await fetch(`${endpoint}?netId=${netId}`)
).json()) as { netId: string; isPaidMember: boolean };
log.trace(`Got Membership API Payload for ${netId}: ${membershipApiPayload}`);
try {
return membershipApiPayload["isPaidMember"];
} catch (e: any) {

Check warning on line 14 in src/api/functions/membership.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Unexpected any. Specify a different type
log.error(`Failed to get response from membership API: ${e.toString()}`);
throw e;
}
}
117 changes: 117 additions & 0 deletions src/api/functions/mobileWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { getSecretValue } from "../plugins/auth.js";
import { genericConfig } from "../../common/config.js";
import {
InternalServerError,
UnauthorizedError,
} from "../../common/errors/index.js";
import { FastifyInstance, FastifyRequest } from "fastify";
// these make sure that esbuild includes the files
import icon from "../resources/MembershipPass.pkpass/icon.png";
import logo from "../resources/MembershipPass.pkpass/logo.png";
import strip from "../resources/MembershipPass.pkpass/strip.png";
import pass from "../resources/MembershipPass.pkpass/pass.js";
import { PKPass } from "passkit-generator";
import { promises as fs } from "fs";

function trim(s: string) {
return (s || "").replace(/^\s+|\s+$/g, "");
}

function convertName(name: string): string {
if (!name.includes(",")) {
return name;
}
return `${trim(name.split(",")[1])} ${name.split(",")[0]}`;
}

export async function issueAppleWalletMembershipCard(
app: FastifyInstance,
request: FastifyRequest,
email: string,
name?: string,
) {
if (!email.endsWith("@illinois.edu")) {
throw new UnauthorizedError({
message:
"Cannot issue membership pass for emails not on the illinois.edu domain.",
});
}
const secretApiConfig = await getSecretValue(
app.secretsManagerClient,
genericConfig.ConfigSecretName,
);
if (!secretApiConfig) {
throw new InternalServerError({
message: "Could not retrieve signing data",
});
}
const signerCert = Buffer.from(
secretApiConfig.acm_passkit_signerCert_base64,
"base64",
).toString("utf-8");
const signerKey = Buffer.from(
secretApiConfig.acm_passkit_signerKey_base64,
"base64",
).toString("utf-8");
const wwdr = Buffer.from(
secretApiConfig.apple_signing_cert_base64,
"base64",
).toString("utf-8");
pass["passTypeIdentifier"] = app.environmentConfig["PasskitIdentifier"];

const pkpass = new PKPass(
{
"icon.png": await fs.readFile(icon),
"logo.png": await fs.readFile(logo),
"strip.png": await fs.readFile(strip),
"pass.json": Buffer.from(JSON.stringify(pass)),
},
{
wwdr,
signerCert,
signerKey,
},
{
// logoText: app.runEnvironment === "dev" ? "INVALID Membership Pass" : "Membership Pass",
serialNumber: app.environmentConfig["PasskitSerialNumber"],
},
);
pkpass.setBarcodes({
altText: email.split("@")[0],
format: "PKBarcodeFormatPDF417",
message: app.runEnvironment === "dev" ? `INVALID${email}INVALID` : email,
});
const iat = new Date().toLocaleDateString("en-US", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
if (name && name !== "") {
pkpass.secondaryFields.push({
label: "Member Name",
key: "name",
value: convertName(name),
});
}
if (app.runEnvironment === "prod") {
pkpass.backFields.push({
label: "Verification URL",
key: "iss",
value: `https://membership.acm.illinois.edu/verify/${email.split("@")[0]}`,
});
} else {
pkpass.backFields.push({
label: "TESTING ONLY Pass",
key: "iss",
value: `Do not honor!`,
});
}
pkpass.backFields.push({ label: "Pass Created On", key: "iat", value: iat });
pkpass.backFields.push({ label: "Membership ID", key: "id", value: email });
const buffer = pkpass.getAsBuffer();
request.log.info(
{ type: "audit", actor: email, target: email },
"Created membership verification pass",
);
return buffer;
}
Loading
Loading