diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-06-21 11:32:42 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-06-21 11:32:42 +0000 |
| commit | 10d45e8d14cdc3672cc65dc7f5ae79e63fb2da1a (patch) | |
| tree | c7718fa75a07a7aa166db6a1a1b2e93030732b1e | |
| parent | f1f665f89cba21d4d448d27471d01a4d78a184ff (diff) | |
| download | karakeep-10d45e8d14cdc3672cc65dc7f5ae79e63fb2da1a.tar.zst | |
fix: Change public image's signed tokens to be time aligned for better caching
| -rw-r--r-- | packages/api/routes/public/assets.ts | 2 | ||||
| -rw-r--r-- | packages/e2e_tests/tests/api/public.test.ts | 8 | ||||
| -rw-r--r-- | packages/shared/signedTokens.test.ts | 82 | ||||
| -rw-r--r-- | packages/shared/signedTokens.ts | 42 | ||||
| -rw-r--r-- | packages/trpc/models/bookmarks.ts | 12 |
5 files changed, 140 insertions, 6 deletions
diff --git a/packages/api/routes/public/assets.ts b/packages/api/routes/public/assets.ts index 4f2827d5..55b25bdc 100644 --- a/packages/api/routes/public/assets.ts +++ b/packages/api/routes/public/assets.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; import { z } from "zod"; import { assets } from "@karakeep/db/schema"; +import serverConfig from "@karakeep/shared/config"; import { verifySignedToken } from "@karakeep/shared/signedTokens"; import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets"; @@ -25,6 +26,7 @@ const app = new Hono() const assetId = c.req.param("assetId"); const tokenPayload = verifySignedToken( c.req.valid("query").token, + serverConfig.signingSecret(), zAssetSignedTokenSchema, ); if (!tokenPayload) { diff --git a/packages/e2e_tests/tests/api/public.test.ts b/packages/e2e_tests/tests/api/public.test.ts index 54ef79ea..94e65633 100644 --- a/packages/e2e_tests/tests/api/public.test.ts +++ b/packages/e2e_tests/tests/api/public.test.ts @@ -8,6 +8,8 @@ import { createTestUser, uploadTestAsset } from "../../utils/api"; import { waitUntil } from "../../utils/general"; import { getTrpcClient } from "../../utils/trpc"; +const SINGING_SECRET = "secret"; + describe("Public API", () => { const port = inject("karakeepPort"); @@ -174,6 +176,7 @@ describe("Public API", () => { assetId, userId, } as z.infer<typeof zAssetSignedTokenSchema>, + SINGING_SECRET, Date.now() + 60000, // Expires in 60 seconds ); const res = await fetch( @@ -208,6 +211,7 @@ describe("Public API", () => { }; const token = createSignedToken( malformedInnerPayload, + SINGING_SECRET, Date.now() + 60000, ); const res = await fetch( @@ -225,6 +229,7 @@ describe("Public API", () => { assetId, userId, } as z.infer<typeof zAssetSignedTokenSchema>, + SINGING_SECRET, Date.now() + 1000, // Expires in 1 second ); @@ -256,6 +261,7 @@ describe("Public API", () => { assetId: anotherAssetId, userId, } as z.infer<typeof zAssetSignedTokenSchema>, + SINGING_SECRET, Date.now() + 60000, ); @@ -285,6 +291,7 @@ describe("Public API", () => { assetId: assetId, // assetId belongs to user1 (userId) userId: userIdUser2, // token claims user2 is accessing it } as z.infer<typeof zAssetSignedTokenSchema>, + SINGING_SECRET, Date.now() + 60000, ); @@ -307,6 +314,7 @@ describe("Public API", () => { assetId: nonExistentAssetId, userId, // Valid userId from the primary user } as z.infer<typeof zAssetSignedTokenSchema>, + SINGING_SECRET, Date.now() + 60000, ); diff --git a/packages/shared/signedTokens.test.ts b/packages/shared/signedTokens.test.ts new file mode 100644 index 00000000..35c3bbf3 --- /dev/null +++ b/packages/shared/signedTokens.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createSignedToken, + getAlignedExpiry, + SignedTokenPayload, + verifySignedToken, +} from "./signedTokens"; + +const SECRET = "secret"; + +describe("getAlignedExpiry", () => { + it("should align to next interval when within grace period", () => { + const now = new Date("2023-01-01T12:29:30Z"); // 30 seconds before next interval + const expiry = getAlignedExpiry(1800, 60, now); // 30min interval, 60s grace + expect(expiry).toBe(new Date("2023-01-01T13:00:00Z").getTime()); + }); + + it("should align to current interval when outside grace period", () => { + const now = new Date("2023-01-01T12:10:01Z"); + const expiry = getAlignedExpiry(1800, 60, now); // 30min interval, 60s grace + expect(expiry).toBe(new Date("2023-01-01T12:30:00Z").getTime()); + }); + + it("should handle exact interval boundary", () => { + const now = new Date("2023-01-01T12:00:00Z"); + const expiry = getAlignedExpiry(1800, 60, now); + expect(expiry).toBe(new Date("2023-01-01T12:30:00Z").getTime()); + }); +}); + +describe("signed tokens", () => { + const testSchema = z + .object({ + id: z.string(), + name: z.string(), + }) + .strict(); + + const testPayload = { + id: "123", + name: "John", + }; + + it("should create and verify valid token", () => { + const token = createSignedToken(testPayload, SECRET); + const verified = verifySignedToken(token, SECRET, testSchema); + expect(verified).toEqual(testPayload); + }); + + it("should return null for expired token", () => { + const token = createSignedToken(testPayload, SECRET, Date.now() - 1000); + const verified = verifySignedToken(token, SECRET, testSchema); + expect(verified).toBeNull(); + }); + + it("should return null for invalid signature", () => { + const token = createSignedToken(testPayload, SECRET); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const decoded: SignedTokenPayload = JSON.parse( + Buffer.from(token, "base64").toString(), + ); + decoded.signature = "tampered" + decoded.signature; + const tampered = Buffer.from(JSON.stringify(decoded)).toString("base64"); + const verified = verifySignedToken(tampered, SECRET, testSchema); + expect(verified).toBeNull(); + }); + + it("should return null if payload doesn't match schema", () => { + const invalidPayload = { ...testPayload, extra: "field" }; + const token = createSignedToken(invalidPayload, SECRET); + const verified = verifySignedToken(token, SECRET, testSchema); + expect(verified).toBeNull(); + }); + + it("should fail with different signing secrets", () => { + const token = createSignedToken(testPayload, "ONE SECRET"); + const verified = verifySignedToken(token, "ANOTHER SECRET", testSchema); + expect(verified).toBeNull(); + }); +}); diff --git a/packages/shared/signedTokens.ts b/packages/shared/signedTokens.ts index b5e27f3e..14a26f60 100644 --- a/packages/shared/signedTokens.ts +++ b/packages/shared/signedTokens.ts @@ -1,8 +1,6 @@ import crypto from "node:crypto"; import { z } from "zod"; -import serverConfig from "./config"; - const zTokenPayload = z.object({ payload: z.unknown(), expiresAt: z.number(), @@ -13,10 +11,45 @@ const zSignedTokenPayload = z.object({ signature: z.string(), }); +/** + * Returns the expiry date aligned to the specified interval. + * If the time left until the next interval is less than the grace period, + * it skips to the following interval. + * + * @param now - The current date and time (defaults to new Date()). + * @param intervalSeconds - The interval in seconds (e.g., 1800 for 30 mins). + * @param gracePeriodSeconds - The grace period in seconds. + * @returns The calculated expiry Date. + */ +export function getAlignedExpiry( + intervalSeconds: number, + gracePeriodSeconds: number, + now: Date = new Date(), +): number { + const ms = now.getTime(); + const intervalMs = intervalSeconds * 1000; + + // Find the next interval + const nextIntervalTime = + Math.floor(ms / intervalMs) * intervalMs + intervalMs; + + // Time left until the next interval + const timeLeft = nextIntervalTime - ms; + + // Decide which interval to use + const finalIntervalTime = + timeLeft < gracePeriodSeconds * 1000 + ? nextIntervalTime + intervalMs + : nextIntervalTime; + + return finalIntervalTime; +} + export type SignedTokenPayload = z.infer<typeof zSignedTokenPayload>; export function createSignedToken( payload: unknown, + secret: string, expiryEpoch?: number, ): string { const expiresAt = expiryEpoch ?? Date.now() + 5 * 60 * 1000; // 5 minutes from now @@ -28,7 +61,7 @@ export function createSignedToken( const payloadString = JSON.stringify(toBeSigned); const signature = crypto - .createHmac("sha256", serverConfig.signingSecret()) + .createHmac("sha256", secret) .update(payloadString) .digest("hex"); @@ -42,6 +75,7 @@ export function createSignedToken( export function verifySignedToken<T>( token: string, + secret: string, schema: z.ZodSchema<T>, ): T | null { try { @@ -52,7 +86,7 @@ export function verifySignedToken<T>( // Verify signature const expectedSignature = crypto - .createHmac("sha256", serverConfig.signingSecret()) + .createHmac("sha256", secret) .update(JSON.stringify(payload)) .digest("hex"); diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts index 6e9e5651..986fca58 100644 --- a/packages/trpc/models/bookmarks.ts +++ b/packages/trpc/models/bookmarks.ts @@ -28,7 +28,10 @@ import { tagsOnBookmarks, } from "@karakeep/db/schema"; import serverConfig from "@karakeep/shared/config"; -import { createSignedToken } from "@karakeep/shared/signedTokens"; +import { + createSignedToken, + getAlignedExpiry, +} from "@karakeep/shared/signedTokens"; import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets"; import { BookmarkTypes, @@ -332,7 +335,12 @@ export class Bookmark implements PrivacyAware { assetId, userId: this.ctx.user.id, }; - const signedToken = createSignedToken(payload); + const signedToken = createSignedToken( + payload, + serverConfig.signingSecret(), + // Tokens will expire in 1 hour and will have a grace period of 15mins + getAlignedExpiry(/* interval */ 3600, /* grace */ 900), + ); return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`; }; const getContent = ( |
