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 /packages/shared | |
| parent | f1f665f89cba21d4d448d27471d01a4d78a184ff (diff) | |
| download | karakeep-10d45e8d14cdc3672cc65dc7f5ae79e63fb2da1a.tar.zst | |
fix: Change public image's signed tokens to be time aligned for better caching
Diffstat (limited to 'packages/shared')
| -rw-r--r-- | packages/shared/signedTokens.test.ts | 82 | ||||
| -rw-r--r-- | packages/shared/signedTokens.ts | 42 |
2 files changed, 120 insertions, 4 deletions
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"); |
