aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-09-14 16:31:08 +0000
committerMohamed Bassem <me@mbassem.com>2025-09-14 16:31:08 +0000
commit7671f4ff7ac5b106c3faa6b59a01f154cb34be99 (patch)
treef9445fd05de16117a6f9fb6941a7f0f359d6618d /packages/trpc
parent69ef2ffe5e9216b0c0690221fc5679baabdc93ea (diff)
downloadkarakeep-7671f4ff7ac5b106c3faa6b59a01f154cb34be99.tar.zst
feat: Regen api keys
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/auth.ts42
-rw-r--r--packages/trpc/routers/apiKeys.test.ts31
-rw-r--r--packages/trpc/routers/apiKeys.ts34
3 files changed, 100 insertions, 7 deletions
diff --git a/packages/trpc/auth.ts b/packages/trpc/auth.ts
index 01966b9e..d252bebb 100644
--- a/packages/trpc/auth.ts
+++ b/packages/trpc/auth.ts
@@ -1,5 +1,6 @@
import { createHash, randomBytes } from "crypto";
import * as bcrypt from "bcryptjs";
+import { and, eq } from "drizzle-orm";
import { apiKeys } from "@karakeep/db/schema";
import serverConfig from "@karakeep/shared/config";
@@ -10,21 +11,50 @@ const BCRYPT_SALT_ROUNDS = 10;
const API_KEY_PREFIX_V1 = "ak1";
const API_KEY_PREFIX_V2 = "ak2";
+function generateApiKeySecret() {
+ const secret = randomBytes(16).toString("hex");
+ return {
+ keyId: randomBytes(10).toString("hex"),
+ secret,
+ secretHash: createHash("sha256").update(secret).digest("base64"),
+ };
+}
+
export function generatePasswordSalt() {
return randomBytes(32).toString("hex");
}
+export async function regenerateApiKey(
+ id: string,
+ userId: string,
+ database: Context["db"],
+) {
+ const { keyId, secret, secretHash } = generateApiKeySecret();
+
+ const plain = `${API_KEY_PREFIX_V2}_${keyId}_${secret}`;
+
+ const res = await database
+ .update(apiKeys)
+ .set({
+ keyId: keyId,
+ keyHash: secretHash,
+ })
+ .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
+
+ if (res.changes == 0) {
+ throw new Error("Failed to regenerate API key");
+ }
+ return plain;
+}
+
export async function generateApiKey(
name: string,
userId: string,
database: Context["db"],
) {
- const id = randomBytes(10).toString("hex");
- const secret = randomBytes(16).toString("hex");
-
- const secretHash = createHash("sha256").update(secret).digest("base64");
+ const { keyId, secret, secretHash } = generateApiKeySecret();
- const plain = `${API_KEY_PREFIX_V2}_${id}_${secret}`;
+ const plain = `${API_KEY_PREFIX_V2}_${keyId}_${secret}`;
const key = (
await database
@@ -32,7 +62,7 @@ export async function generateApiKey(
.values({
name: name,
userId: userId,
- keyId: id,
+ keyId,
keyHash: secretHash,
})
.returning()
diff --git a/packages/trpc/routers/apiKeys.test.ts b/packages/trpc/routers/apiKeys.test.ts
index b3e57db3..1fd2159a 100644
--- a/packages/trpc/routers/apiKeys.test.ts
+++ b/packages/trpc/routers/apiKeys.test.ts
@@ -141,6 +141,37 @@ describe("API Keys Routes", () => {
);
});
});
+ describe("regenerate", () => {
+ test<CustomTestContext>("revokes API key successfully", async ({
+ unauthedAPICaller,
+ db,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "password123",
+ confirmPassword: "password123",
+ });
+
+ const api = getApiCaller(db, user.id, user.email).apiKeys;
+
+ const firstKey = await api.create({ name: "Test Key" });
+ const regeneratedKey = await api.regenerate({ id: firstKey.id });
+
+ // Validate the new key
+ const validationResult = await unauthedAPICaller.apiKeys.validate({
+ apiKey: regeneratedKey.key,
+ });
+ expect(validationResult.success).toBe(true);
+
+ // Validate the old key is revoked
+ await expect(() =>
+ unauthedAPICaller.apiKeys.validate({
+ apiKey: firstKey.key,
+ }),
+ ).rejects.toThrow();
+ });
+ });
describe("revoke", () => {
test<CustomTestContext>("revokes API key successfully", async ({
diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts
index dc3a3527..93b7d9ec 100644
--- a/packages/trpc/routers/apiKeys.ts
+++ b/packages/trpc/routers/apiKeys.ts
@@ -5,7 +5,12 @@ import { z } from "zod";
import { apiKeys } from "@karakeep/db/schema";
import serverConfig from "@karakeep/shared/config";
-import { authenticateApiKey, generateApiKey, validatePassword } from "../auth";
+import {
+ authenticateApiKey,
+ generateApiKey,
+ regenerateApiKey,
+ validatePassword,
+} from "../auth";
import {
authedProcedure,
createRateLimitMiddleware,
@@ -31,6 +36,33 @@ export const apiKeysAppRouter = router({
.mutation(async ({ input, ctx }) => {
return await generateApiKey(input.name, ctx.user.id, ctx.db);
}),
+ regenerate: authedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ }),
+ )
+ .output(zApiKeySchema)
+ .mutation(async ({ input, ctx }) => {
+ // Find the existing API key to get its name
+ const existingKey = await ctx.db.query.apiKeys.findFirst({
+ where: and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.user.id)),
+ });
+
+ if (!existingKey) {
+ throw new TRPCError({
+ message: "API key not found",
+ code: "NOT_FOUND",
+ });
+ }
+
+ return {
+ id: existingKey.id,
+ name: existingKey.name,
+ createdAt: existingKey.createdAt,
+ key: await regenerateApiKey(existingKey.id, ctx.user.id, ctx.db),
+ };
+ }),
revoke: authedProcedure
.input(
z.object({