Repository navigation
feat(server-sdk): return the user from getSession on refresh, refresh the JWKS in the background #2290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
albertoelias-crossmint
merged 4 commits into
main
from
albertoe/server-auth-session-user
Nov 6, 2026
Merged
feat(server-sdk): return the user from getSession on refresh, refresh the JWKS in the background #2290
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
1a05b42
feat(server-sdk): return the user from getSession when it refreshes
AlbertoElias 7cba615
feat(server-sdk): refresh the JWKS in the background
AlbertoElias dda9661
Merge main into albertoe/server-auth-session-user
AlbertoElias 5ccec04
fix(server-sdk): count JWKS age from every download, jose's own included
AlbertoElias File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(() => {}); | ||
|
|
||
| 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)); | ||
|
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); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
|
albertoelias-crossmint marked this conversation as resolved.
Resolved
|
||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.