Skip to content

Build basic API Key support #125

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

Merged
merged 22 commits into from
Apr 23, 2025
Merged
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
60 changes: 29 additions & 31 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,41 +1,39 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
{
"name": "Ubuntu",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/base:jammy",
"features": {
"ghcr.io/devcontainers/features/node:1": {},
"name": "Ubuntu",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/base:jammy",
"features": {
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/devcontainers/features/aws-cli:1": {},
"ghcr.io/jungaretti/features/make:1": {},
"ghcr.io/customink/codespaces-features/sam-cli:1": {},
"ghcr.io/devcontainers/features/python:1": {}
},
"ghcr.io/jungaretti/features/make:1": {},
"ghcr.io/customink/codespaces-features/sam-cli:1": {},
"ghcr.io/devcontainers/features/python:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
},

// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},

// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
8080,
5173
],
"customizations": {
"vscode": {
"extensions": [
"EditorConfig.EditorConfig",
"waderyan.gitblame",
"Gruntfuggly.todo-tree"
]
}
}
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [8080, 5173],
"customizations": {
"vscode": {
"extensions": [
"EditorConfig.EditorConfig",
"waderyan.gitblame",
"Gruntfuggly.todo-tree"
]
}
}

// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "uname -a",
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "uname -a",

// Configure tool-specific properties.
// "customizations": {},
// Configure tool-specific properties.
// "customizations": {},

// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
2 changes: 2 additions & 0 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22.x

- uses: actions/checkout@v4
env:
HUSKY: "0"
Expand Down Expand Up @@ -90,6 +91,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22.x

- uses: actions/checkout@v4
env:
HUSKY: "0"
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ build: src/ cloudformation/ docs/
VITE_BUILD_HASH=$(GIT_HASH) yarn build
cp -r src/api/resources/ dist/api/resources
rm -rf dist/lambda/sqs
sam build --template-file cloudformation/main.yml
sam build --template-file cloudformation/main.yml --use-container
mkdir -p .aws-sam/build/AppApiLambdaFunction/node_modules/aws-crt/
cp -r node_modules/aws-crt/dist .aws-sam/build/AppApiLambdaFunction/node_modules/aws-crt

Expand Down
1 change: 1 addition & 0 deletions cloudformation/iam.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Resources:
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests-status
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-linkry
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-keys
# Index accesses
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/index/*
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/index/*
Expand Down
26 changes: 23 additions & 3 deletions cloudformation/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Resources:
DependsOn:
- AppLogGroups
Properties:
Architectures: [arm64]
Architectures: [x86_64]
CodeUri: ../dist/lambda
AutoPublishAlias: live
Runtime: nodejs22.x
Expand All @@ -166,7 +166,7 @@ Resources:
RunEnvironment: !Ref RunEnvironment
EntraRoleArn: !GetAtt AppSecurityRoles.Outputs.EntraFunctionRoleArn
LinkryKvArn: !GetAtt LinkryRecordsCloudfrontStore.Arn
AWS_CRT_NODEJS_BINARY_RELATIVE_PATH: node_modules/aws-crt/dist/bin/linux-arm64-glibc/aws-crt-nodejs.node
AWS_CRT_NODEJS_BINARY_RELATIVE_PATH: node_modules/aws-crt/dist/bin/linux-x64-glibc/aws-crt-nodejs.node
VpcConfig:
Ipv6AllowedForDualStack: !If [ShouldAttachVpc, True, !Ref AWS::NoValue]
SecurityGroupIds:
Expand Down Expand Up @@ -198,7 +198,7 @@ Resources:
DependsOn:
- AppLogGroups
Properties:
Architectures: [arm64]
Architectures: [x86_64]
CodeUri: ../dist/sqsConsumer
AutoPublishAlias: live
Runtime: nodejs22.x
Expand Down Expand Up @@ -273,6 +273,26 @@ Resources:
- AttributeName: email
KeyType: HASH

ApiKeyTable:
Type: "AWS::DynamoDB::Table"
DeletionPolicy: "Retain"
UpdateReplacePolicy: "Retain"
Properties:
BillingMode: "PAY_PER_REQUEST"
TableName: infra-core-api-keys
DeletionProtectionEnabled: !If [IsProd, true, false]
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: !If [IsProd, true, false]
AttributeDefinitions:
- AttributeName: keyId
AttributeType: S
KeySchema:
- AttributeName: keyId
KeyType: HASH
TimeToLiveSpecification:
AttributeName: expiresAt
Enabled: true

ExternalMembershipRecordsTable:
Type: "AWS::DynamoDB::Table"
DeletionPolicy: "Retain"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"lint": "yarn workspaces run lint",
"prepare": "node .husky/install.mjs || true",
"typecheck": "yarn workspaces run typecheck",
"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",
"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",
"test:unit-ui": "yarn test:unit --ui",
"test:unit-watch": "vitest tests/unit",
"test:live": "vitest tests/live",
Expand Down
2 changes: 1 addition & 1 deletion src/api/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const commonParams = {
target: "es2022", // Target ES2022
sourcemap: false,
platform: "node",
external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify", "zod", "zod-openapi", "@fastify/swagger", "@fastify/swagger-ui"],
external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify", "zod", "zod-openapi", "@fastify/swagger", "@fastify/swagger-ui", "argon2"],
alias: {
'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js')
},
Expand Down
17 changes: 14 additions & 3 deletions src/api/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,32 @@
};
}

type RoleSchema = {
export type RoleSchema = {
"x-required-roles": AppRoles[];
"x-disable-api-key-auth": boolean;
description: string;
};

type RolesConfig = {
disableApiKeyAuth: boolean;
};

export function withRoles<T extends FastifyZodOpenApiSchema>(
roles: AppRoles[],
schema: T,
{ disableApiKeyAuth }: RolesConfig = { disableApiKeyAuth: false },
): T & RoleSchema {
const security = [{ bearerAuth: [] }] as any;

Check warning on line 40 in src/api/components/index.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Unexpected any. Specify a different type
if (!disableApiKeyAuth) {
security.push({ apiKeyAuth: [] });
}
return {
security: [{ bearerAuth: [] }],
security,
"x-required-roles": roles,
"x-disable-api-key-auth": disableApiKeyAuth,
description:
roles.length > 0
? `Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}`
? `${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 : ""}`
: "Requires valid authentication but no specific role.",
...schema,
};
Expand Down
128 changes: 128 additions & 0 deletions src/api/functions/apiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { createHash, randomBytes } from "crypto";
import * as argon2 from "argon2";
import { UnauthenticatedError } from "common/errors/index.js";
import NodeCache from "node-cache";
import {
DeleteItemCommand,
DynamoDBClient,
GetItemCommand,
} from "@aws-sdk/client-dynamodb";
import { genericConfig } from "common/config.js";
import { AUTH_DECISION_CACHE_SECONDS as API_KEY_DATA_CACHE_SECONDS } from "./authorization.js";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { ApiKeyDynamoEntry, DecomposedApiKey } from "common/types/apiKey.js";

function min(a: number, b: number) {
return a < b ? a : b;
}

export const API_KEY_CACHE_SECONDS = 120;

export const createChecksum = (key: string) => {
return createHash("sha256").update(key).digest("hex").slice(0, 6);
};

export const createApiKey = async () => {
const keyId = randomBytes(6).toString("hex");
const prefix = `acmuiuc_${keyId}`;
const rawKey = randomBytes(32).toString("hex");
const checksum = createChecksum(rawKey);
const apiKey = `${prefix}_${rawKey}_${checksum}`;
const hashedKey = await argon2.hash(rawKey);
return { apiKey, hashedKey, keyId };
};

export const getApiKeyParts = (apiKey: string): DecomposedApiKey => {
const [prefix, id, rawKey, checksum] = apiKey.split("_");
if (!prefix || !id || !rawKey || !checksum) {
throw new UnauthenticatedError({
message: "Invalid API key.",
});
}
if (
prefix != "acmuiuc" ||
id.length != 12 ||
rawKey.length != 64 ||
checksum.length != 6
) {
throw new UnauthenticatedError({
message: "Invalid API key.",
});
}
return {
prefix,
id,
rawKey,
checksum,
};
};

export const verifyApiKey = async ({
apiKey,
hashedKey,
}: {
apiKey: string;
hashedKey: string;
}) => {
try {
const { rawKey, checksum: submittedChecksum } = getApiKeyParts(apiKey);
const isChecksumValid = createChecksum(rawKey) === submittedChecksum;
if (!isChecksumValid) {
return false;
}
return await argon2.verify(hashedKey, rawKey);
} catch (e) {
if (e instanceof UnauthenticatedError) {
return false;
}
throw e;
}
};

export const getApiKeyData = async ({
nodeCache,
dynamoClient,
id,
}: {
nodeCache: NodeCache;
dynamoClient: DynamoDBClient;
id: string;
}): Promise<ApiKeyDynamoEntry | undefined> => {
const cacheKey = `auth_apikey_${id}`;
const cachedValue = nodeCache.get(`auth_apikey_${id}`);
if (cachedValue !== undefined) {
return cachedValue as ApiKeyDynamoEntry;
}
const getCommand = new GetItemCommand({
TableName: genericConfig.ApiKeyTable,
Key: { keyId: { S: id } },
});
const result = await dynamoClient.send(getCommand);
if (!result || !result.Item) {
nodeCache.set(cacheKey, null, API_KEY_DATA_CACHE_SECONDS);
return undefined;
}
const unmarshalled = unmarshall(result.Item) as ApiKeyDynamoEntry;
if (
unmarshalled.expiresAt &&
unmarshalled.expiresAt <= Math.floor(Date.now() / 1000)
) {
dynamoClient.send(
new DeleteItemCommand({
TableName: genericConfig.ApiKeyTable,
Key: { keyId: { S: id } },
}),
); // don't need to wait for the response
return undefined;
}
if (!("keyHash" in unmarshalled)) {
return undefined; // bad data, don't cache it
}
let cacheTime = API_KEY_DATA_CACHE_SECONDS;
if (unmarshalled["expiresAt"]) {
const currentEpoch = Date.now();
cacheTime = min(cacheTime, unmarshalled["expiresAt"] - currentEpoch);
}
nodeCache.set(cacheKey, unmarshalled as ApiKeyDynamoEntry, cacheTime);
return unmarshalled;
};
Loading
Loading