Skip to content
Open
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
1 change: 1 addition & 0 deletions rivetkit-typescript/packages/rivetkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
"drizzle-orm": "^0.44.2",
"hono": "^4.7.0",
"invariant": "^2.2.4",
"jose": "^5.9.6",
"p-retry": "^6.2.1",
"pino": "^9.5.0",
"uuid": "^12.0.0",
Expand Down
58 changes: 58 additions & 0 deletions rivetkit-typescript/packages/rivetkit/src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as jose from "jose";

export interface VerifyJwtOptions {
/**
* The JWKS URI to fetch the public keys from.
* E.g., "https://example.com/.well-known/jwks.json"
*/
jwksUri: string;
/**
* Expected issuer of the JWT.
*/
issuer?: string | string[];
/**
* Expected audience of the JWT.
*/
audience?: string | string[];
/**
* Allowed clock tolerance in seconds. Default is 0.
*/
clockTolerance?: number;
/**
* Additional options to pass to `jose.jwtVerify`.
*/
algorithms?: string[];
}

const jwksCache = new Map<string, ReturnType<typeof jose.createRemoteJWKSet>>();

function getJwks(jwksUri: string) {
let jwks = jwksCache.get(jwksUri);
if (!jwks) {
jwks = jose.createRemoteJWKSet(new URL(jwksUri));
jwksCache.set(jwksUri, jwks);
}
return jwks;
}

/**
* Verifies a JSON Web Token (JWT) using the keys from the specified JWKS URI.
* The JWKS result is automatically cached per URI to reduce overhead.
*
* @param token The raw JWT token string.
* @param options Options for verifying the token, requiring at least a `jwksUri`.
* @returns The verified JWT payload and protected header.
*/
export async function verifyJwt(
token: string,
options: VerifyJwtOptions,
): Promise<jose.JWTVerifyResult> {
const JWKS = getJwks(options.jwksUri);

return await jose.jwtVerify(token, JWKS, {
issuer: options.issuer,
audience: options.audience,
clockTolerance: options.clockTolerance,
algorithms: options.algorithms,
});
}
1 change: 1 addition & 0 deletions rivetkit-typescript/packages/rivetkit/src/auth/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./jwt";
1 change: 1 addition & 0 deletions rivetkit-typescript/packages/rivetkit/src/mod.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "@/actor/mod";
export * from "@/auth/mod";
export {
type AnyClient,
type Client,
Expand Down
105 changes: 105 additions & 0 deletions rivetkit-typescript/packages/rivetkit/tests/auth/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import * as jose from "jose";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { verifyJwt } from "../../src/auth/jwt";

describe("verifyJwt", () => {
let jwksServer: ReturnType<typeof serve>;
let jwksUrl: string;
let privateKey: jose.KeyLike;
let publicKey: jose.KeyLike;
let keyId = "test-key-1";

beforeAll(async () => {
// Generate an RSA key pair for testing
const { publicKey: pub, privateKey: priv } =
await jose.generateKeyPair("RS256");
publicKey = pub;
privateKey = priv;

// Export the public key to JWK format
const jwk = await jose.exportJWK(publicKey);
jwk.kid = keyId;
jwk.alg = "RS256";
jwk.use = "sig";

// Create a mock JWKS server
const app = new Hono();
app.get("/.well-known/jwks.json", (c) => {
return c.json({ keys: [jwk] });
});

return new Promise((resolve) => {
jwksServer = serve(
{
fetch: app.fetch,
port: 0, // dynamic port
},
(info) => {
jwksUrl = `http://localhost:${info.port}/.well-known/jwks.json`;
resolve();
},
);
});
});

afterAll(() => {
if (jwksServer) {
jwksServer.close();
}
});

test("successfully verifies a valid JWT", async () => {
const token = await new jose.SignJWT({ userId: "123" })
.setProtectedHeader({ alg: "RS256", kid: keyId })
.setIssuedAt()
.setIssuer("https://example.com")
.setAudience("my-client")
.setExpirationTime("2h")
.sign(privateKey);

const result = await verifyJwt(token, {
jwksUri: jwksUrl,
issuer: "https://example.com",
audience: "my-client",
});

expect(result.payload.userId).toBe("123");
expect(result.protectedHeader.kid).toBe(keyId);
});

test("fails if token has wrong issuer", async () => {
const token = await new jose.SignJWT({ userId: "123" })
.setProtectedHeader({ alg: "RS256", kid: keyId })
.setIssuedAt()
.setIssuer("https://wrong-issuer.com")
.setAudience("my-client")
.setExpirationTime("2h")
.sign(privateKey);

await expect(
verifyJwt(token, {
jwksUri: jwksUrl,
issuer: "https://example.com",
audience: "my-client",
}),
).rejects.toThrow("unexpected \"iss\" claim value");
});

test("fails if token has expired", async () => {
const token = await new jose.SignJWT({ userId: "123" })
.setProtectedHeader({ alg: "RS256", kid: keyId })
.setIssuedAt()
.setIssuer("https://example.com")
.setExpirationTime("-1h") // Expired 1 hour ago
.sign(privateKey);

await expect(
verifyJwt(token, {
jwksUri: jwksUrl,
issuer: "https://example.com",
}),
).rejects.toThrow(/\"exp\" claim timestamp check failed/);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// vitest.config.ts
import { resolve } from "node:path";
import tsconfigPaths from "file:///D:/Desktop/rivet/actors/node_modules/.pnpm/vite-tsconfig-paths@5.1.4_t_b43743a918cc653bf66351419e9e59f0/node_modules/vite-tsconfig-paths/dist/index.js";
import { configDefaults, defineConfig } from "file:///D:/Desktop/rivet/actors/node_modules/.pnpm/vitest@3.2.4_@types+debug@4_665f696678b4dfdca3e79bee3a3839d5/node_modules/vitest/dist/config.js";

// ../../../vitest.base.ts
import { availableParallelism, cpus } from "node:os";
var maxConcurrency = (() => {
try {
return availableParallelism();
} catch {
return cpus().length;
}
})();
var vitest_base_default = {
test: {
testTimeout: 1e4,
hookTimeout: 1e4,
maxConcurrency,
// Enable parallelism
sequence: {
concurrent: true
},
env: {
// Enable logging
RIVET_LOG_LEVEL: "DEBUG",
RIVET_LOG_TARGET: "1",
RIVET_LOG_TIMESTAMP: "1",
RIVET_LOG_ERROR_STACK: "1",
RIVET_LOG_MESSAGE: "1"
}
}
};

// vitest.config.ts
var __vite_injected_original_dirname = "D:\\Desktop\\rivet\\actors\\rivetkit-typescript\\packages\\rivetkit";
var vitest_config_default = defineConfig({
...vitest_base_default,
test: {
...vitest_base_default.test,
exclude: [
...configDefaults.exclude,
...process.env.RIVETKIT_INCLUDE_PLATFORM_TESTS === "1" ? [] : ["tests/platforms/**/*.test.ts"]
],
fileParallelism: false,
testTimeout: 3e4,
hookTimeout: 3e4,
minWorkers: 1,
maxWorkers: 1,
sequence: {
...vitest_base_default.test.sequence,
concurrent: false
}
},
// Used to resolve "rivetkit" to "src/mod.ts" in the test fixtures
plugins: [tsconfigPaths()],
resolve: {
alias: {
"@": resolve(__vite_injected_original_dirname, "./src"),
"rivetkit/errors": resolve(__vite_injected_original_dirname, "./src/actor/errors.ts")
}
}
});
export {
vitest_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZXN0LmNvbmZpZy50cyIsICIuLi8uLi8uLi92aXRlc3QuYmFzZS50cyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIkQ6XFxcXERlc2t0b3BcXFxccml2ZXRcXFxcYWN0b3JzXFxcXHJpdmV0a2l0LXR5cGVzY3JpcHRcXFxccGFja2FnZXNcXFxccml2ZXRraXRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIkQ6XFxcXERlc2t0b3BcXFxccml2ZXRcXFxcYWN0b3JzXFxcXHJpdmV0a2l0LXR5cGVzY3JpcHRcXFxccGFja2FnZXNcXFxccml2ZXRraXRcXFxcdml0ZXN0LmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vRDovRGVza3RvcC9yaXZldC9hY3RvcnMvcml2ZXRraXQtdHlwZXNjcmlwdC9wYWNrYWdlcy9yaXZldGtpdC92aXRlc3QuY29uZmlnLnRzXCI7aW1wb3J0IHsgcmVzb2x2ZSB9IGZyb20gXCJub2RlOnBhdGhcIjtcbmltcG9ydCB0c2NvbmZpZ1BhdGhzIGZyb20gXCJ2aXRlLXRzY29uZmlnLXBhdGhzXCI7XG5pbXBvcnQgeyBjb25maWdEZWZhdWx0cywgZGVmaW5lQ29uZmlnIH0gZnJvbSBcInZpdGVzdC9jb25maWdcIjtcbmltcG9ydCBkZWZhdWx0Q29uZmlnIGZyb20gXCIuLi8uLi8uLi92aXRlc3QuYmFzZS50c1wiO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuXHQuLi5kZWZhdWx0Q29uZmlnLFxuXHR0ZXN0OiB7XG5cdFx0Li4uZGVmYXVsdENvbmZpZy50ZXN0LFxuXHRcdGV4Y2x1ZGU6IFtcblx0XHRcdC4uLmNvbmZpZ0RlZmF1bHRzLmV4Y2x1ZGUsXG5cdFx0XHQuLi4ocHJvY2Vzcy5lbnYuUklWRVRLSVRfSU5DTFVERV9QTEFURk9STV9URVNUUyA9PT0gXCIxXCJcblx0XHRcdFx0PyBbXVxuXHRcdFx0XHQ6IFtcInRlc3RzL3BsYXRmb3Jtcy8qKi8qLnRlc3QudHNcIl0pLFxuXHRcdF0sXG5cdFx0ZmlsZVBhcmFsbGVsaXNtOiBmYWxzZSxcblx0XHR0ZXN0VGltZW91dDogMzBfMDAwLFxuXHRcdGhvb2tUaW1lb3V0OiAzMF8wMDAsXG5cdFx0bWluV29ya2VyczogMSxcblx0XHRtYXhXb3JrZXJzOiAxLFxuXHRcdHNlcXVlbmNlOiB7XG5cdFx0XHQuLi5kZWZhdWx0Q29uZmlnLnRlc3Quc2VxdWVuY2UsXG5cdFx0XHRjb25jdXJyZW50OiBmYWxzZSxcblx0XHR9LFxuXHR9LFxuXHQvLyBVc2VkIHRvIHJlc29sdmUgXCJyaXZldGtpdFwiIHRvIFwic3JjL21vZC50c1wiIGluIHRoZSB0ZXN0IGZpeHR1cmVzXG5cdHBsdWdpbnM6IFt0c2NvbmZpZ1BhdGhzKCldLFxuXHRyZXNvbHZlOiB7XG5cdFx0YWxpYXM6IHtcblx0XHRcdFwiQFwiOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyY1wiKSxcblx0XHRcdFwicml2ZXRraXQvZXJyb3JzXCI6IHJlc29sdmUoX19kaXJuYW1lLCBcIi4vc3JjL2FjdG9yL2Vycm9ycy50c1wiKSxcblx0XHR9LFxuXHR9LFxufSk7XG4iLCAiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIkQ6XFxcXERlc2t0b3BcXFxccml2ZXRcXFxcYWN0b3JzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJEOlxcXFxEZXNrdG9wXFxcXHJpdmV0XFxcXGFjdG9yc1xcXFx2aXRlc3QuYmFzZS50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vRDovRGVza3RvcC9yaXZldC9hY3RvcnMvdml0ZXN0LmJhc2UudHNcIjtpbXBvcnQgeyBhdmFpbGFibGVQYXJhbGxlbGlzbSwgY3B1cyB9IGZyb20gXCJub2RlOm9zXCI7XG5pbXBvcnQgdHlwZSB7IFZpdGVVc2VyQ29uZmlnIH0gZnJvbSBcInZpdGVzdC9jb25maWdcIjtcblxuY29uc3QgbWF4Q29uY3VycmVuY3kgPSAoKCkgPT4ge1xuXHR0cnkge1xuXHRcdHJldHVybiBhdmFpbGFibGVQYXJhbGxlbGlzbSgpO1xuXHR9IGNhdGNoIHtcblx0XHRyZXR1cm4gY3B1cygpLmxlbmd0aDtcblx0fVxufSkoKTtcblxuZXhwb3J0IGRlZmF1bHQge1xuXHR0ZXN0OiB7XG5cdFx0dGVzdFRpbWVvdXQ6IDEwXzAwMCxcblx0XHRob29rVGltZW91dDogMTBfMDAwLFxuXHRcdG1heENvbmN1cnJlbmN5LFxuXHRcdC8vIEVuYWJsZSBwYXJhbGxlbGlzbVxuXHRcdHNlcXVlbmNlOiB7XG5cdFx0XHRjb25jdXJyZW50OiB0cnVlLFxuXHRcdH0sXG5cdFx0ZW52OiB7XG5cdFx0XHQvLyBFbmFibGUgbG9nZ2luZ1xuXHRcdFx0UklWRVRfTE9HX0xFVkVMOiBcIkRFQlVHXCIsXG5cdFx0XHRSSVZFVF9MT0dfVEFSR0VUOiBcIjFcIixcblx0XHRcdFJJVkVUX0xPR19USU1FU1RBTVA6IFwiMVwiLFxuXHRcdFx0UklWRVRfTE9HX0VSUk9SX1NUQUNLOiBcIjFcIixcblx0XHRcdFJJVkVUX0xPR19NRVNTQUdFOiBcIjFcIixcblx0XHR9LFxuXHR9LFxufSBzYXRpc2ZpZXMgVml0ZVVzZXJDb25maWc7XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQTJYLFNBQVMsZUFBZTtBQUNuWixPQUFPLG1CQUFtQjtBQUMxQixTQUFTLGdCQUFnQixvQkFBb0I7OztBQ0ZrTixTQUFTLHNCQUFzQixZQUFZO0FBRzFTLElBQU0sa0JBQWtCLE1BQU07QUFDN0IsTUFBSTtBQUNILFdBQU8scUJBQXFCO0FBQUEsRUFDN0IsUUFBUTtBQUNQLFdBQU8sS0FBSyxFQUFFO0FBQUEsRUFDZjtBQUNELEdBQUc7QUFFSCxJQUFPLHNCQUFRO0FBQUEsRUFDZCxNQUFNO0FBQUEsSUFDTCxhQUFhO0FBQUEsSUFDYixhQUFhO0FBQUEsSUFDYjtBQUFBO0FBQUEsSUFFQSxVQUFVO0FBQUEsTUFDVCxZQUFZO0FBQUEsSUFDYjtBQUFBLElBQ0EsS0FBSztBQUFBO0FBQUEsTUFFSixpQkFBaUI7QUFBQSxNQUNqQixrQkFBa0I7QUFBQSxNQUNsQixxQkFBcUI7QUFBQSxNQUNyQix1QkFBdUI7QUFBQSxNQUN2QixtQkFBbUI7QUFBQSxJQUNwQjtBQUFBLEVBQ0Q7QUFDRDs7O0FEN0JBLElBQU0sbUNBQW1DO0FBS3pDLElBQU8sd0JBQVEsYUFBYTtBQUFBLEVBQzNCLEdBQUc7QUFBQSxFQUNILE1BQU07QUFBQSxJQUNMLEdBQUcsb0JBQWM7QUFBQSxJQUNqQixTQUFTO0FBQUEsTUFDUixHQUFHLGVBQWU7QUFBQSxNQUNsQixHQUFJLFFBQVEsSUFBSSxvQ0FBb0MsTUFDakQsQ0FBQyxJQUNELENBQUMsOEJBQThCO0FBQUEsSUFDbkM7QUFBQSxJQUNBLGlCQUFpQjtBQUFBLElBQ2pCLGFBQWE7QUFBQSxJQUNiLGFBQWE7QUFBQSxJQUNiLFlBQVk7QUFBQSxJQUNaLFlBQVk7QUFBQSxJQUNaLFVBQVU7QUFBQSxNQUNULEdBQUcsb0JBQWMsS0FBSztBQUFBLE1BQ3RCLFlBQVk7QUFBQSxJQUNiO0FBQUEsRUFDRDtBQUFBO0FBQUEsRUFFQSxTQUFTLENBQUMsY0FBYyxDQUFDO0FBQUEsRUFDekIsU0FBUztBQUFBLElBQ1IsT0FBTztBQUFBLE1BQ04sS0FBSyxRQUFRLGtDQUFXLE9BQU87QUFBQSxNQUMvQixtQkFBbUIsUUFBUSxrQ0FBVyx1QkFBdUI7QUFBQSxJQUM5RDtBQUFBLEVBQ0Q7QUFDRCxDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=