Skip to content

Commit 530cddc

Browse files
authored
Build API Key support (#125)
* build basic api key support * set appropriate request data * fix table schema * add unit tests * move argon2 external to aws lambda * build AWS SAM in container * enable docker support in the container * use x86_64 runners :( * fix AWS_CRT path * fix cloudformation * code updates * fix compile errors * fix cfn error * update wording * add api key delete route * support and basic live tests * fix delete http status codes * add unit tests for API key route * remove logging statements * add transaction creator tests * fix tests * add UI and basic UI tests
1 parent 6d5d640 commit 530cddc

39 files changed

+1931
-106
lines changed

.devcontainer/devcontainer.json

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,39 @@
11
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
22
// README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
33
{
4-
"name": "Ubuntu",
5-
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6-
"image": "mcr.microsoft.com/devcontainers/base:jammy",
7-
"features": {
8-
"ghcr.io/devcontainers/features/node:1": {},
4+
"name": "Ubuntu",
5+
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6+
"image": "mcr.microsoft.com/devcontainers/base:jammy",
7+
"features": {
8+
"ghcr.io/devcontainers/features/node:1": {},
99
"ghcr.io/devcontainers/features/aws-cli:1": {},
10-
"ghcr.io/jungaretti/features/make:1": {},
11-
"ghcr.io/customink/codespaces-features/sam-cli:1": {},
12-
"ghcr.io/devcontainers/features/python:1": {}
13-
},
10+
"ghcr.io/jungaretti/features/make:1": {},
11+
"ghcr.io/customink/codespaces-features/sam-cli:1": {},
12+
"ghcr.io/devcontainers/features/python:1": {},
13+
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
14+
},
1415

15-
// Features to add to the dev container. More info: https://containers.dev/features.
16-
// "features": {},
16+
// Features to add to the dev container. More info: https://containers.dev/features.
17+
// "features": {},
1718

18-
// Use 'forwardPorts' to make a list of ports inside the container available locally.
19-
"forwardPorts": [
20-
8080,
21-
5173
22-
],
23-
"customizations": {
24-
"vscode": {
25-
"extensions": [
26-
"EditorConfig.EditorConfig",
27-
"waderyan.gitblame",
28-
"Gruntfuggly.todo-tree"
29-
]
30-
}
31-
}
19+
// Use 'forwardPorts' to make a list of ports inside the container available locally.
20+
"forwardPorts": [8080, 5173],
21+
"customizations": {
22+
"vscode": {
23+
"extensions": [
24+
"EditorConfig.EditorConfig",
25+
"waderyan.gitblame",
26+
"Gruntfuggly.todo-tree"
27+
]
28+
}
29+
}
3230

33-
// Use 'postCreateCommand' to run commands after the container is created.
34-
// "postCreateCommand": "uname -a",
31+
// Use 'postCreateCommand' to run commands after the container is created.
32+
// "postCreateCommand": "uname -a",
3533

36-
// Configure tool-specific properties.
37-
// "customizations": {},
34+
// Configure tool-specific properties.
35+
// "customizations": {},
3836

39-
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
40-
// "remoteUser": "root"
37+
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
38+
// "remoteUser": "root"
4139
}

.github/workflows/deploy-prod.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ jobs:
4242
uses: actions/setup-node@v4
4343
with:
4444
node-version: 22.x
45+
4546
- uses: actions/checkout@v4
4647
env:
4748
HUSKY: "0"
@@ -90,6 +91,7 @@ jobs:
9091
uses: actions/setup-node@v4
9192
with:
9293
node-version: 22.x
94+
9395
- uses: actions/checkout@v4
9496
env:
9597
HUSKY: "0"

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ build: src/ cloudformation/ docs/
5858
VITE_BUILD_HASH=$(GIT_HASH) yarn build
5959
cp -r src/api/resources/ dist/api/resources
6060
rm -rf dist/lambda/sqs
61-
sam build --template-file cloudformation/main.yml
61+
sam build --template-file cloudformation/main.yml --use-container
6262
mkdir -p .aws-sam/build/AppApiLambdaFunction/node_modules/aws-crt/
6363
cp -r node_modules/aws-crt/dist .aws-sam/build/AppApiLambdaFunction/node_modules/aws-crt
6464

cloudformation/iam.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Resources:
7777
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests
7878
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests-status
7979
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-linkry
80+
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-keys
8081
# Index accesses
8182
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/index/*
8283
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/index/*

cloudformation/main.yml

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ Resources:
151151
DependsOn:
152152
- AppLogGroups
153153
Properties:
154-
Architectures: [arm64]
154+
Architectures: [x86_64]
155155
CodeUri: ../dist/lambda
156156
AutoPublishAlias: live
157157
Runtime: nodejs22.x
@@ -166,7 +166,7 @@ Resources:
166166
RunEnvironment: !Ref RunEnvironment
167167
EntraRoleArn: !GetAtt AppSecurityRoles.Outputs.EntraFunctionRoleArn
168168
LinkryKvArn: !GetAtt LinkryRecordsCloudfrontStore.Arn
169-
AWS_CRT_NODEJS_BINARY_RELATIVE_PATH: node_modules/aws-crt/dist/bin/linux-arm64-glibc/aws-crt-nodejs.node
169+
AWS_CRT_NODEJS_BINARY_RELATIVE_PATH: node_modules/aws-crt/dist/bin/linux-x64-glibc/aws-crt-nodejs.node
170170
VpcConfig:
171171
Ipv6AllowedForDualStack: !If [ShouldAttachVpc, True, !Ref AWS::NoValue]
172172
SecurityGroupIds:
@@ -198,7 +198,7 @@ Resources:
198198
DependsOn:
199199
- AppLogGroups
200200
Properties:
201-
Architectures: [arm64]
201+
Architectures: [x86_64]
202202
CodeUri: ../dist/sqsConsumer
203203
AutoPublishAlias: live
204204
Runtime: nodejs22.x
@@ -273,6 +273,26 @@ Resources:
273273
- AttributeName: email
274274
KeyType: HASH
275275

276+
ApiKeyTable:
277+
Type: "AWS::DynamoDB::Table"
278+
DeletionPolicy: "Retain"
279+
UpdateReplacePolicy: "Retain"
280+
Properties:
281+
BillingMode: "PAY_PER_REQUEST"
282+
TableName: infra-core-api-keys
283+
DeletionProtectionEnabled: !If [IsProd, true, false]
284+
PointInTimeRecoverySpecification:
285+
PointInTimeRecoveryEnabled: !If [IsProd, true, false]
286+
AttributeDefinitions:
287+
- AttributeName: keyId
288+
AttributeType: S
289+
KeySchema:
290+
- AttributeName: keyId
291+
KeyType: HASH
292+
TimeToLiveSpecification:
293+
AttributeName: expiresAt
294+
Enabled: true
295+
276296
ExternalMembershipRecordsTable:
277297
Type: "AWS::DynamoDB::Table"
278298
DeletionPolicy: "Retain"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"lint": "yarn workspaces run lint",
1818
"prepare": "node .husky/install.mjs || true",
1919
"typecheck": "yarn workspaces run typecheck",
20-
"test:unit": "cross-env RunEnvironment='dev' vitest run --coverage tests/unit --config tests/unit/vitest.config.ts && yarn workspace infra-core-ui run test:unit",
20+
"test:unit": "cross-env RunEnvironment='dev' vitest run --coverage --config tests/unit/vitest.config.ts tests/unit && yarn workspace infra-core-ui run test:unit",
2121
"test:unit-ui": "yarn test:unit --ui",
2222
"test:unit-watch": "vitest tests/unit",
2323
"test:live": "vitest tests/live",

src/api/build.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const commonParams = {
1616
target: "es2022", // Target ES2022
1717
sourcemap: false,
1818
platform: "node",
19-
external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify", "zod", "zod-openapi", "@fastify/swagger", "@fastify/swagger-ui"],
19+
external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify", "zod", "zod-openapi", "@fastify/swagger", "@fastify/swagger-ui", "argon2"],
2020
alias: {
2121
'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js')
2222
},

src/api/components/index.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,32 @@ export function withTags<T extends FastifyZodOpenApiSchema>(
2222
};
2323
}
2424

25-
type RoleSchema = {
25+
export type RoleSchema = {
2626
"x-required-roles": AppRoles[];
27+
"x-disable-api-key-auth": boolean;
2728
description: string;
2829
};
2930

31+
type RolesConfig = {
32+
disableApiKeyAuth: boolean;
33+
};
34+
3035
export function withRoles<T extends FastifyZodOpenApiSchema>(
3136
roles: AppRoles[],
3237
schema: T,
38+
{ disableApiKeyAuth }: RolesConfig = { disableApiKeyAuth: false },
3339
): T & RoleSchema {
40+
const security = [{ bearerAuth: [] }] as any;
41+
if (!disableApiKeyAuth) {
42+
security.push({ apiKeyAuth: [] });
43+
}
3444
return {
35-
security: [{ bearerAuth: [] }],
45+
security,
3646
"x-required-roles": roles,
47+
"x-disable-api-key-auth": disableApiKeyAuth,
3748
description:
3849
roles.length > 0
39-
? `Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}`
50+
? `${disableApiKeyAuth ? "API key authentication is not permitted for this route.\n\n" : ""}Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}`
4051
: "Requires valid authentication but no specific role.",
4152
...schema,
4253
};

src/api/functions/apiKey.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { createHash, randomBytes } from "crypto";
2+
import * as argon2 from "argon2";
3+
import { UnauthenticatedError } from "common/errors/index.js";
4+
import NodeCache from "node-cache";
5+
import {
6+
DeleteItemCommand,
7+
DynamoDBClient,
8+
GetItemCommand,
9+
} from "@aws-sdk/client-dynamodb";
10+
import { genericConfig } from "common/config.js";
11+
import { AUTH_DECISION_CACHE_SECONDS as API_KEY_DATA_CACHE_SECONDS } from "./authorization.js";
12+
import { unmarshall } from "@aws-sdk/util-dynamodb";
13+
import { ApiKeyDynamoEntry, DecomposedApiKey } from "common/types/apiKey.js";
14+
15+
function min(a: number, b: number) {
16+
return a < b ? a : b;
17+
}
18+
19+
export const API_KEY_CACHE_SECONDS = 120;
20+
21+
export const createChecksum = (key: string) => {
22+
return createHash("sha256").update(key).digest("hex").slice(0, 6);
23+
};
24+
25+
export const createApiKey = async () => {
26+
const keyId = randomBytes(6).toString("hex");
27+
const prefix = `acmuiuc_${keyId}`;
28+
const rawKey = randomBytes(32).toString("hex");
29+
const checksum = createChecksum(rawKey);
30+
const apiKey = `${prefix}_${rawKey}_${checksum}`;
31+
const hashedKey = await argon2.hash(rawKey);
32+
return { apiKey, hashedKey, keyId };
33+
};
34+
35+
export const getApiKeyParts = (apiKey: string): DecomposedApiKey => {
36+
const [prefix, id, rawKey, checksum] = apiKey.split("_");
37+
if (!prefix || !id || !rawKey || !checksum) {
38+
throw new UnauthenticatedError({
39+
message: "Invalid API key.",
40+
});
41+
}
42+
if (
43+
prefix != "acmuiuc" ||
44+
id.length != 12 ||
45+
rawKey.length != 64 ||
46+
checksum.length != 6
47+
) {
48+
throw new UnauthenticatedError({
49+
message: "Invalid API key.",
50+
});
51+
}
52+
return {
53+
prefix,
54+
id,
55+
rawKey,
56+
checksum,
57+
};
58+
};
59+
60+
export const verifyApiKey = async ({
61+
apiKey,
62+
hashedKey,
63+
}: {
64+
apiKey: string;
65+
hashedKey: string;
66+
}) => {
67+
try {
68+
const { rawKey, checksum: submittedChecksum } = getApiKeyParts(apiKey);
69+
const isChecksumValid = createChecksum(rawKey) === submittedChecksum;
70+
if (!isChecksumValid) {
71+
return false;
72+
}
73+
return await argon2.verify(hashedKey, rawKey);
74+
} catch (e) {
75+
if (e instanceof UnauthenticatedError) {
76+
return false;
77+
}
78+
throw e;
79+
}
80+
};
81+
82+
export const getApiKeyData = async ({
83+
nodeCache,
84+
dynamoClient,
85+
id,
86+
}: {
87+
nodeCache: NodeCache;
88+
dynamoClient: DynamoDBClient;
89+
id: string;
90+
}): Promise<ApiKeyDynamoEntry | undefined> => {
91+
const cacheKey = `auth_apikey_${id}`;
92+
const cachedValue = nodeCache.get(`auth_apikey_${id}`);
93+
if (cachedValue !== undefined) {
94+
return cachedValue as ApiKeyDynamoEntry;
95+
}
96+
const getCommand = new GetItemCommand({
97+
TableName: genericConfig.ApiKeyTable,
98+
Key: { keyId: { S: id } },
99+
});
100+
const result = await dynamoClient.send(getCommand);
101+
if (!result || !result.Item) {
102+
nodeCache.set(cacheKey, null, API_KEY_DATA_CACHE_SECONDS);
103+
return undefined;
104+
}
105+
const unmarshalled = unmarshall(result.Item) as ApiKeyDynamoEntry;
106+
if (
107+
unmarshalled.expiresAt &&
108+
unmarshalled.expiresAt <= Math.floor(Date.now() / 1000)
109+
) {
110+
dynamoClient.send(
111+
new DeleteItemCommand({
112+
TableName: genericConfig.ApiKeyTable,
113+
Key: { keyId: { S: id } },
114+
}),
115+
); // don't need to wait for the response
116+
return undefined;
117+
}
118+
if (!("keyHash" in unmarshalled)) {
119+
return undefined; // bad data, don't cache it
120+
}
121+
let cacheTime = API_KEY_DATA_CACHE_SECONDS;
122+
if (unmarshalled["expiresAt"]) {
123+
const currentEpoch = Date.now();
124+
cacheTime = min(cacheTime, unmarshalled["expiresAt"] - currentEpoch);
125+
}
126+
nodeCache.set(cacheKey, unmarshalled as ApiKeyDynamoEntry, cacheTime);
127+
return unmarshalled;
128+
};

0 commit comments

Comments
 (0)