Skip to content

Commit

Permalink
feat: api keys support for CLI/backend/auth (#1387)
Browse files Browse the repository at this point in the history
* feat: api keys support for CLI/backend/auth

* fix(backend): test execution fix

* fix: changed api key id to uuid

* chore: Add changeset

---------

Co-authored-by: Mohamad Mohebifar <[email protected]>
  • Loading branch information
arybitskiy and mohebifar authored Dec 1, 2024
1 parent 563df0e commit b0dae1b
Show file tree
Hide file tree
Showing 23 changed files with 707 additions and 78 deletions.
5 changes: 5 additions & 0 deletions .changeset/eight-bees-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"codemod": minor
---

Add API keys functionality
3 changes: 3 additions & 0 deletions apps/auth-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ CLERK_SECRET_KEY=
CLERK_JWT_KEY=
CLERK_PUBLISH_KEY=
CLI_TOKEN_TEMPLATE=

UNKEY_ROOT_KEY=
UNKEY_API_ID=
1 change: 1 addition & 0 deletions apps/auth-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@clerk/fastify": "catalog:",
"@codemod-com/auth": "workspace:*",
"@codemod-com/database": "workspace:*",
"@unkey/api": "catalog:",
"@codemod-com/utilities": "workspace:*",
"@fastify/busboy": "catalog:",
"@fastify/cors": "catalog:",
Expand Down
45 changes: 45 additions & 0 deletions apps/auth-service/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,25 @@ import type {
} from "@codemod-com/api-types";
import { isNeitherNullNorUndefined } from "@codemod-com/utilities";

import { Unkey } from "@unkey/api";
import { createLoginIntent } from "./handlers/intents/create.js";
import { getLoginIntent } from "./handlers/intents/get.js";
import { populateLoginIntent } from "./handlers/intents/populate.js";
import { environment } from "./util.js";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY as string });
const apiId = process.env.UNKEY_API_ID as string;

const getUserId = async (key: string) => {
const response = await unkey.keys.verify({ apiId, key });

if (response.error) {
throw new Error(response.error.message);
}

return response.result.identity?.externalId ?? response.result.ownerId;
};

export const initApp = async (toRegister: FastifyPluginCallback[]) => {
const { PORT: port } = environment;
if (Number.isNaN(port)) {
Expand Down Expand Up @@ -168,6 +182,37 @@ const routes: FastifyPluginCallback = (instance, _opts, done) => {
});
});

instance.get("/apiUserData", async (request, reply) => {
const apiKey = request.headers["x-api-key"] as string;
const userId = await getUserId(apiKey);

if (!userId) {
return reply.status(200).send({});
}

const user = await clerkClient.users.getUser(userId);
const organizations = (
await clerkClient.users.getOrganizationMembershipList({ userId })
).data.map((organization) => organization);
const allowedNamespaces = [
...organizations.map(({ organization }) => organization.slug),
].filter(isNeitherNullNorUndefined);

if (user.username) {
allowedNamespaces.unshift(user.username);

if (environment.VERIFIED_PUBLISHERS.includes(user.username)) {
allowedNamespaces.push("codemod-com");
}
}

return reply.status(200).send({
user,
organizations,
allowedNamespaces,
});
});

instance.delete<{ Reply: RevokeScopedTokenResponse }>(
"/revokeToken",
async (request, reply) => {
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ TASK_MANAGER_QUEUE_NAME=
POSTHOG_API_KEY=
POSTHOG_PROJECT_ID=

ZAPIER_PUBLISH_HOOK=
ZAPIER_PUBLISH_HOOK=

UNKEY_ROOT_KEY=
UNKEY_API_ID=
3 changes: 3 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@fastify/rate-limit": "catalog:",
"@slack/web-api": "catalog:",
"@types/tar": "catalog:",
"@unkey/api": "catalog:",
"ai": "2.2.29",
"axios": "catalog:",
"bullmq": "catalog:",
Expand All @@ -54,13 +55,15 @@
"fuse.js": "catalog:",
"ioredis": "catalog:",
"langchain": "catalog:",
"lodash-es": "catalog:",
"openai": "catalog:",
"openai-edge": "catalog:",
"parse-github-url": "catalog:",
"pg": "catalog:",
"replicate": "catalog:",
"semver": "7.6.0",
"tar": "^6.2.0",
"uuid": "^11.0.3",
"valibot": "catalog:",
"ws": "^8.18.0",
"zod": "catalog:"
Expand Down
35 changes: 35 additions & 0 deletions apps/backend/src/handlers/createAPIKeyHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
type CreateAPIKeyResponse,
createAPIKeyRequestSchema,
} from "@codemod-com/api-types";
import type { UserDataPopulatedRequest } from "@codemod-com/auth";
import { prisma } from "@codemod-com/database";
import type { RouteHandler } from "fastify";
import { v4 as uuidv4 } from "uuid";
import { parse } from "valibot";
import { createApiKey } from "../services/UnkeyService.js";

export const createAPIKeyHandler: RouteHandler<{
Reply: CreateAPIKeyResponse;
}> = async (request: UserDataPopulatedRequest) => {
const user = request.user!;

const uuid = uuidv4();

const apiKey = await createApiKey({
externalId: user.id,
apiKeyData: parse(createAPIKeyRequestSchema, request.body),
uuid,
});

await prisma.apiKey.create({
data: {
externalId: user.id,
uuid,
keyId: apiKey.keyId,
},
});

const reply: CreateAPIKeyResponse = { key: apiKey.key, uuid };
return reply;
};
40 changes: 40 additions & 0 deletions apps/backend/src/handlers/deleteAPIKeysHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
type DeleteAPIKeysResponse,
deleteAPIKeysRequestSchema,
} from "@codemod-com/api-types";
import type { UserDataPopulatedRequest } from "@codemod-com/auth";
import { prisma } from "@codemod-com/database";
import type { RouteHandler } from "fastify";
import { parse } from "valibot";
import { deleteApiKeys, listApiKeys } from "../services/UnkeyService.js";

export const deleteAPIKeysHandler: RouteHandler<{
Reply: DeleteAPIKeysResponse;
}> = async (request: UserDataPopulatedRequest) => {
const user = request.user!;

const { uuid } = parse(deleteAPIKeysRequestSchema, request.params);

const keysToDelete = await prisma.apiKey.findMany({
where: { externalId: user.id, uuid },
});

const keysInfo = await listApiKeys({ externalId: user.id });

await deleteApiKeys({ keyIds: keysToDelete.map((key) => key.keyId) });

await prisma.apiKey.deleteMany({
where: {
externalId: user.id,
keyId: { in: keysToDelete.map((key) => key.keyId) },
},
});

const reply: DeleteAPIKeysResponse = {
keys: keysToDelete
.map(({ uuid }) => keysInfo.keys.find((k) => k.meta?.uuid === uuid))
.filter((key) => !!key)
.map(({ start, name }) => ({ start, name })),
};
return reply;
};
25 changes: 25 additions & 0 deletions apps/backend/src/handlers/listAPIKeysHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ListAPIKeysResponse } from "@codemod-com/api-types";
import type { UserDataPopulatedRequest } from "@codemod-com/auth";
import type { RouteHandler } from "fastify";
import { listApiKeys } from "../services/UnkeyService.js";

export const listAPIKeysHandler: RouteHandler<{
Reply: ListAPIKeysResponse;
}> = async (request: UserDataPopulatedRequest) => {
const user = request.user!;

const apiKeys = await listApiKeys({ externalId: user.id }).then(
({ keys }) => keys,
);

const reply: ListAPIKeysResponse = {
keys: apiKeys.map(({ start, name, createdAt, expires, meta }) => ({
start,
name,
createdAt,
expiresAt: expires,
uuid: meta?.uuid as string | undefined,
})),
};
return reply;
};
26 changes: 25 additions & 1 deletion apps/backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { randomBytes } from "node:crypto";
import type { CodemodListResponse } from "@codemod-com/api-types";
import type {
CodemodListResponse,
CreateAPIKeyResponse,
DeleteAPIKeysResponse,
ListAPIKeysResponse,
} from "@codemod-com/api-types";
import { getAuthPlugin } from "@codemod-com/auth";
import { prisma } from "@codemod-com/database";
import { decryptWithIv, encryptWithIv } from "@codemod-com/utilities";
Expand All @@ -10,6 +15,8 @@ import Fastify, {
type FastifyPluginCallback,
type FastifyRequest,
} from "fastify";
import { createAPIKeyHandler } from "./handlers/createAPIKeyHandler.js";
import { deleteAPIKeysHandler } from "./handlers/deleteAPIKeysHandler.js";
import {
type GetCodemodDownloadLinkResponse,
getCodemodDownloadLink,
Expand All @@ -20,6 +27,7 @@ import {
getCodemodsHandler,
} from "./handlers/getCodemodsHandler.js";
import { getCodemodsListHandler } from "./handlers/getCodemodsListHandler.js";
import { listAPIKeysHandler } from "./handlers/listAPIKeysHandler.js";
import {
type PublishHandlerResponse,
publishHandler,
Expand Down Expand Up @@ -169,6 +177,22 @@ const routes: FastifyPluginCallback = (instance, _opts, done) => {
},
);

instance.post<{
Reply: CreateAPIKeyResponse;
}>("/api-keys", { preHandler: instance.getUserData }, createAPIKeyHandler);

instance.get<{
Reply: ListAPIKeysResponse;
}>("/api-keys", { preHandler: instance.getUserData }, listAPIKeysHandler);

instance.delete<{
Reply: DeleteAPIKeysResponse;
}>(
"/api-keys/:uuid",
{ preHandler: instance.getUserData },
deleteAPIKeysHandler,
);

instance.get("/codemods/:criteria", getCodemodHandler);

instance.get<{ Reply: GetCodemodsResponse }>(
Expand Down
56 changes: 56 additions & 0 deletions apps/backend/src/services/UnkeyService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { CreateAPIKeyRequest } from "@codemod-com/api-types";
import { Unkey } from "@unkey/api";
import { memoize } from "lodash-es";

const UNKEY_API_ID = process.env.UNKEY_API_ID as string;
const UNKEY_ROOT_KEY = process.env.UNKEY_ROOT_KEY as string;

const getUnkey = memoize(() => new Unkey({ rootKey: UNKEY_ROOT_KEY }));

export const createApiKey = async ({
apiKeyData,
externalId,
uuid,
}: { apiKeyData: CreateAPIKeyRequest; externalId: string; uuid: string }) => {
const response = await getUnkey().keys.create({
apiId: UNKEY_API_ID,
prefix: "codemod.com",
externalId,
name: apiKeyData.name,
expires: apiKeyData.expiresAt
? Date.parse(apiKeyData.expiresAt)
: undefined,
meta: {
uuid,
},
});

if (response.error) {
throw new Error(response.error.message);
}

return response.result;
};

export const listApiKeys = async ({ externalId }: { externalId: string }) => {
const response = await getUnkey().apis.listKeys({
apiId: UNKEY_API_ID,
externalId,
});

if (response.error) {
throw new Error(response.error.message);
}

return response.result;
};

export const deleteApiKeys = async ({ keyIds }: { keyIds: string[] }) => {
await Promise.all(
keyIds.map(async (keyId) =>
getUnkey().keys.delete({
keyId,
}),
),
);
};
Loading

0 comments on commit b0dae1b

Please sign in to comment.