UNPKG

@spawnco/server

Version:

Server SDK

286 lines (241 loc) 8.14 kB
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(); }); }); });