Skip to content

Commit fb227e9

Browse files
committed
Test hosted token limits import bounds and diagnostic redaction
1 parent f4ee00d commit fb227e9

10 files changed

Lines changed: 277 additions & 10 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { SignJWT, jwtVerify } from "jose";
3+
import { workosAccessTokenOptions } from "./access-token-options";
4+
5+
describe("WorkOS token age boundary", () => {
6+
it("accepts a fresh token, rejects it after 24 hours, and rejects future issuance", async () => {
7+
const key = new TextEncoder().encode("synthetic-signing-key-for-unit-test-only");
8+
const issuedAt = 1_700_000_000;
9+
const token = await new SignJWT({})
10+
.setProtectedHeader({ alg: "HS256" })
11+
.setIssuedAt(issuedAt)
12+
.setExpirationTime(issuedAt + 7 * 86400)
13+
.sign(key);
14+
await expect(
15+
jwtVerify(token, key, {
16+
...workosAccessTokenOptions,
17+
currentDate: new Date((issuedAt + 86399) * 1000),
18+
}),
19+
).resolves.toHaveProperty("payload.iat", issuedAt);
20+
await expect(
21+
jwtVerify(token, key, {
22+
...workosAccessTokenOptions,
23+
currentDate: new Date((issuedAt + 86401) * 1000),
24+
}),
25+
).rejects.toHaveProperty("code", "ERR_JWT_EXPIRED");
26+
await expect(
27+
jwtVerify(token, key, {
28+
...workosAccessTokenOptions,
29+
currentDate: new Date((issuedAt - 1) * 1000),
30+
}),
31+
).rejects.toHaveProperty("code", "ERR_JWT_CLAIM_VALIDATION_FAILED");
32+
});
33+
});

‎apps/cloud/src/auth/workos.node.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const signAccessToken = (
5959
readonly organizationId?: string;
6060
readonly sessionId?: string;
6161
readonly expiresIn?: string | number;
62+
readonly issuedAt?: number;
6263
} = {},
6364
) => {
6465
const jwt = new SignJWT({
@@ -67,7 +68,7 @@ const signAccessToken = (
6768
})
6869
.setProtectedHeader({ alg: "RS256", kid: keypair.kid })
6970
.setSubject(claims.subject ?? USER.id)
70-
.setIssuedAt();
71+
.setIssuedAt(claims.issuedAt);
7172

7273
return (
7374
typeof claims.expiresIn === "number"
@@ -317,6 +318,21 @@ describe("authenticateSealedSession", () => {
317318
});
318319
});
319320

321+
it("refreshes a token beyond the local age limit even when WorkOS exp is later", async () => {
322+
const keypair = await generateKeypair("k_old");
323+
await withWorkOSStub(keypair, async (stub) => {
324+
const now = Math.floor(Date.now() / 1000);
325+
const token = await signAccessToken(keypair, {
326+
issuedAt: now - 86401,
327+
expiresIn: now + 86400,
328+
});
329+
const result = await runAuthenticate(await sealSession(token), stub.baseUrl);
330+
expect(result?.sessionId).toBe("session_refreshed");
331+
expect(result?.refreshedSession).toEqual(expect.any(String));
332+
expect(stub.requests()[1]?.body).toMatchObject({ grant_type: "refresh_token" });
333+
});
334+
});
335+
320336
it("returns null for garbage session data", async () => {
321337
const keypair = await generateKeypair("k_garbage");
322338
await withWorkOSStub(keypair, async (stub) => {

‎apps/cloud/src/mcp/mcp-auth.node.test.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ describe("access token expiry and identity boundaries", () => {
151151
}),
152152
);
153153
}
154-
it.effect(`${kind} accepts a token issued more than a day ago that has not expired`, () =>
154+
it.effect(`${kind} rejects a token issued more than a day ago even when exp is later`, () =>
155155
Effect.gen(function* () {
156156
const { publicKey, privateKey } = yield* Effect.promise(() => generateKeyPair("RS256"));
157157
const jwk = yield* Effect.promise(() => exportJWK(publicKey));
@@ -169,10 +169,13 @@ describe("access token expiry and identity boundaries", () => {
169169
.setProtectedHeader({ alg: "RS256", kid: "expiry-key" })
170170
.sign(privateKey),
171171
);
172-
const verified = yield* kind === "mcp"
173-
? verifyMcpAccessToken(token, jwks, { issuer, audience: resource })
174-
: verifyWorkosUserManagementToken(token, jwks);
175-
expect(verified).toEqual({ accountId: "user_test", organizationId: "org_test" });
172+
const error = yield* Effect.flip(
173+
kind === "mcp"
174+
? verifyMcpAccessToken(token, jwks, { issuer, audience: resource })
175+
: verifyWorkosUserManagementToken(token, jwks),
176+
);
177+
expect(error).toBeInstanceOf(McpJwtVerificationError);
178+
expect(error.reason).toBe("expired");
176179
}),
177180
);
178181
it.effect(`${kind} rejects a non-string organization claim`, () =>

‎apps/cloud/src/observability/observability.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,51 @@ describe("Durable Object platform reset noise", () => {
409409
expect(options.beforeSend(event)).toBeNull();
410410
});
411411
});
412+
413+
describe("Sentry privacy boundary", () => {
414+
it("strips secrets from auto-captured errors while retaining diagnostic locations", () => {
415+
const secret = "SYNTHETIC_PRIVATE_MARKER";
416+
const sent = cloudSentryOptions({
417+
SENTRY_DSN: "https://public@example.invalid/1",
418+
} as Env).beforeSend({
419+
type: undefined,
420+
event_id: "safe-event-id",
421+
message: secret,
422+
user: { email: secret },
423+
request: {
424+
url: `https://example.test/?token=${secret}`,
425+
headers: { authorization: secret },
426+
data: secret,
427+
},
428+
extra: { cause: secret },
429+
breadcrumbs: [{ message: secret }],
430+
tags: { token: secret, otel_trace_id: traceId },
431+
exception: {
432+
values: [
433+
{
434+
type: "TypeError",
435+
value: secret,
436+
stacktrace: {
437+
frames: [
438+
{
439+
filename: `/assets/example.js?token=${secret}`,
440+
function: "handleRequest",
441+
lineno: 42,
442+
vars: { secret },
443+
},
444+
],
445+
},
446+
},
447+
],
448+
},
449+
});
450+
expect(JSON.stringify(sent)).not.toContain(secret);
451+
expect(sent?.event_id).toBe("safe-event-id");
452+
expect(sent?.tags?.otel_trace_id).toBe(traceId);
453+
expect(sent?.exception?.values?.[0]?.stacktrace?.frames?.[0]).toMatchObject({
454+
filename: "/assets/example.js",
455+
function: "handleRequest",
456+
lineno: 42,
457+
});
458+
});
459+
});

‎apps/cloud/src/observability/redact-span-urls.test.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,13 @@ describe("UrlRedactingSpanProcessor", () => {
216216
span.setStatus({ code: SpanStatusCode.ERROR, message });
217217
});
218218

219-
// Non-vacuous: the exception event exists and kept its scrubbed URL.
219+
// The exception event and classification survive without raw error text.
220220
const events = JSON.stringify(exported?.events);
221221
expect(events).toContain("exception");
222-
expect(events).toContain("https://api.test/graphql");
222+
expect(events).toContain("[REDACTED]");
223223
expect(events).not.toContain("synthetic-userinfo-secret");
224224
expect(events).not.toContain("synthetic-key-secret");
225-
expect(exported?.status.message).toBe("Transport: fetch failed (GET https://api.test/graphql)");
225+
expect(exported?.status.message).toBe("[REDACTED]");
226226
});
227227
});
228228

@@ -284,3 +284,22 @@ describe("credential canary — no export channel carries the secret", () => {
284284
},
285285
);
286286
});
287+
288+
describe("non-URL secrets in exceptions", () => {
289+
it("does not export a provider response or SQL values as error text", () => {
290+
const secret = "synthetic-plain-secret";
291+
const exported = exportSpanWith({ "http.response.status_code": "500" }, (span) => {
292+
span.recordException({
293+
name: "ProviderError",
294+
message: `Failed query values: ${secret}`,
295+
stack: `at provider: ${secret}`,
296+
});
297+
span.setStatus({ code: SpanStatusCode.ERROR, message: secret });
298+
});
299+
expect(JSON.stringify({ events: exported?.events, status: exported?.status })).not.toContain(
300+
secret,
301+
);
302+
expect(exported?.status.code).toBe(SpanStatusCode.ERROR);
303+
expect(exported?.events[0]?.attributes?.["exception.type"]).toBe("ProviderError");
304+
});
305+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { limitRequestBody } from "./request-limits";
3+
4+
const streamedRequest = (chunks: readonly string[], headers?: HeadersInit) => {
5+
let index = 0;
6+
let cancelled = false;
7+
const stream = new ReadableStream({
8+
pull(controller) {
9+
const value = chunks[index++];
10+
if (value === undefined) controller.close();
11+
else controller.enqueue(new TextEncoder().encode(value));
12+
},
13+
cancel() {
14+
cancelled = true;
15+
},
16+
});
17+
return {
18+
request: new Request("https://example.test/api/import?format=json", {
19+
method: "POST",
20+
headers,
21+
body: stream,
22+
duplex: "half",
23+
} as RequestInit),
24+
cancelled: () => cancelled,
25+
};
26+
};
27+
28+
describe("request body boundary", () => {
29+
it("preserves body, URL, method and headers at the exact byte limit", async () => {
30+
const { request } = streamedRequest(['{"a":', '"é"}'], {
31+
"content-type": "application/json",
32+
authorization: "Bearer fixture",
33+
});
34+
const limited = await limitRequestBody(request, 10);
35+
expect(limited).toBeInstanceOf(Request);
36+
if (!(limited instanceof Request)) return;
37+
expect(limited.url).toBe(request.url);
38+
expect(limited.method).toBe("POST");
39+
expect(limited.headers.get("authorization")).toBe("Bearer fixture");
40+
expect(await limited.json()).toEqual({ a: "é" });
41+
});
42+
for (const headers of [undefined, { "content-length": "1" }]) {
43+
it(`rejects oversized streamed bodies with ${headers ? "understated" : "absent"} length`, async () => {
44+
const source = streamedRequest(["123", "456", "789", "more"], headers);
45+
const response = await limitRequestBody(source.request, 5);
46+
expect(response).toBeInstanceOf(Response);
47+
if (!(response instanceof Response)) return;
48+
expect(response.status).toBe(413);
49+
expect(await response.json()).toEqual({ error: "Request body too large" });
50+
expect(source.cancelled()).toBe(true);
51+
});
52+
}
53+
it("rejects an oversized declared length before reading", async () => {
54+
const source = streamedRequest(["body"], { "content-length": "100" });
55+
const response = await limitRequestBody(source.request, 5);
56+
expect(response instanceof Response && response.status).toBe(413);
57+
expect(source.cancelled()).toBe(true);
58+
});
59+
it("passes bodyless requests through", async () => {
60+
const request = new Request("https://example.test/mcp");
61+
expect(await limitRequestBody(request, 5)).toBe(request);
62+
});
63+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
import { scenario } from "../src/scenario";
4+
import { Target } from "../src/services";
5+
6+
scenario(
7+
"Request limits · API and MCP reject oversized bodies before parsing",
8+
{ timeout: 120_000 },
9+
Effect.gen(function* () {
10+
const target = yield* Target;
11+
for (const path of ["/api/integrations", "/mcp"]) {
12+
const response = yield* Effect.promise(() =>
13+
fetch(new URL(path, target.baseUrl), {
14+
method: "POST",
15+
headers: { "content-type": "application/json" },
16+
body: "x".repeat(32 * 1024 * 1024 + 1),
17+
}),
18+
);
19+
expect(response.status).toBe(413);
20+
expect(yield* Effect.promise(() => response.json())).toEqual({
21+
error: "Request body too large",
22+
});
23+
}
24+
const health = yield* Effect.promise(() => fetch(new URL("/api/account/me", target.baseUrl)));
25+
expect(health.status).toBe(401);
26+
}),
27+
);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Layer } from "effect";
3+
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
4+
import { introspect } from "./introspect";
5+
6+
describe("GraphQL introspection body limit", () => {
7+
for (const status of [200, 500]) {
8+
it.effect(`cancels an oversized streamed ${status} response before parsing`, () =>
9+
Effect.gen(function* () {
10+
let cancelled = false;
11+
const response = new Response(
12+
new ReadableStream({
13+
pull(controller) {
14+
controller.enqueue(new Uint8Array(1024 * 1024));
15+
},
16+
cancel() {
17+
cancelled = true;
18+
},
19+
}),
20+
{ status, headers: { "content-length": "1" } },
21+
);
22+
const client = HttpClient.make((request) =>
23+
Effect.succeed(HttpClientResponse.fromWeb(request, response)),
24+
);
25+
const error = yield* introspect("https://example.test/graphql").pipe(
26+
Effect.provide(Layer.succeed(HttpClient.HttpClient)(client)),
27+
Effect.flip,
28+
);
29+
expect(error).toHaveProperty("reason", "response-too-large");
30+
expect(cancelled).toBe(true);
31+
}),
32+
);
33+
}
34+
});

‎packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ const ENDPOINT = "https://graph.example.test/graphql";
3131
* live `message` getter, which is the exact path the leak took. */
3232
const capturingLogger = (sink: Array<string>) =>
3333
Logger.make<unknown, void>((options) => {
34-
sink.push(String(options.message));
34+
// Preserve structured log fields so the secret check covers them too.
35+
sink.push(JSON.stringify(options.message));
3536
sink.push(Cause.pretty(options.cause));
3637
});
3738

‎packages/plugins/openapi/src/sdk/parse.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,29 @@ describe("OpenAPI fetchSpecText", () => {
155155
}),
156156
);
157157

158+
it.effect("bounds a streamed response even when Content-Length understates it", () =>
159+
Effect.gen(function* () {
160+
let cancelled = false;
161+
const response = new Response(
162+
new ReadableStream({
163+
pull(controller) {
164+
controller.enqueue(new Uint8Array(1024 * 1024));
165+
},
166+
cancel() {
167+
cancelled = true;
168+
},
169+
}),
170+
{ headers: { "content-length": "1" } },
171+
);
172+
const error = yield* fetchSpecText(specUrl).pipe(
173+
Effect.provide(layerWithResponse(response)),
174+
Effect.flip,
175+
);
176+
expect(error).toHaveProperty("message", expect.stringMatching(/too large to parse/));
177+
expect(cancelled).toBe(true);
178+
}),
179+
);
180+
158181
it.effect("fetches a document with an in-range declared length", () =>
159182
Effect.gen(function* () {
160183
const specText = yield* fetchSpecText(specUrl).pipe(

0 commit comments

Comments
 (0)