Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions lib/knowhere-service-jwt-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
});
25 changes: 25 additions & 0 deletions lib/knowhere-service-jwt-refresh.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions server/routers/knowhere-service-jwt.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}),
});
5 changes: 3 additions & 2 deletions server/routers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading