diff options
| -rw-r--r-- | apps/web/components/settings/AddApiKey.tsx | 25 | ||||
| -rw-r--r-- | apps/web/components/settings/ApiKeySettings.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/settings/ApiKeySuccess.tsx | 24 | ||||
| -rw-r--r-- | apps/web/components/settings/RegenerateApiKey.tsx | 118 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 8 | ||||
| -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 |
8 files changed, 259 insertions, 29 deletions
diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx index 326da229..c8baa626 100644 --- a/apps/web/components/settings/AddApiKey.tsx +++ b/apps/web/components/settings/AddApiKey.tsx @@ -5,7 +5,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import { Button } from "@/components/ui/button"; -import CopyBtn from "@/components/ui/copy-button"; import { Dialog, DialogClose, @@ -33,24 +32,7 @@ import { PlusCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -function ApiKeySuccess({ apiKey }: { apiKey: string }) { - const { t } = useTranslation(); - return ( - <div> - <div className="py-4 text-sm text-muted-foreground"> - {t("settings.api_keys.key_success_please_copy")} - </div> - <div className="flex space-x-2 pt-2"> - <Input value={apiKey} readOnly /> - <CopyBtn - getStringToCopy={() => { - return apiKey; - }} - /> - </div> - </div> - ); -} +import ApiKeySuccess from "./ApiKeySuccess"; function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { const { t } = useTranslation(); @@ -145,7 +127,10 @@ export default function AddApiKey() { </DialogTitle> </DialogHeader> {key ? ( - <ApiKeySuccess apiKey={key} /> + <ApiKeySuccess + apiKey={key} + message={t("settings.api_keys.key_success")} + /> ) : ( <AddApiKeyForm onSuccess={setKey} /> )} diff --git a/apps/web/components/settings/ApiKeySettings.tsx b/apps/web/components/settings/ApiKeySettings.tsx index 2b9d19d1..bc4b71c5 100644 --- a/apps/web/components/settings/ApiKeySettings.tsx +++ b/apps/web/components/settings/ApiKeySettings.tsx @@ -11,6 +11,7 @@ import { api } from "@/server/api/client"; import AddApiKey from "./AddApiKey"; import DeleteApiKey from "./DeleteApiKey"; +import RegenerateApiKey from "./RegenerateApiKey"; export default async function ApiKeys() { // oxlint-disable-next-line rules-of-hooks @@ -41,7 +42,10 @@ export default async function ApiKeys() { <TableCell>**_{k.keyId}_**</TableCell> <TableCell>{k.createdAt.toLocaleString()}</TableCell> <TableCell> - <DeleteApiKey name={k.name} id={k.id} /> + <div className="flex items-center gap-2"> + <RegenerateApiKey name={k.name} id={k.id} /> + <DeleteApiKey name={k.name} id={k.id} /> + </div> </TableCell> </TableRow> ))} diff --git a/apps/web/components/settings/ApiKeySuccess.tsx b/apps/web/components/settings/ApiKeySuccess.tsx new file mode 100644 index 00000000..370d711b --- /dev/null +++ b/apps/web/components/settings/ApiKeySuccess.tsx @@ -0,0 +1,24 @@ +import CopyBtn from "@/components/ui/copy-button"; +import { Input } from "@/components/ui/input"; + +export default function ApiKeySuccess({ + apiKey, + message, +}: { + apiKey: string; + message: string; +}) { + return ( + <div> + <div className="py-4 text-sm text-muted-foreground">{message}</div> + <div className="flex space-x-2 pt-2"> + <Input value={apiKey} readOnly /> + <CopyBtn + getStringToCopy={() => { + return apiKey; + }} + /> + </div> + </div> + ); +} diff --git a/apps/web/components/settings/RegenerateApiKey.tsx b/apps/web/components/settings/RegenerateApiKey.tsx new file mode 100644 index 00000000..1c034026 --- /dev/null +++ b/apps/web/components/settings/RegenerateApiKey.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { RefreshCcw } from "lucide-react"; + +import ApiKeySuccess from "./ApiKeySuccess"; + +export default function RegenerateApiKey({ + id, + name, +}: { + id: string; + name: string; +}) { + const { t } = useTranslation(); + const router = useRouter(); + + const [key, setKey] = useState<string | undefined>(undefined); + const [dialogOpen, setDialogOpen] = useState<boolean>(false); + + const mutator = api.apiKeys.regenerate.useMutation({ + onSuccess: (resp) => { + setKey(resp.key); + router.refresh(); + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + setDialogOpen(false); + }, + }); + + const handleRegenerate = () => { + mutator.mutate({ id }); + }; + + return ( + <Dialog + open={dialogOpen} + onOpenChange={(o) => { + setDialogOpen(o); + setKey(undefined); + }} + > + <DialogTrigger asChild> + <Button variant="ghost" size="sm" title="Regenerate"> + <RefreshCcw className="h-4 w-4" /> + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle> + {key + ? t("settings.api_keys.key_regenerated") + : t("settings.api_keys.regenerate_api_key")} + </DialogTitle> + {!key && ( + <DialogDescription> + {t("settings.api_keys.regenerate_warning", { name })} + </DialogDescription> + )} + </DialogHeader> + {key ? ( + <ApiKeySuccess + apiKey={key} + message={t("settings.api_keys.key_regenerated_please_copy")} + /> + ) : ( + <p className="text-sm"> + {t("settings.api_keys.regenerate_confirmation")} + </p> + )} + <DialogFooter className="sm:justify-end"> + {!key ? ( + <> + <DialogClose asChild> + <Button type="button" variant="outline"> + {t("actions.cancel")} + </Button> + </DialogClose> + <ActionButton + variant="destructive" + onClick={handleRegenerate} + loading={mutator.isPending} + > + {t("actions.regenerate")} + </ActionButton> + </> + ) : ( + <DialogClose asChild> + <Button type="button" variant="outline"> + {t("actions.close")} + </Button> + </DialogClose> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 065b5ed6..561c4e5a 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -80,6 +80,7 @@ "close": "Close", "merge": "Merge", "cancel": "Cancel", + "regenerate": "Regenerate", "apply_all": "Apply All", "ignore": "Ignore", "sort": { @@ -222,7 +223,12 @@ "new_api_key": "New API Key", "new_api_key_desc": "Give your API key a unique name", "key_success": "Key was successfully created", - "key_success_please_copy": "Please copy the key and store it somewhere safe. Once you close the dialog, you won't be able to access it again." + "key_success_please_copy": "Please copy the key and store it somewhere safe. Once you close the dialog, you won't be able to access it again.", + "regenerate_api_key": "Regenerate API Key", + "key_regenerated": "Key was successfully regenerated", + "key_regenerated_please_copy": "Please copy the new key and store it somewhere safe. The old key has been revoked and will no longer work.", + "regenerate_warning": "Are you sure you want to regenerate the API key \"{{name}}\"?", + "regenerate_confirmation": "This will revoke the current key and generate a new one. Any applications using the current key will stop working." }, "broken_links": { "broken_links": "Broken Links", 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({ |
