diff --git a/lib/knowhere-service-jwt-refresh.test.ts b/lib/knowhere-service-jwt-refresh.test.ts new file mode 100644 index 0000000..2f5cbd7 --- /dev/null +++ b/lib/knowhere-service-jwt-refresh.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createRemoteJWKSet: vi.fn(() => "jwks-set"), + jwtVerify: vi.fn(), +})); + +vi.mock("jose", () => ({ + createRemoteJWKSet: mocks.createRemoteJWKSet, + jwtVerify: mocks.jwtVerify, +})); + +import { + readServiceJwtUserId, + SERVICE_JWT_REFRESH_CLOCK_TOLERANCE_SECONDS, + verifyServiceJwtForRefresh, +} from "@/lib/knowhere-service-jwt-refresh"; + +describe("readServiceJwtUserId", () => { + it("reads the Dashboard service JWT user id claim", () => { + expect(readServiceJwtUserId({ id: "user_1" })).toBe("user_1"); + }); + + it("rejects empty or missing ids", () => { + expect(readServiceJwtUserId({})).toBeNull(); + expect(readServiceJwtUserId({ id: "" })).toBeNull(); + expect(readServiceJwtUserId({ id: 12 })).toBeNull(); + }); +}); + +describe("verifyServiceJwtForRefresh", () => { + beforeEach(() => { + mocks.createRemoteJWKSet.mockClear(); + mocks.jwtVerify.mockReset(); + mocks.createRemoteJWKSet.mockReturnValue("jwks-set"); + }); + + it("verifies with a seven-day clock tolerance and returns the user id", async () => { + mocks.jwtVerify.mockResolvedValue({ payload: { id: "user_1" } }); + + await expect( + verifyServiceJwtForRefresh("expired.jwt", "https://dashboard.example/api/auth/jwks") + ).resolves.toBe("user_1"); + + expect(mocks.createRemoteJWKSet).toHaveBeenCalledWith( + new URL("https://dashboard.example/api/auth/jwks") + ); + expect(mocks.jwtVerify).toHaveBeenCalledWith("expired.jwt", "jwks-set", { + clockTolerance: SERVICE_JWT_REFRESH_CLOCK_TOLERANCE_SECONDS, + }); + expect(SERVICE_JWT_REFRESH_CLOCK_TOLERANCE_SECONDS).toBe(7 * 24 * 60 * 60); + }); + + it("rejects tokens whose payload has no user id", async () => { + mocks.jwtVerify.mockResolvedValue({ payload: { sub: "user_1" } }); + + await expect( + verifyServiceJwtForRefresh("expired.jwt", "https://dashboard.example/api/auth/jwks") + ).rejects.toThrow("Service JWT is missing a user id."); + }); +}); diff --git a/lib/knowhere-service-jwt-refresh.ts b/lib/knowhere-service-jwt-refresh.ts new file mode 100644 index 0000000..c5730a1 --- /dev/null +++ b/lib/knowhere-service-jwt-refresh.ts @@ -0,0 +1,25 @@ +import { createRemoteJWKSet, type JWTPayload, jwtVerify } from "jose"; + +/** + * Dashboard service JWTs last one hour. QStash workflows may keep a snapshot + * for much longer, so refresh accepts recently expired tokens whose signature + * still verifies. Seven days is the outer bound for that replay window. + */ +export const SERVICE_JWT_REFRESH_CLOCK_TOLERANCE_SECONDS = 7 * 24 * 60 * 60; + +export function readServiceJwtUserId(payload: JWTPayload): string | null { + const userId = payload.id; + return typeof userId === "string" && userId.length > 0 ? userId : null; +} + +export async function verifyServiceJwtForRefresh(token: string, jwksUrl: string): Promise { + const jwks = createRemoteJWKSet(new URL(jwksUrl)); + const { payload } = await jwtVerify(token, jwks, { + clockTolerance: SERVICE_JWT_REFRESH_CLOCK_TOLERANCE_SECONDS, + }); + const userId = readServiceJwtUserId(payload); + if (!userId) { + throw new Error("Service JWT is missing a user id."); + } + return userId; +} diff --git a/package.json b/package.json index bdc6905..1ee02b4 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "embla-carousel-react": "^8.6.0", "framer-motion": "^12.26.2", "input-otp": "^1.4.2", + "jose": "^6.1.0", "lenis": "^1.3.17", "lucide-react": "^0.294.0", "next": "^16.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aec4744..c3692dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + jose: + specifier: ^6.1.0 + version: 6.1.3 lenis: specifier: ^1.3.17 version: 1.3.17(react@19.2.3) diff --git a/server/routers/index.ts b/server/routers/index.ts index bf35db1..60d6df2 100644 --- a/server/routers/index.ts +++ b/server/routers/index.ts @@ -2,6 +2,7 @@ import { base } from "@server/context"; import { apiKeysRouter } from "@server/routers/api-keys"; import { creditsRouter } from "@server/routers/credits"; import { jobsRouter } from "@server/routers/jobs"; +import { knowhereServiceJwtRouter } from "@server/routers/knowhere-service-jwt"; import { newsletterRouter } from "@server/routers/newsletter"; import { subscriptionsRouter } from "@server/routers/subscriptions"; import { usageRouter } from "@server/routers/usage"; @@ -12,6 +13,7 @@ import { webhookSecretsRouter } from "@server/routers/webhook-secrets"; export const appRouter = base.router({ apiKeys: apiKeysRouter, users: usersRouter, + knowhereServiceJwt: knowhereServiceJwtRouter, credits: creditsRouter, subscriptions: subscriptionsRouter, newsletter: newsletterRouter, diff --git a/server/routers/knowhere-service-jwt.ts b/server/routers/knowhere-service-jwt.ts new file mode 100644 index 0000000..5d1536f --- /dev/null +++ b/server/routers/knowhere-service-jwt.ts @@ -0,0 +1,40 @@ +import { env } from "@lib/env"; +import { ORPCError } from "@orpc/server"; +import { + issueKnowhereServiceJwt, + KNOWHERE_SERVICE_JWT_EXPIRY_SECONDS, +} from "@server/knowhere-service-jwt"; +import { publicProcedure } from "@server/orpc"; +import { z } from "zod"; +import { verifyServiceJwtForRefresh } from "@/lib/knowhere-service-jwt-refresh"; + +export const knowhereServiceJwtRouter = publicProcedure.router({ + /** + * Re-issue a one-hour Knowhere service JWT from a still-signed snapshot. + * + * Notebook QStash workflows cannot send the Dashboard session cookie, so they + * POST the stored JWT here when it is near expiry. Signature is required; + * expiration may be up to seven days in the past. + */ + refresh: publicProcedure + .input(z.object({ token: z.string().min(1) })) + .handler(async ({ input }) => { + let userId: string; + try { + userId = await verifyServiceJwtForRefresh( + input.token, + `${env.BETTER_AUTH_URL}/api/auth/jwks` + ); + } catch { + throw new ORPCError("UNAUTHORIZED", { + status: 401, + message: "Authentication required", + }); + } + + return { + token: await issueKnowhereServiceJwt(userId), + expiresInSeconds: KNOWHERE_SERVICE_JWT_EXPIRY_SECONDS, + }; + }), +}); diff --git a/server/routers/users.ts b/server/routers/users.ts index 2a9c523..3ad250b 100644 --- a/server/routers/users.ts +++ b/server/routers/users.ts @@ -533,8 +533,9 @@ export const usersRouter = protectedProcedure.router({ * No persistent Knowhere API key is created, stored, or returned. * * The Dashboard session cookie already authenticates the caller, so - * the JWT stays short-lived. If a relying app needs a fresh token - * later, it calls this endpoint again with the still-valid session. + * the JWT stays short-lived. Interactive callers refresh by calling this + * endpoint again with the still-valid session. Background workflows + * without a session cookie use `knowhereServiceJwt.refresh`. */ issueServiceJwt: protectedProcedure.handler(async ({ context }) => { return {