@spawnco/server
Version:
Server SDK
286 lines (241 loc) • 8.14 kB
text/typescript
import { describe, it, expect, beforeEach, vi } from "vitest";
import jwt from "jsonwebtoken";
import { generateKeyPairSync } from "crypto";
import { TokenVerifier } from "./token-verifier";
import { TokenPayload } from "@spawnco/sdk-types";
import { pem2jwk } from "pem-jwk";
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("TokenVerifier", () => {
let tokenVerifier: TokenVerifier;
let mockJWKS: any;
let publicKey: string;
let privateKey: string;
const keyId = "test-key-2024";
beforeEach(() => {
vi.clearAllMocks();
// Generate test keys
const keyPair = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: {
type: "spki",
format: "pem",
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
},
});
publicKey = keyPair.publicKey;
privateKey = keyPair.privateKey;
// Create mock JWKS response
const jwk = pem2jwk(publicKey);
mockJWKS = {
keys: [
{
...jwk,
kid: keyId,
use: "sig",
alg: "RS256",
},
],
};
// Reset fetch mock
mockFetch.mockReset();
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockJWKS,
});
tokenVerifier = new TokenVerifier(
"https://test.spawn.com/api/.well-known/jwks.json",
);
});
describe("constructor", () => {
it("should use provided JWKS URL", () => {
const customUrl = "https://custom.example.com/jwks.json";
const verifier = new TokenVerifier(customUrl);
expect(verifier).toBeDefined();
});
it("should use default URL when none provided", () => {
const verifier = new TokenVerifier();
expect(verifier).toBeDefined();
});
it("should construct URL from SPAWN_API_URL env var", () => {
process.env.SPAWN_API_URL = "https://api.spawn.test";
const verifier = new TokenVerifier();
expect(verifier).toBeDefined();
delete process.env.SPAWN_API_URL;
});
});
// Helper function to create test tokens
const createTestToken = (
payload: Partial<TokenPayload>,
options?: jwt.SignOptions,
) => {
const defaultPayload: TokenPayload = {
sub: "user-123",
variantId: "variant-456",
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
isGuest: false,
...payload,
};
return jwt.sign(defaultPayload, privateKey, {
algorithm: "RS256",
keyid: keyId,
...options,
});
};
describe("verify", () => {
it("should successfully verify a valid token", async () => {
const token = createTestToken({});
const result = await tokenVerifier.verify(token);
expect(result).toBeDefined();
expect(result.sub).toBe("user-123");
expect(result.variantId).toBe("variant-456");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("should throw error for invalid token format", async () => {
await expect(tokenVerifier.verify("invalid-token")).rejects.toThrow(
"Invalid token format",
);
expect(mockFetch).not.toHaveBeenCalled();
});
it("should throw error for token without key ID", async () => {
// Create token without kid
const token = jwt.sign({ sub: "user-123" }, privateKey, {
algorithm: "RS256",
});
await expect(tokenVerifier.verify(token)).rejects.toThrow(
"Token missing key ID",
);
expect(mockFetch).not.toHaveBeenCalled();
});
it("should throw error when JWKS fetch fails", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const token = createTestToken({});
await expect(tokenVerifier.verify(token)).rejects.toThrow(
"Failed to fetch JWKS: 500 Internal Server Error",
);
});
it("should throw error for unknown key ID", async () => {
const token = jwt.sign({ sub: "user-123" }, privateKey, {
algorithm: "RS256",
keyid: "unknown-key-id",
});
await expect(tokenVerifier.verify(token)).rejects.toThrow(
"Key unknown-key-id not found in JWKS",
);
});
it("should throw error for expired token", async () => {
const token = createTestToken({
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
});
await expect(tokenVerifier.verify(token)).rejects.toThrow(/expired/i);
});
it("should throw error for token signed with wrong algorithm", async () => {
const token = jwt.sign({ sub: "user-123" }, "secret", {
algorithm: "HS256",
keyid: keyId,
});
await expect(tokenVerifier.verify(token)).rejects.toThrow();
});
it("should cache JWKS and not fetch twice for same key", async () => {
const token = createTestToken({});
// First verification
await tokenVerifier.verify(token);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Second verification should use cache
await tokenVerifier.verify(token);
expect(mockFetch).toHaveBeenCalledTimes(1); // Still only 1 call
});
it("should fetch new key when different key ID is used", async () => {
const token1 = createTestToken({});
await tokenVerifier.verify(token1);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Create new key and token
const newKeyPair = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
const newJwk = pem2jwk(newKeyPair.publicKey);
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
keys: [
...mockJWKS.keys,
{
...newJwk,
kid: "new-key-2024",
use: "sig",
alg: "RS256",
},
],
}),
});
const token2 = jwt.sign({ sub: "user-456" }, newKeyPair.privateKey, {
algorithm: "RS256",
keyid: "new-key-2024",
});
await tokenVerifier.verify(token2);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe("clearCache", () => {
it("should clear the cache and force new fetch", async () => {
const token = createTestToken({});
// First verification
await tokenVerifier.verify(token);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Clear cache
tokenVerifier.clearCache();
// Next verification should fetch again
await tokenVerifier.verify(token);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe("cache expiration", () => {
it.skip("should refetch JWKS after cache expires", async () => {
// This test requires timer mocking which is not fully supported in Bun's vitest compatibility
// In a real scenario, the cache would expire after 1 hour
});
});
describe("error handling", () => {
it("should handle network errors gracefully", async () => {
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const token = createTestToken({});
await expect(tokenVerifier.verify(token)).rejects.toThrow(
"Network error",
);
});
it("should handle malformed JWKS response", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ invalid: "response" }), // No keys array
});
const token = createTestToken({});
await expect(tokenVerifier.verify(token)).rejects.toThrow();
});
it("should handle invalid JWK in response", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
keys: [
{
kid: keyId,
// Missing required fields for JWK
},
],
}),
});
const token = createTestToken({});
await expect(tokenVerifier.verify(token)).rejects.toThrow();
});
});
});