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
29 type JWTVerifyGetKey,29 type JWTVerifyGetKey,
30 type KeyLike,30 type KeyLike,
31} from "jose";31} from "jose";
32import { Schema } from "effect";
32import { JWKSNoMatchingKey } from "jose/errors";33import { JWKSNoMatchingKey } from "jose/errors";
33 34
34export interface CachedRemoteJWKSetOptions {35export interface CachedRemoteJWKSetOptions {
@@ -54,13 +55,19 @@ export interface CachedRemoteJWKSet extends JWTVerifyGetKey { · 19 unmodified lines
54const DEFAULT_TTL_MS = 60 * 60 * 1000;55const DEFAULT_TTL_MS = 60 * 60 * 1000;
55const DEFAULT_TIMEOUT_MS = 5000;56const DEFAULT_TIMEOUT_MS = 5000;
56 57
58const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown);
59const JsonWebKeySetPayload = Schema.Struct({
60 keys: Schema.Array(JsonWebKey),
61});
62const decodeJsonWebKeySetPayload = Schema.decodeUnknownPromise(JsonWebKeySetPayload);
63
64const isJwksNoMatchingKey = (cause: unknown): boolean =>
65 Schema.is(Schema.Struct({ code: Schema.String }))(cause) && cause.code === JWKSNoMatchingKey.code;
66
57interface CacheEntry {67interface CacheEntry {
58 jwks: JSONWebKeySet;68 jwks: JSONWebKeySet;
59 fetchedAt: number;69 fetchedAt: number;
60 resolver: (70 resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>;
61 protectedHeader: JWTHeaderParameters,
62 token?: FlattenedJWSInput,
63 ) => Promise<KeyLike>;
64}71}
65 72
66const fetchJwksOnce = async (73const fetchJwksOnce = async (
@@ -70,20 +77,28 @@ const fetchJwksOnce = async ( · 3 unmodified lines
70): Promise<JSONWebKeySet> => {77): Promise<JSONWebKeySet> => {
71 const controller = new AbortController();78 const controller = new AbortController();
72 const timer = setTimeout(() => controller.abort(), timeoutMs);79 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
73 try {81 try {
74 const response = await fetchImpl(url.toString(), {82 const response = await fetchImpl(url.toString(), {
75 method: "GET",83 method: "GET",
76 headers: { accept: "application/json" },84 headers: { accept: "application/json" },
77 signal: controller.signal,85 signal: controller.signal,
78 });86 });
87
79 if (!response.ok) {88 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
80 throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`);90 throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`);
81 }91 }
82 const body = (await response.json()) as JSONWebKeySet;92
83 if (!body || !Array.isArray((body as JSONWebKeySet).keys)) {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
84 throw new Error("JWKS fetch returned malformed payload");100 throw new Error("JWKS fetch returned malformed payload");
85 }101 }
86 return body;
87 } finally {102 } finally {
88 clearTimeout(timer);103 clearTimeout(timer);
89 }104 }
@@ -111,19 +126,17 @@ export const createCachedRemoteJWKSet = ( · 21 unmodified lines
111 const refresh = (): Promise<CacheEntry> => {126 const refresh = (): Promise<CacheEntry> => {
112 if (inflight) return inflight;127 if (inflight) return inflight;
113 inflight = (async () => {128 inflight = (async () => {
114 try {129 const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);
115 const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs);130 const next: CacheEntry = {
116 const next: CacheEntry = {131 jwks,
117 jwks,132 fetchedAt: Date.now(),
118 fetchedAt: Date.now(),133 resolver: createLocalJWKSet(jwks),
119 resolver: createLocalJWKSet(jwks),134 };
120 };135 entry = next;
121 entry = next;136 return next;
122 return next;137 })().finally(() => {
123 } finally {138 inflight = null;
124 inflight = null;139 });
125 }
126 })();
127 return inflight;140 return inflight;
128 };141 };
129 142
@@ -135,15 +148,19 @@ export const createCachedRemoteJWKSet = ( · 5 unmodified lines
135 148
136 const get: JWTVerifyGetKey = async (protectedHeader, token) => {149 const get: JWTVerifyGetKey = async (protectedHeader, token) => {
137 const current = await ensureFresh(false);150 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
138 try {152 try {
139 return await current.resolver(protectedHeader, token);153 return await current.resolver(protectedHeader, token);
140 } catch (error) {154 } catch (error) {
141 // Likely cause: keys rotated upstream after our TTL window started.155 // Likely cause: keys rotated upstream after our TTL window started.
142 // Refetch once and try again. Anything still failing bubbles up so156 // Refetch once and try again. Anything still failing bubbles up so
143 // jose can classify it (we do not silently swallow real failures).157 // 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 }
145 const refreshed = await ensureFresh(true);162 const refreshed = await ensureFresh(true);
146 return await refreshed.resolver(protectedHeader, token);163 return refreshed.resolver(protectedHeader, token);
147 }164 }
148 };165 };
149 166
apps/cloud/src/mcp-auth.ts+22−10
@@ -1,6 +1,6 @@
1import { Data, Effect, Result } from "effect";1import { Data, Effect, Result, Schema } from "effect";
2import { jwtVerify, type JWTVerifyGetKey } from "jose";2import { jwtVerify, type JWTVerifyGetKey } from "jose";
3import { JOSEError, JWKSInvalid, JWKSTimeout, JWTExpired } from "jose/errors";3import { JWKSInvalid, JWKSTimeout, JWTExpired } from "jose/errors";
4 4
5export type VerifiedToken = {5export type VerifiedToken = {
6 /** The WorkOS account ID (user ID). */6 /** The WorkOS account ID (user ID). */
@@ -14,17 +14,29 @@ export class McpJwtVerificationError extends Data.TaggedError("McpJwtVerificatio · 7 unmodified lines
14 readonly reason: "expired" | "invalid" | "system";14 readonly reason: "expired" | "invalid" | "system";
15}> {}15}> {}
16 16
17const JoseErrorCode = Schema.Struct({ code: Schema.String });
18
19const getJoseErrorCode = (cause: unknown): string | null =>
20 Schema.is(JoseErrorCode)(cause) ? cause.code : null;
21
22const isJoseErrorCode = (code: string): boolean => code.startsWith("ERR_J");
23
17const classifyJwtVerificationError = (cause: unknown): McpJwtVerificationError =>24const classifyJwtVerificationError = (cause: unknown): McpJwtVerificationError =>
18 new McpJwtVerificationError({25 new McpJwtVerificationError({
19 cause,26 cause,
20 reason:27 reason: (() => {
21 cause instanceof JWTExpired28 const code = getJoseErrorCode(cause);
22 ? "expired"29 if (code === JWTExpired.code) return "expired";
23 : cause instanceof JWKSTimeout ||30 if (
24 cause instanceof JWKSInvalid ||31 code === JWKSTimeout.code ||
25 !(cause instanceof JOSEError)32 code === JWKSInvalid.code ||
26 ? "system"33 code === null ||
27 : "invalid",34 !isJoseErrorCode(code)
35 ) {
36 return "system";
37 }
38 return "invalid";
39 })(),
28 });40 });
29 41
30const isExpectedJwtVerificationError = (error: McpJwtVerificationError): boolean =>42const isExpectedJwtVerificationError = (error: McpJwtVerificationError): boolean =>