Skip to content

Commit 987066b

Browse files
authored
Merge pull request #36 from AgentWorkforce/sdk/timeout-fix-fetch
fix(sdk): add fetch timeouts to TokenVerifier JWKS and revocation requests
2 parents aa2576d + 37409e4 commit 987066b

2 files changed

Lines changed: 237 additions & 2 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { describe, it, beforeEach, afterEach, mock } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { TokenVerifier } from "../verify.js";
4+
import { RelayAuthError } from "../errors.js";
5+
6+
function base64UrlEncode(data: Uint8Array | string): string {
7+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
8+
const binary = String.fromCharCode(...bytes);
9+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
10+
}
11+
12+
function encodeJsonBase64Url(obj: Record<string, unknown>): string {
13+
return base64UrlEncode(JSON.stringify(obj));
14+
}
15+
16+
const validClaims = {
17+
sub: "identity-1",
18+
org: "org-1",
19+
wks: "wks-1",
20+
scopes: ["relay:agent:read"],
21+
sponsorId: "sponsor-1",
22+
sponsorChain: ["sponsor-1"],
23+
token_type: "access" as const,
24+
iss: "relayauth:test",
25+
aud: ["api.example.com"],
26+
exp: Math.floor(Date.now() / 1000) + 3600,
27+
iat: Math.floor(Date.now() / 1000) - 60,
28+
jti: "token-id-1",
29+
};
30+
31+
async function generateRS256KeyPair(): Promise<{
32+
privateKey: CryptoKey;
33+
publicKey: CryptoKey;
34+
jwk: JsonWebKey;
35+
}> {
36+
const keyPair = await crypto.subtle.generateKey(
37+
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
38+
true,
39+
["sign", "verify"],
40+
);
41+
const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
42+
return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, jwk };
43+
}
44+
45+
async function signToken(
46+
header: Record<string, unknown>,
47+
payload: Record<string, unknown>,
48+
privateKey: CryptoKey,
49+
): Promise<string> {
50+
const encodedHeader = encodeJsonBase64Url(header);
51+
const encodedPayload = encodeJsonBase64Url(payload);
52+
const signingInput = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`);
53+
const signature = await crypto.subtle.sign(
54+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
55+
privateKey,
56+
signingInput,
57+
);
58+
const encodedSignature = base64UrlEncode(new Uint8Array(signature));
59+
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
60+
}
61+
62+
function slowFetchHonoringSignal(delayMs: number, response: () => Response) {
63+
return mock.fn((_input: RequestInfo | URL, init?: RequestInit) => {
64+
return new Promise<Response>((resolve, reject) => {
65+
const signal = init?.signal;
66+
const timer = setTimeout(() => resolve(response()), delayMs);
67+
if (signal) {
68+
const onAbort = () => {
69+
clearTimeout(timer);
70+
const reason =
71+
(signal as AbortSignal & { reason?: unknown }).reason ??
72+
new DOMException("The operation was aborted.", "AbortError");
73+
reject(reason);
74+
};
75+
if (signal.aborted) {
76+
onAbort();
77+
} else {
78+
signal.addEventListener("abort", onAbort, { once: true });
79+
}
80+
}
81+
});
82+
});
83+
}
84+
85+
describe("TokenVerifier — fetch timeouts", () => {
86+
let originalFetch: typeof globalThis.fetch;
87+
88+
beforeEach(() => {
89+
originalFetch = globalThis.fetch;
90+
});
91+
92+
afterEach(() => {
93+
globalThis.fetch = originalFetch;
94+
});
95+
96+
it("aborts JWKS fetch after jwksTimeoutMs and throws within the window", async () => {
97+
const { privateKey } = await generateRS256KeyPair();
98+
const token = await signToken(
99+
{ alg: "RS256", typ: "JWT", kid: "k1" },
100+
validClaims,
101+
privateKey,
102+
);
103+
104+
// Slow fetch that would never resolve in test time if the abort signal is missing.
105+
globalThis.fetch = slowFetchHonoringSignal(60_000, () => new Response("never", { status: 200 }));
106+
107+
const timeoutMs = 100;
108+
const verifier = new TokenVerifier({
109+
jwksUrl: "https://auth.test/.well-known/jwks.json",
110+
jwksTimeoutMs: timeoutMs,
111+
});
112+
113+
const start = Date.now();
114+
await assert.rejects(
115+
async () => { await verifier.verify(token); },
116+
(err) => {
117+
assert.ok(err instanceof RelayAuthError, "expected RelayAuthError");
118+
assert.match((err as Error).message, /Failed to fetch JWKS/);
119+
return true;
120+
},
121+
);
122+
const elapsed = Date.now() - start;
123+
// Should fire well before the 60s slow-fetch — give generous slack for CI jitter
124+
// but still prove we did not hang on the upstream.
125+
assert.ok(elapsed < 2000, `expected timeout to fire quickly, took ${elapsed}ms`);
126+
assert.ok(elapsed >= timeoutMs - 20, `expected to wait at least ~${timeoutMs}ms, took ${elapsed}ms`);
127+
});
128+
129+
it("falls back to default JWKS timeout when jwksTimeoutMs is not provided", async () => {
130+
const { privateKey } = await generateRS256KeyPair();
131+
const token = await signToken(
132+
{ alg: "RS256", typ: "JWT", kid: "k1" },
133+
validClaims,
134+
privateKey,
135+
);
136+
137+
let observedSignal: AbortSignal | undefined;
138+
globalThis.fetch = mock.fn((_input: RequestInfo | URL, init?: RequestInit) => {
139+
observedSignal = init?.signal ?? undefined;
140+
return Promise.resolve(new Response(JSON.stringify({ keys: [] }), { status: 200 }));
141+
});
142+
143+
const verifier = new TokenVerifier({
144+
jwksUrl: "https://auth.test/.well-known/jwks.json",
145+
});
146+
147+
await assert.rejects(async () => { await verifier.verify(token); });
148+
assert.ok(observedSignal, "expected fetch to be called with an AbortSignal");
149+
});
150+
151+
it("aborts revocation fetch after revocationTimeoutMs", async () => {
152+
const { privateKey, jwk } = await generateRS256KeyPair();
153+
const kid = "rev-key-1";
154+
const jwkWithKid = { ...jwk, kid, use: "sig", alg: "RS256" };
155+
156+
const token = await signToken(
157+
{ alg: "RS256", typ: "JWT", kid },
158+
validClaims,
159+
privateKey,
160+
);
161+
162+
globalThis.fetch = mock.fn((input: RequestInfo | URL, init?: RequestInit) => {
163+
const url = typeof input === "string" ? input : input.toString();
164+
if (url.includes("jwks")) {
165+
return Promise.resolve(new Response(JSON.stringify({ keys: [jwkWithKid] }), { status: 200 }));
166+
}
167+
// Revocation: a slow response (60s) that holds a refed timer open. The
168+
// verifier's AbortSignal.timeout should fire well before, aborting the
169+
// request. The refed timer guarantees the event loop stays alive long
170+
// enough for the abort to land (AbortSignal.timeout uses an unref'd
171+
// timer, so without this the test process can exit early).
172+
return new Promise<Response>((resolve, reject) => {
173+
const signal = init?.signal;
174+
const timer = setTimeout(() => resolve(new Response("never", { status: 200 })), 60_000);
175+
if (signal) {
176+
const onAbort = () => {
177+
clearTimeout(timer);
178+
const reason =
179+
(signal as AbortSignal & { reason?: unknown }).reason ??
180+
new DOMException("The operation was aborted.", "AbortError");
181+
reject(reason);
182+
};
183+
if (signal.aborted) {
184+
onAbort();
185+
} else {
186+
signal.addEventListener("abort", onAbort, { once: true });
187+
}
188+
}
189+
});
190+
});
191+
192+
const timeoutMs = 100;
193+
const verifier = new TokenVerifier({
194+
jwksUrl: "https://auth.test/.well-known/jwks.json",
195+
issuer: "relayauth:test",
196+
audience: ["api.example.com"],
197+
checkRevocation: true,
198+
revocationUrl: "https://auth.test/revocation",
199+
revocationTimeoutMs: timeoutMs,
200+
});
201+
202+
const start = Date.now();
203+
await assert.rejects(
204+
async () => { await verifier.verify(token); },
205+
(err) => {
206+
assert.ok(err instanceof RelayAuthError);
207+
assert.match((err as Error).message, /Failed to check token revocation/);
208+
return true;
209+
},
210+
);
211+
const elapsed = Date.now() - start;
212+
assert.ok(elapsed < 2000, `expected revocation timeout to fire quickly, took ${elapsed}ms`);
213+
assert.ok(elapsed >= timeoutMs - 20, `expected to wait at least ~${timeoutMs}ms, took ${elapsed}ms`);
214+
});
215+
});

packages/sdk/typescript/src/verify.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { RelayAuthError, TokenExpiredError, TokenRevokedError } from "./errors.j
44
import { ScopeChecker } from "./scopes.js";
55

66
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
7+
const DEFAULT_JWKS_TIMEOUT_MS = 5000;
8+
const DEFAULT_REVOCATION_TIMEOUT_MS = 5000;
79

810
export interface VerifyOptions {
911
jwksUrl?: string;
@@ -13,6 +15,8 @@ export interface VerifyOptions {
1315
cacheTtlMs?: number;
1416
checkRevocation?: boolean;
1517
revocationUrl?: string;
18+
jwksTimeoutMs?: number;
19+
revocationTimeoutMs?: number;
1620
}
1721

1822
type JwtHeader = {
@@ -110,7 +114,11 @@ export class TokenVerifier {
110114

111115
let response: Response;
112116
try {
113-
response = await fetch(jwksUrl);
117+
response = await fetch(jwksUrl, {
118+
signal: AbortSignal.timeout(
119+
normalizeTimeoutMs(this.options?.jwksTimeoutMs, DEFAULT_JWKS_TIMEOUT_MS),
120+
),
121+
});
114122
} catch {
115123
throw new RelayAuthError("Failed to fetch JWKS", "jwks_fetch_failed", 502);
116124
}
@@ -256,7 +264,11 @@ export class TokenVerifier {
256264

257265
let response: Response;
258266
try {
259-
response = await fetch(url);
267+
response = await fetch(url, {
268+
signal: AbortSignal.timeout(
269+
normalizeTimeoutMs(this.options?.revocationTimeoutMs, DEFAULT_REVOCATION_TIMEOUT_MS),
270+
),
271+
});
260272
} catch {
261273
throw new RelayAuthError("Failed to check token revocation", "revocation_check_failed", 502);
262274
}
@@ -483,6 +495,14 @@ function normalizeCacheTtlMs(cacheTtlMs: number | undefined): number {
483495
return Math.max(0, cacheTtlMs);
484496
}
485497

498+
function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number): number {
499+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
500+
return fallback;
501+
}
502+
503+
return timeoutMs;
504+
}
505+
486506
function decodeBase64UrlJson<T>(value: string): T | null {
487507
try {
488508
return JSON.parse(decodeBase64Url(value)) as T;

0 commit comments

Comments
 (0)