diff --git a/.changeset/server-jwks-background-refresh.md b/.changeset/server-jwks-background-refresh.md new file mode 100644 index 0000000000..8fb99318df --- /dev/null +++ b/.changeset/server-jwks-background-refresh.md @@ -0,0 +1,5 @@ +--- +"@crossmint/server-sdk": patch +--- + +JWT verification no longer waits for the JWKS to be re-downloaded every 10 minutes: the keys are refreshed in the background while verification keeps using the current ones. If the JWKS endpoint keeps failing, keys stay in use for up to an hour; a token signed with an unknown key still waits for a download, as before. diff --git a/.changeset/server-session-user.md b/.changeset/server-session-user.md new file mode 100644 index 0000000000..282c6064c7 --- /dev/null +++ b/.changeset/server-session-user.md @@ -0,0 +1,6 @@ +--- +"@crossmint/server-sdk": patch +"@crossmint/common-sdk-auth": patch +--- + +`getSession` returns the user (email, phone number, linked accounts) when it had to refresh the session, since the refresh response already carries it. A server that needs the email can skip `getUser` in that case. diff --git a/packages/common/auth/src/types/authmaterial.ts b/packages/common/auth/src/types/authmaterial.ts index 505560a52b..446387c460 100644 --- a/packages/common/auth/src/types/authmaterial.ts +++ b/packages/common/auth/src/types/authmaterial.ts @@ -24,6 +24,11 @@ export type AuthSession = { jwt: string; refreshToken: RefreshToken; userId: string; + /** + * The user (email, phone number and other linked accounts), when getting the session refreshed it: the + * refresh response carries it. Undefined when the JWT was still valid; fetch the user then. + */ + user?: SDKExternalUser; }; interface RefreshToken { diff --git a/packages/server/src/auth/CrossmintAuthServer.ts b/packages/server/src/auth/CrossmintAuthServer.ts index 9edea83f3f..2ebdd0395f 100644 --- a/packages/server/src/auth/CrossmintAuthServer.ts +++ b/packages/server/src/auth/CrossmintAuthServer.ts @@ -61,7 +61,8 @@ export class CrossmintAuthServer extends CrossmintAuth { * itself (`{ jwt, refreshToken }`). From a request, the cookies are read under the project-scoped * names the client SDK writes (see `getAuthCookieNames`), falling back to * the legacy `crossmint-jwt` / `crossmint-refresh-token` names. If a response object is provided, - * refreshed auth material is stored in its cookies. + * refreshed auth material is stored in its cookies. When the session was refreshed, the result also + * holds the `user` (email, phone number, linked accounts), so no {@link getUser} call is needed. * * @throws CrossmintAuthenticationError if no valid session can be established. */ @@ -241,6 +242,8 @@ export class CrossmintAuthServer extends CrossmintAuth { jwt: refreshedAuthMaterial.jwt, refreshToken: refreshedAuthMaterial.refreshToken, userId, + // verifyRefreshedAuthMaterial checked that this user is the JWT's subject + user: refreshedAuthMaterial.user, }; } diff --git a/packages/server/src/auth/crossmint-auth-server.test.ts b/packages/server/src/auth/crossmint-auth-server.test.ts index 9466eb99fa..5156f58b55 100644 --- a/packages/server/src/auth/crossmint-auth-server.test.ts +++ b/packages/server/src/auth/crossmint-auth-server.test.ts @@ -158,7 +158,7 @@ describe("CrossmintAuthServer", () => { secret: "new-refresh-token", expiresAt: "2023-12-31T23:59:59Z", }, - user: { id: "user456" }, + user: { id: "user456", email: "user@example.com" }, }), ok: true, }); @@ -172,6 +172,7 @@ describe("CrossmintAuthServer", () => { expiresAt: "2023-12-31T23:59:59Z", }, userId: "user456", + user: { id: "user456", email: "user@example.com" }, }); expect(jwtUtils.verifyCrossmintJwt).toHaveBeenCalledWith( refreshedJwt, diff --git a/packages/server/src/auth/utils/jwks-client.test.ts b/packages/server/src/auth/utils/jwks-client.test.ts index 28183bfe5c..51fd02bb4d 100644 --- a/packages/server/src/auth/utils/jwks-client.test.ts +++ b/packages/server/src/auth/utils/jwks-client.test.ts @@ -1,64 +1,202 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { exportJWK, generateKeyPair, SignJWT, type JWK, type KeyLike } from "jose"; import { createJWKSClient } from "./jwksClient"; -import { createRemoteJWKSet } from "jose"; +import { JwksUnavailableError, verifyCrossmintJwt } from "./jwt"; -vi.mock("jose", () => ({ - createRemoteJWKSet: vi.fn(), -})); +// Runs verifyCrossmintJwt against a real JWKS endpoint on localhost, with only Date faked to move key age. + +const AUDIENCE = "project-a"; +const MINUTE = 60 * 1000; + +type EndpointState = "up" | "down" | "hanging"; + +type JwksEndpoint = { + uri: string; + state: EndpointState; + requests: number; + addKey(kid: string): Promise; + removeKey(kid: string): void; + sign(kid: string): Promise; + close(): Promise; +}; + +async function startJwksEndpoint(): Promise { + const privateKeys = new Map(); + const publicJwks: JWK[] = []; + + const endpoint = { + state: "up" as EndpointState, + requests: 0, + async addKey(kid: string) { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + privateKeys.set(kid, privateKey); + publicJwks.push({ ...(await exportJWK(publicKey)), kid, alg: "RS256", use: "sig" }); + }, + removeKey(kid: string) { + publicJwks.splice( + publicJwks.findIndex((jwk) => jwk.kid === kid), + 1 + ); + }, + sign(kid: string) { + const key = privateKeys.get(kid); + if (key == null) { + throw new Error(`no key ${kid}`); + } + return new SignJWT({}) + .setProtectedHeader({ alg: "RS256", kid }) + .setSubject("user-1") + .setAudience(AUDIENCE) + .setIssuedAt() + .setExpirationTime("1d") + .sign(key); + }, + }; + + const server: Server = createServer((_req, res) => { + endpoint.requests++; + if (endpoint.state === "hanging") { + return; // never answers; closed in close() + } + if (endpoint.state === "down") { + res.writeHead(503).end(); + return; + } + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ keys: publicJwks })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + + await endpoint.addKey("key-1"); + return Object.assign(endpoint, { + // a new port per endpoint, so each test gets its own cached client + uri: `http://127.0.0.1:${port}/.well-known/jwks.json`, + close: () => { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }); +} describe("createJWKSClient", () => { - const validJwksUri = "https://example.com/.well-known/jwks.json"; - const retryJwksUri = "https://retry.example.com/.well-known/jwks.json"; - const cachedJwksUri = "https://cached.example.com/.well-known/jwks.json"; - const originalConsoleError = console.error; - - beforeEach(() => { - vi.resetAllMocks(); - console.error = vi.fn(); + let endpoint: JwksEndpoint; + let start: number; + + function at(minutes: number) { + vi.setSystemTime(start + minutes * MINUTE); + } + + async function verify(kid = "key-1") { + return verifyCrossmintJwt(await endpoint.sign(kid), endpoint.uri, { audience: AUDIENCE }); + } + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + start = Date.now(); + endpoint = await startJwksEndpoint(); }); - afterEach(() => { + afterEach(async () => { + await endpoint.close(); + vi.useRealTimers(); vi.restoreAllMocks(); - console.error = originalConsoleError; }); - it("creates a JWKS client for a valid URI", () => { - const mockJWKSFunction = vi.fn() as unknown as ReturnType; - vi.mocked(createRemoteJWKSet).mockReturnValue(mockJWKSFunction); + it("returns the same client for the same URI", () => { + expect(createJWKSClient(endpoint.uri)).toBe(createJWKSClient(endpoint.uri)); + }); + + it("throws for a URI that is not a URL", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); - const result = createJWKSClient(validJwksUri); + expect(() => createJWKSClient("not a url")).toThrow("Unable to create JWKS client"); + }); + + describe("when the keys are younger than 10 minutes", () => { + it("verifies without downloading them again", async () => { + await verify(); + at(9); + await verify(); + + expect(endpoint.requests).toBe(1); + }); + }); + + describe("when the keys are older than 10 minutes", () => { + it("verifies with them right away and re-downloads them in the background", async () => { + await verify(); + endpoint.state = "hanging"; + at(11); + + await expect(verify()).resolves.toMatchObject({ sub: "user-1" }); + await vi.waitFor(() => expect(endpoint.requests).toBe(2)); + }); + + it("replaces them once the background download completes", async () => { + await verify(); + await endpoint.addKey("key-2"); + endpoint.removeKey("key-1"); + at(11); - expect(createRemoteJWKSet).toHaveBeenCalledWith( - new URL(validJwksUri), - expect.objectContaining({ - cacheMaxAge: 600000, - }) - ); - expect(result).toBe(mockJWKSFunction); + await verify("key-1"); + // the old key is rejected once the new set is in use, with no download beyond the background one + await vi.waitFor(() => expect(verify("key-1")).rejects.toThrow()); + await expect(verify("key-2")).resolves.toMatchObject({ sub: "user-1" }); + expect(endpoint.requests).toBe(2); + }); + + it("keeps using them while the endpoint fails, retrying at most every 30 seconds", async () => { + await verify(); + endpoint.state = "down"; + + at(11); + await verify(); + await verify(); + at(59); + await verify(); + + // the first verification at 10 minutes, then the one at 59; not the second at 10 + await vi.waitFor(() => expect(endpoint.requests).toBe(3)); + }); }); - it("retries client creation after a failure", () => { - const mockJWKSFunction = vi.fn() as unknown as ReturnType; - vi.mocked(createRemoteJWKSet) - .mockImplementationOnce(() => { - throw new Error("Failed to create JWKS client"); - }) - .mockReturnValue(mockJWKSFunction); - - expect(() => createJWKSClient(retryJwksUri)).toThrow("Unable to create JWKS client"); - expect(console.error).toHaveBeenCalled(); - expect(createJWKSClient(retryJwksUri)).toBe(mockJWKSFunction); - expect(createRemoteJWKSet).toHaveBeenCalledTimes(2); + describe("when no download has succeeded for an hour", () => { + it("fails until the endpoint answers again", async () => { + await verify(); + endpoint.state = "down"; + at(61); + + await expect(verify()).rejects.toBeInstanceOf(JwksUnavailableError); + + endpoint.state = "up"; + await expect(verify()).resolves.toMatchObject({ sub: "user-1" }); + }); }); - it("reuses a client for repeated calls with the same URI", () => { - const mockJWKSFunction = vi.fn() as unknown as ReturnType; - vi.mocked(createRemoteJWKSet).mockReturnValue(mockJWKSFunction); + describe("when a token signed with a new key made it download the keys", () => { + it("counts their age from that download", async () => { + await verify(); + await endpoint.addKey("key-2"); + at(9); + await verify("key-2"); + endpoint.state = "down"; + at(61); + + await expect(verify("key-2")).resolves.toMatchObject({ sub: "user-1" }); + }); + }); - const firstClient = createJWKSClient(cachedJwksUri); - const secondClient = createJWKSClient(cachedJwksUri); + describe("when a token is signed with a key it has not seen", () => { + it("downloads the keys again before verifying", async () => { + await verify(); + await endpoint.addKey("key-2"); + // jose waits 30 seconds between downloads triggered by unknown keys + at(1); - expect(secondClient).toBe(firstClient); - expect(createRemoteJWKSet).toHaveBeenCalledTimes(1); + await expect(verify("key-2")).resolves.toMatchObject({ sub: "user-1" }); + expect(endpoint.requests).toBe(2); + }); }); }); diff --git a/packages/server/src/auth/utils/jwksClient.ts b/packages/server/src/auth/utils/jwksClient.ts index 1ab5c1b285..74b5e559a5 100644 --- a/packages/server/src/auth/utils/jwksClient.ts +++ b/packages/server/src/auth/utils/jwksClient.ts @@ -1,18 +1,22 @@ -import { createRemoteJWKSet } from "jose"; +import { createRemoteJWKSet, jwksCache, type JWKSCacheInput, type JWTVerifyGetKey } from "jose"; -const jwksClientsByUri = new Map>(); +/** Keys older than this are re-downloaded in the background; verification keeps using them meanwhile. */ +const REFRESH_AFTER_MS = 10 * 60 * 1000; +/** While the endpoint fails, keys stay in use up to this age; past it, verification waits for a download. */ +const MAX_STALE_MS = 60 * 60 * 1000; +/** Minimum gap between background refresh attempts, so a failing endpoint is not called on every request. */ +const REFRESH_RETRY_MS = 30 * 1000; -export function createJWKSClient(jwksUri: string) { +const jwksClientsByUri = new Map(); + +export function createJWKSClient(jwksUri: string): JWTVerifyGetKey { const cachedJWKS = jwksClientsByUri.get(jwksUri); if (cachedJWKS != null) { return cachedJWKS; } try { - const JWKS = createRemoteJWKSet(new URL(jwksUri), { - cacheMaxAge: 600000, // Cache for 10 minutes (similar to jwks-rsa default) - }); - + const JWKS = createRefreshingJWKS(new URL(jwksUri)); jwksClientsByUri.set(jwksUri, JWKS); return JWKS; } catch (error) { @@ -20,3 +24,27 @@ export function createJWKSClient(jwksUri: string) { throw new Error("Unable to create JWKS client"); } } + +/** + * A remote key set that never makes a verification wait for a routine refresh: only the first download, a + * token signed with a key it has not seen (rotation), or keys past MAX_STALE_MS block. + */ +function createRefreshingJWKS(url: URL): JWTVerifyGetKey { + // Age is tracked here, not by jose, which still re-downloads on its own for an unknown key id. jose stamps + // `uat` on this cache after every successful download, its own included, so the age covers both. + const cache: JWKSCacheInput = {}; + const remote = createRemoteJWKSet(url, { cacheMaxAge: Number.POSITIVE_INFINITY, [jwksCache]: cache }); + let refreshAttemptedAt = Number.NEGATIVE_INFINITY; + + return async (protectedHeader, token) => { + const age = "uat" in cache ? Date.now() - cache.uat : Number.POSITIVE_INFINITY; + if (age > MAX_STALE_MS) { + await remote.reload(); + } else if (age > REFRESH_AFTER_MS && Date.now() - refreshAttemptedAt > REFRESH_RETRY_MS) { + refreshAttemptedAt = Date.now(); + // A failed refresh keeps the current keys; the next verification after REFRESH_RETRY_MS retries. + remote.reload().catch(() => {}); + } + return remote(protectedHeader, token); + }; +} diff --git a/packages/server/src/auth/utils/jwt.ts b/packages/server/src/auth/utils/jwt.ts index 63051a1bbc..bb5f88e5f1 100644 --- a/packages/server/src/auth/utils/jwt.ts +++ b/packages/server/src/auth/utils/jwt.ts @@ -1,4 +1,4 @@ -import { decodeJwt, jwtVerify, errors, type createRemoteJWKSet, createLocalJWKSet, type JSONWebKeySet } from "jose"; +import { decodeJwt, jwtVerify, errors, createLocalJWKSet, type JSONWebKeySet, type JWTVerifyGetKey } from "jose"; import { CrossmintAuthenticationError } from "@crossmint/common-sdk-auth"; @@ -54,11 +54,7 @@ export function assertJwtAudience(jwt: string, expectedAudience: string): void { } } -async function verifyJWT( - signingKey: ReturnType | ReturnType, - token: string, - audience: string -) { +async function verifyJWT(signingKey: JWTVerifyGetKey, token: string, audience: string) { try { const { payload } = await jwtVerify(token, signingKey, { audience,