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
5 changes: 5 additions & 0 deletions .changeset/server-jwks-background-refresh.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/server-session-user.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/common/auth/src/types/authmaterial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion packages/server/src/auth/CrossmintAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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,
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/auth/crossmint-auth-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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,
Expand Down
226 changes: 182 additions & 44 deletions packages/server/src/auth/utils/jwks-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
removeKey(kid: string): void;
sign(kid: string): Promise<string>;
close(): Promise<void>;
};

async function startJwksEndpoint(): Promise<JwksEndpoint> {
const privateKeys = new Map<string, KeyLike>();
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<void>((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<void>((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<typeof createRemoteJWKSet>;
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(() => {});
Comment thread
albertoelias-crossmint marked this conversation as resolved.
Resolved

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));
Comment thread
albertoelias-crossmint marked this conversation as resolved.
Resolved
});

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<typeof createRemoteJWKSet>;
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<typeof createRemoteJWKSet>;
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);
});
});
});
42 changes: 35 additions & 7 deletions packages/server/src/auth/utils/jwksClient.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,50 @@
import { createRemoteJWKSet } from "jose";
import { createRemoteJWKSet, jwksCache, type JWKSCacheInput, type JWTVerifyGetKey } from "jose";

const jwksClientsByUri = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
/** 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<string, JWTVerifyGetKey>();

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) {
console.error("Error creating JWKS client", error);
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);
Comment thread
albertoelias-crossmint marked this conversation as resolved.
Resolved
};
}
8 changes: 2 additions & 6 deletions packages/server/src/auth/utils/jwt.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -54,11 +54,7 @@ export function assertJwtAudience(jwt: string, expectedAudience: string): void {
}
}

async function verifyJWT(
signingKey: ReturnType<typeof createRemoteJWKSet> | ReturnType<typeof createLocalJWKSet>,
token: string,
audience: string
) {
async function verifyJWT(signingKey: JWTVerifyGetKey, token: string, audience: string) {
try {
const { payload } = await jwtVerify(token, signingKey, {
audience,
Expand Down
Loading