diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-09-14 16:31:08 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-09-14 16:31:08 +0000 |
| commit | 7671f4ff7ac5b106c3faa6b59a01f154cb34be99 (patch) | |
| tree | f9445fd05de16117a6f9fb6941a7f0f359d6618d /packages/trpc | |
| parent | 69ef2ffe5e9216b0c0690221fc5679baabdc93ea (diff) | |
| download | karakeep-7671f4ff7ac5b106c3faa6b59a01f154cb34be99.tar.zst | |
feat: Regen api keys
Diffstat (limited to 'packages/trpc')
| -rw-r--r-- | packages/trpc/auth.ts | 42 | ||||
| -rw-r--r-- | packages/trpc/routers/apiKeys.test.ts | 31 | ||||
| -rw-r--r-- | packages/trpc/routers/apiKeys.ts | 34 |
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({ |
