History

Validate cloud JWT boundaries with schema

Rhys Sullivan committed 6 months ago

Commit 139dd5c1 parent b039b6e2 files · +61 −32Browse files

Unified · Split

2 files
  1. apps/cloud/src/jwks-cache.ts +39 −22
  2. apps/cloud/src/mcp-auth.ts +22 −10
apps/cloud/src/jwks-cache.ts+39−22
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
2929 type JWTVerifyGetKey,
3030 type KeyLike,
3131 } from "jose";
32+import { Schema } from "effect";
3233 import { JWKSNoMatchingKey } from "jose/errors";
3334
3435 export interface CachedRemoteJWKSetOptions {
@@ -54,13 +55,19 @@ export interface CachedRemoteJWKSet extends JWTVerifyGetKey { · 19 unmodified lines
5455 const DEFAULT_TTL_MS = 60 * 60 * 1000;
5556 const DEFAULT_TIMEOUT_MS = 5000;
5657
58+const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown);
59+const JsonWebKeySetPayload = Schema.Struct({
60+ keys: Schema.Array(JsonWebKey),
61+});
62+const decodeJsonWebKeySetPayload = Schema.decodeUnknownPromise(JsonWebKeySetPayload);
63+
64+const isJwksNoMatchingKey = (cause: unknown): boolean =>
65+ Schema.is(Schema.Struct({ code: Schema.String }))(cause) && cause.code === JWKSNoMatchingKey.code;
66+
5767 interface CacheEntry {
5868 jwks: JSONWebKeySet;
5969 fetchedAt: number;
60− resolver: (
61− protectedHeader: JWTHeaderParameters,
62− token?: FlattenedJWSInput,
63− ) => Promise<KeyLike>;
70+ resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>;
6471 }
6572
6673 const fetchJwksOnce = async (
@@ -70,20 +77,28 @@ const fetchJwksOnce = async ( · 3 unmodified lines
7077 ): Promise<JSONWebKeySet> => {
7178 const controller = new AbortController();
7279 const timer = setTimeout(() => controller.abort(), timeoutMs);
80+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch adapter must clear abort timer while preserving promise rejection behavior
7381 try {
7482 const response = await fetchImpl(url.toString(), {
7583 method: "GET",
7684 headers: { accept: "application/json" },
7785 signal: controller.signal,
7886 });
87+
7988 if (!response.ok) {
89+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: fetch-backed JWT key resolver must reject with the existing Error cause shape
8090 throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`);
8191 }
82− const body = (await response.json()) as JSONWebKeySet;
83− if (!body || !Array.isArray((body as JSONWebKeySet).keys)) {
92+
93+ const body = await response.json();
94+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch JSON validation maps Schema failures to the existing malformed JWKS rejection
95+ try {
96+ await decodeJsonWebKeySetPayload(body);
97+ return body as JSONWebKeySet;
98+ } catch {
99+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: fetch JSON validation preserves the existing malformed JWKS rejection
84100 throw new Error("JWKS fetch returned malformed payload");
85101 }
86− return body;
87102 } finally {
88103 clearTimeout(timer);
89104 }
@@ -111,19 +126,17 @@ export const createCachedRemoteJWKSet = ( · 21 unmodified lines
111126 const refresh = (): Promise<CacheEntry> => {
112127 if (inflight) return inflight;
113128 inflight = (async () => {
114− try {
115− const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);
116− const next: CacheEntry = {
117− jwks,
118− fetchedAt: Date.now(),
119− resolver: createLocalJWKSet(jwks),
120− };
121− entry = next;
122− return next;
123− } finally {
124− inflight = null;
125− }
126− })();
129+ const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);
130+ const next: CacheEntry = {
131+ jwks,
132+ fetchedAt: Date.now(),
133+ resolver: createLocalJWKSet(jwks),
134+ };
135+ entry = next;
136+ return next;
137+ })().finally(() => {
138+ inflight = null;
139+ });
127140 return inflight;
128141 };
129142
@@ -135,15 +148,19 @@ export const createCachedRemoteJWKSet = ( · 5 unmodified lines
135148
136149 const get: JWTVerifyGetKey = async (protectedHeader, token) => {
137150 const current = await ensureFresh(false);
151+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures
138152 try {
139153 return await current.resolver(protectedHeader, token);
140154 } catch (error) {
141155 // Likely cause: keys rotated upstream after our TTL window started.
142156 // Refetch once and try again. Anything still failing bubbles up so
143157 // jose can classify it (we do not silently swallow real failures).
144− if (!(error instanceof JWKSNoMatchingKey)) throw error;
158+ if (!isJwksNoMatchingKey(error)) {
159+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection
160+ throw error;
161+ }
145162 const refreshed = await ensureFresh(true);
146− return await refreshed.resolver(protectedHeader, token);
163+ return refreshed.resolver(protectedHeader, token);
147164 }
148165 };
149166
apps/cloud/src/mcp-auth.ts+22−10
@@ -1,6 +1,6 @@
1−import { Data, Effect, Result } from "effect";
1+import { Data, Effect, Result, Schema } from "effect";
22 import { jwtVerify, type JWTVerifyGetKey } from "jose";
3−import { JOSEError, JWKSInvalid, JWKSTimeout, JWTExpired } from "jose/errors";
3+import { JWKSInvalid, JWKSTimeout, JWTExpired } from "jose/errors";
44
55 export type VerifiedToken = {
66 /** The WorkOS account ID (user ID). */
@@ -14,17 +14,29 @@ export class McpJwtVerificationError extends Data.TaggedError("McpJwtVerificatio · 7 unmodified lines
1414 readonly reason: "expired" | "invalid" | "system";
1515 }> {}
1616
17+const JoseErrorCode = Schema.Struct({ code: Schema.String });
18+
19+const getJoseErrorCode = (cause: unknown): string | null =>
20+ Schema.is(JoseErrorCode)(cause) ? cause.code : null;
21+
22+const isJoseErrorCode = (code: string): boolean => code.startsWith("ERR_J");
23+
1724 const classifyJwtVerificationError = (cause: unknown): McpJwtVerificationError =>
1825 new McpJwtVerificationError({
1926 cause,
20− reason:
21− cause instanceof JWTExpired
22− ? "expired"
23− : cause instanceof JWKSTimeout ||
24− cause instanceof JWKSInvalid ||
25− !(cause instanceof JOSEError)
26− ? "system"
27− : "invalid",
27+ reason: (() => {
28+ const code = getJoseErrorCode(cause);
29+ if (code === JWTExpired.code) return "expired";
30+ if (
31+ code === JWKSTimeout.code ||
32+ code === JWKSInvalid.code ||
33+ code === null ||
34+ !isJoseErrorCode(code)
35+ ) {
36+ return "system";
37+ }
38+ return "invalid";
39+ })(),
2840 });
2941
3042 const isExpectedJwtVerificationError = (error: McpJwtVerificationError): boolean =>