Skip to content

Commit 0108829

Browse files
committed
Get UIUC access tokens so we can hash UINs
1 parent f97e226 commit 0108829

File tree

6 files changed

+181
-111
lines changed

6 files changed

+181
-111
lines changed

src/api/functions/uin.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { hash } from "argon2";
2+
import { genericConfig } from "common/config.js";
3+
import {
4+
BaseError,
5+
EntraFetchError,
6+
InternalServerError,
7+
UnauthenticatedError,
8+
ValidationError,
9+
} from "common/errors/index.js";
10+
import { type FastifyBaseLogger } from "fastify";
11+
12+
export type HashUinInputs = {
13+
pepper: string;
14+
uin: string;
15+
};
16+
17+
export type GetUserUinInputs = {
18+
uiucAccessToken: string;
19+
pepper: string;
20+
};
21+
22+
export const verifyUiucAccessToken = async ({
23+
accessToken,
24+
logger,
25+
}: {
26+
accessToken: string | string[] | undefined;
27+
logger: FastifyBaseLogger;
28+
}) => {
29+
if (!accessToken) {
30+
throw new UnauthenticatedError({
31+
message: "Access token not found.",
32+
});
33+
}
34+
if (Array.isArray(accessToken)) {
35+
throw new ValidationError({
36+
message: "Multiple tokens cannot be specified!",
37+
});
38+
}
39+
const url =
40+
"https://graph.microsoft.com/v1.0/me?$select=userPrincipalName,givenName,surname,mail";
41+
42+
try {
43+
const response = await fetch(url, {
44+
method: "GET",
45+
headers: {
46+
Authorization: `Bearer ${accessToken}`,
47+
"Content-Type": "application/json",
48+
},
49+
});
50+
51+
if (response.status === 401) {
52+
const errorText = await response.text();
53+
logger.warn(`Microsoft Graph API unauthenticated response: ${errorText}`);
54+
throw new UnauthenticatedError({
55+
message: "Invalid or expired access token.",
56+
});
57+
}
58+
59+
if (!response.ok) {
60+
const errorText = await response.text();
61+
logger.error(
62+
`Microsoft Graph API error: ${response.status} - ${errorText}`,
63+
);
64+
throw new InternalServerError({
65+
message: "Failed to contact Microsoft Graph API.",
66+
});
67+
}
68+
69+
const data = (await response.json()) as {
70+
userPrincipalName: string;
71+
givenName: string;
72+
surname: string;
73+
mail: string;
74+
};
75+
logger.info("Access token successfully verified with Microsoft Graph API.");
76+
return data;
77+
} catch (error) {
78+
if (error instanceof BaseError) {
79+
throw error;
80+
} else {
81+
logger.error(error);
82+
throw new InternalServerError({
83+
message:
84+
"An unexpected error occurred during access token verification.",
85+
});
86+
}
87+
}
88+
};
89+
90+
export async function getUinHash({
91+
pepper,
92+
uin,
93+
}: HashUinInputs): Promise<string> {
94+
return hash(uin, { salt: Buffer.from(pepper) });
95+
}
96+
97+
export async function getHashedUserUin({
98+
uiucAccessToken,
99+
pepper,
100+
}: GetUserUinInputs): Promise<string> {
101+
const url = `https://graph.microsoft.com/v1.0/me?$select=${genericConfig.UinExtendedAttributeName}`;
102+
try {
103+
const response = await fetch(url, {
104+
method: "GET",
105+
headers: {
106+
Authorization: `Bearer ${uiucAccessToken}`,
107+
"Content-Type": "application/json",
108+
},
109+
});
110+
111+
if (!response.ok) {
112+
throw new EntraFetchError({
113+
message: "Failed to get user's UIN.",
114+
email: "",
115+
});
116+
}
117+
118+
const data = (await response.json()) as {
119+
[genericConfig.UinExtendedAttributeName]: string;
120+
};
121+
122+
return await getUinHash({
123+
pepper,
124+
uin: data[genericConfig.UinExtendedAttributeName],
125+
});
126+
} catch (error) {
127+
if (error instanceof EntraFetchError) {
128+
throw error;
129+
}
130+
131+
throw new EntraFetchError({
132+
message: "Failed to fetch user UIN.",
133+
email: "",
134+
});
135+
}
136+
}

src/api/routes/v2/membership.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { createCheckoutSession } from "api/functions/stripe.js";
99
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
1010
import * as z from "zod/v4";
1111
import { notAuthenticatedError, withTags } from "api/components/index.js";
12-
import { verifyUiucIdToken } from "./mobileWallet.js";
12+
import { verifyUiucAccessToken, getHashedUserUin } from "api/functions/uin.js";
1313

1414
function splitOnce(s: string, on: string) {
1515
const [first, ...rest] = s.split(on);
@@ -55,13 +55,12 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
5555
}),
5656
},
5757
async (request, reply) => {
58-
const idToken = request.headers["x-uiuc-token"];
59-
const verifiedData = await verifyUiucIdToken({
60-
idToken,
61-
redisClient: fastify.redisClient,
58+
const accessToken = request.headers["x-uiuc-token"];
59+
const verifiedData = await verifyUiucAccessToken({
60+
accessToken,
6261
logger: request.log,
6362
});
64-
const { preferred_username: upn, email, name } = verifiedData;
63+
const { userPrincipalName: upn, givenName, surname } = verifiedData;
6564
const netId = upn.replace("@illinois.edu", "");
6665
if (netId.includes("@")) {
6766
request.log.error(
@@ -87,15 +86,6 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
8786
message: `${upn} is already a paid member.`,
8887
});
8988
}
90-
let firstName: string = "";
91-
let lastName: string = "";
92-
if (!name.includes(",")) {
93-
const splitted = splitOnce(name, " ");
94-
firstName = splitted[0] || "";
95-
lastName = splitted[1] || "";
96-
}
97-
firstName = trim(name.split(",")[1]);
98-
lastName = name.split(",")[0];
9989

10090
return reply.status(200).send(
10191
await createCheckoutSession({
@@ -118,7 +108,7 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
118108
},
119109
type: "text",
120110
text: {
121-
default_value: firstName,
111+
default_value: givenName,
122112
},
123113
},
124114
{
@@ -129,7 +119,7 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
129119
},
130120
type: "text",
131121
text: {
132-
default_value: lastName,
122+
default_value: surname,
133123
},
134124
},
135125
],

src/api/routes/v2/mobileWallet.ts

Lines changed: 6 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -11,88 +11,9 @@ import {
1111
import rateLimiter from "api/plugins/rateLimiter.js";
1212
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
1313
import { notAuthenticatedError, withTags } from "api/components/index.js";
14-
import jwt, { Algorithm } from "jsonwebtoken";
15-
import { getJwksKey } from "api/plugins/auth.js";
1614
import { issueAppleWalletMembershipCard } from "api/functions/mobileWallet.js";
17-
import { Redis } from "api/types.js";
1815
import { Readable } from "stream";
19-
20-
const UIUC_TENANT_ID = "44467e6f-462c-4ea2-823f-7800de5434e3";
21-
const COULD_NOT_PARSE_MESSAGE = "ID token could not be parsed.";
22-
23-
export const verifyUiucIdToken = async ({
24-
idToken,
25-
redisClient,
26-
logger,
27-
}: {
28-
idToken: string | string[] | undefined;
29-
redisClient: Redis;
30-
logger: FastifyBaseLogger;
31-
}) => {
32-
if (!idToken) {
33-
throw new UnauthenticatedError({
34-
message: "ID token not found.",
35-
});
36-
}
37-
if (Array.isArray(idToken)) {
38-
throw new ValidationError({
39-
message: "Multiple tokens cannot be specified!",
40-
});
41-
}
42-
const decoded = jwt.decode(idToken, { complete: true });
43-
if (!decoded) {
44-
throw new UnauthenticatedError({
45-
message: COULD_NOT_PARSE_MESSAGE,
46-
});
47-
}
48-
const header = decoded?.header;
49-
if (!header.kid) {
50-
throw new UnauthenticatedError({
51-
message: COULD_NOT_PARSE_MESSAGE,
52-
});
53-
}
54-
const signingKey = await getJwksKey({
55-
redisClient,
56-
kid: header.kid,
57-
logger,
58-
});
59-
const verifyOptions: jwt.VerifyOptions = {
60-
algorithms: ["RS256" as Algorithm],
61-
issuer: `https://login.microsoftonline.com/${UIUC_TENANT_ID}/v2.0`,
62-
};
63-
let verifiedData;
64-
try {
65-
verifiedData = jwt.verify(idToken, signingKey, verifyOptions) as {
66-
preferred_username?: string;
67-
email?: string;
68-
name?: string;
69-
};
70-
} catch (e) {
71-
if (e instanceof Error && e.name === "TokenExpiredError") {
72-
throw new UnauthenticatedError({
73-
message: "Access token has expired.",
74-
});
75-
}
76-
if (e instanceof Error && e.name === "JsonWebTokenError") {
77-
logger.error(e);
78-
throw new UnauthenticatedError({
79-
message: COULD_NOT_PARSE_MESSAGE,
80-
});
81-
}
82-
throw e;
83-
}
84-
const { preferred_username: upn, email, name } = verifiedData;
85-
if (!upn || !email || !name) {
86-
throw new UnauthenticatedError({
87-
message: COULD_NOT_PARSE_MESSAGE,
88-
});
89-
}
90-
return verifiedData as {
91-
preferred_username: string;
92-
email: string;
93-
name: string;
94-
};
95-
};
16+
import { verifyUiucAccessToken } from "api/functions/uin.js";
9617

9718
const mobileWalletV2Route: FastifyPluginAsync = async (fastify, _options) => {
9819
fastify.register(rateLimiter, {
@@ -127,13 +48,12 @@ const mobileWalletV2Route: FastifyPluginAsync = async (fastify, _options) => {
12748
}),
12849
},
12950
async (request, reply) => {
130-
const idToken = request.headers["x-uiuc-token"];
131-
const verifiedData = await verifyUiucIdToken({
132-
idToken,
133-
redisClient: fastify.redisClient,
51+
const accessToken = request.headers["x-uiuc-token"];
52+
const verifiedData = await verifyUiucAccessToken({
53+
accessToken,
13454
logger: request.log,
13555
});
136-
const { preferred_username: upn, name } = verifiedData;
56+
const { userPrincipalName: upn, givenName, surname } = verifiedData;
13757
const netId = upn.replace("@illinois.edu", "");
13858
if (netId.includes("@")) {
13959
request.log.error(
@@ -168,7 +88,7 @@ const mobileWalletV2Route: FastifyPluginAsync = async (fastify, _options) => {
16888
upn,
16989
upn,
17090
request.log,
171-
name,
91+
`${givenName} ${surname}`,
17292
);
17393
const myStream = new Readable({
17494
read() {

src/common/config.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export type GenericConfigType = {
5454
ApiKeyTable: string;
5555
ConfigSecretName: string;
5656
TestingCredentialsSecret: string;
57+
UinHashingSecret: string;
58+
UinExtendedAttributeName: string;
5759
};
5860

5961
type EnvironmentConfigType = {
@@ -92,6 +94,8 @@ const genericConfig: GenericConfigType = {
9294
ApiKeyTable: "infra-core-api-keys",
9395
ConfigSecretName: "infra-core-api-config",
9496
TestingCredentialsSecret: "infra-core-api-testing-credentials",
97+
UinHashingSecret: "infra-core-api-uin-pepper",
98+
UinExtendedAttributeName: "extension_a70c2e1556954056a6a8edfb1f42f556_uiucEduUIN"
9599
} as const;
96100

97101
const environmentConfig: EnvironmentConfigType = {
@@ -104,7 +108,7 @@ const environmentConfig: EnvironmentConfigType = {
104108
/^https:\/\/(?:.*\.)?acmuiuc\.pages\.dev$/,
105109
/http:\/\/localhost:\d+$/,
106110
],
107-
ConfigurationSecretIds: [genericConfig.TestingCredentialsSecret, genericConfig.ConfigSecretName],
111+
ConfigurationSecretIds: [genericConfig.TestingCredentialsSecret, genericConfig.ConfigSecretName, genericConfig.UinHashingSecret],
108112
AadValidClientId: "39c28870-94e4-47ee-b4fb-affe0bf96c9f",
109113
LinkryBaseUrl: "https://core.aws.qa.acmuiuc.org",
110114
PasskitIdentifier: "pass.org.acmuiuc.qa.membership",
@@ -124,7 +128,7 @@ const environmentConfig: EnvironmentConfigType = {
124128
prod: {
125129
UserFacingUrl: "https://core.acm.illinois.edu",
126130
AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] },
127-
ConfigurationSecretIds: [genericConfig.ConfigSecretName],
131+
ConfigurationSecretIds: [genericConfig.ConfigSecretName, genericConfig.UinHashingSecret],
128132
ValidCorsOrigins: [
129133
/^https:\/\/(?:.*\.)?acmuiuc-academic-web\.pages\.dev$/,
130134
/^https:\/\/(?:.*\.)?acmuiuc\.pages\.dev$/,
@@ -160,6 +164,7 @@ export type SecretConfig = {
160164
stripe_links_endpoint_secret: string;
161165
redis_url: string;
162166
encryption_key: string;
167+
UIN_HASHING_SECRET_PEPPER: string;
163168
};
164169

165170
export type SecretTesting = {

tests/unit/secret.testdata.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const testSecretObject = {
2121
const secretJson = JSON.stringify(secretObject);
2222

2323
const testSecretJson = JSON.stringify(testSecretObject);
24+
const uinSecretJson = JSON.stringify({
25+
UIN_HASHING_SECRET_PEPPER: "dc1f1a24-fce5-480b-a342-e7bd34d8f8c5",
26+
});
2427

2528
const jwtPayload = {
2629
aud: "custom_jwt",
@@ -81,4 +84,5 @@ export {
8184
testSecretObject,
8285
jwtPayload,
8386
jwtPayloadNoGroups,
87+
uinSecretJson,
8488
};

0 commit comments

Comments
 (0)