From fc5d8d95bc75e4142c9ddbc73040276e0f03f659 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 05:26:39 +0000 Subject: [PATCH 1/9] feat(functions): add hybrid JWT verification to TS edge runtime Port the asymmetric JWT support from the Go CLI (supabase/cli#4721, #4985) to the TypeScript edge runtime. The runtime now verifies ES256/RS256 tokens against a JWKS, falling back to the legacy symmetric secret for HS256, and exposes the JWKS to user workers via SUPABASE_JWKS. https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR --- packages/stack/package.json | 3 +- packages/stack/src/functions.ts | 3 + packages/stack/src/functions.unit.test.ts | 4 + .../stack/src/services/edge-runtime-main.ts | 98 ++++++++++++------- packages/stack/src/services/jose-jsr.d.ts | 11 +++ 5 files changed, 81 insertions(+), 38 deletions(-) create mode 100644 packages/stack/src/services/jose-jsr.d.ts diff --git a/packages/stack/package.json b/packages/stack/package.json index c538b044bc..5a3948dd8f 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -48,7 +48,8 @@ "@typescript/native-preview", "oxfmt", "oxlint", - "oxlint-tsgolint" + "oxlint-tsgolint", + "jsr" ], "ignoreBinaries": [ "nx", diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts index 527a163b65..c973234476 100644 --- a/packages/stack/src/functions.ts +++ b/packages/stack/src/functions.ts @@ -8,6 +8,7 @@ import { type ResolvedFunctionConfig, } from "@supabase/config"; import { Effect, FileSystem, Path, Redacted } from "effect"; +import { generateJwks } from "./JwtGenerator.ts"; import type { ResolvedStackConfig } from "./StackBuilder.ts"; export interface FunctionsConfig { @@ -27,6 +28,7 @@ export interface FunctionsRuntimeConfig { readonly publishableKey: string; readonly secretKey: string; readonly jwtSecret: string; + readonly jwks: string; readonly env: Readonly>; readonly functions: Readonly< Record< @@ -198,6 +200,7 @@ export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* ( publishableKey: stackConfig.publishableKey, secretKey: stackConfig.secretKey, jwtSecret: stackConfig.jwtSecret, + jwks: generateJwks(stackConfig.jwtSecret), env, functions: Object.fromEntries( enabledManifest.map(([slug, config]) => [ diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index a744b173ba..1396928bf0 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -78,6 +78,10 @@ describe("stack Functions runtime config", () => { CONFIG_ONLY: "from-project-env", SHARED: "from-project-env", }); + // JWKS is injected so the edge runtime can verify asymmetric JWTs. + expect(JSON.parse(config!.jwks)).toEqual({ + keys: [expect.objectContaining({ kty: "oct", k: expect.any(String) })], + }); }).pipe( Effect.provide(BunServices.layer), Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 1db10e1243..df5c77bf43 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -1,6 +1,16 @@ declare const Deno: any; declare const EdgeRuntime: any; +type Jose = typeof import("jsr:@panva/jose@6"); + +// jose is loaded lazily via dynamic import so the workspace test toolchain +// (which transforms this text-embedded file) never has to resolve the +// Deno-only `jsr:` specifier. The import only runs inside the edge runtime. +let josePromise: Promise | null = null; +function loadJose() { + return (josePromise ??= import("jsr:@panva/jose@6")); +} + const placeholder = { code: "FUNCTIONS_NOT_CONFIGURED", message: "Edge Functions are not configured for this local stack yet.", @@ -21,43 +31,60 @@ async function loadConfig() { } } -function base64UrlToBytes(value: string) { - const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); - const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); +async function isValidLegacyJwt(jose: Jose, jwtSecret: string, jwt: string) { + try { + await jose.jwtVerify(jwt, new TextEncoder().encode(jwtSecret)); + return true; + } catch (error) { + console.error("Symmetric legacy JWT verification failed", error); + return false; + } } -function bytesEqual(left: Uint8Array, right: Uint8Array) { - if (left.byteLength !== right.byteLength) return false; - let result = 0; - for (let i = 0; i < left.byteLength; i++) { - result |= left[i]! ^ right[i]!; +function createLocalJwks(jose: Jose, jwks: string) { + try { + return jose.createLocalJWKSet(JSON.parse(jwks)); + } catch { + return null; + } +} + +async function isValidAsymmetricJwt(jose: Jose, jwks: string, jwksUrl: string, jwt: string) { + try { + // Prefer the JWKS injected by the CLI; fall back to fetching it from the + // local auth service's well-known endpoint for keys minted elsewhere. + const keySet = createLocalJwks(jose, jwks) ?? jose.createRemoteJWKSet(new URL(jwksUrl)); + await jose.jwtVerify(jwt, keySet); + return true; + } catch (error) { + console.error("Asymmetric JWT verification failed", error); + return false; } - return result === 0; } -async function isValidLocalJwt(secret: string, jwt: string) { - const parts = jwt.split("."); - if (parts.length !== 3) return false; - const [header, payload, signature] = parts; - const decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(header!))); - - // WARN:(kallebysantos) Go version supports Asymmetric JWTs (ES256 | RS256) via SUPABASE_JWKS env - // It must be ported to TS as well - if (decodedHeader.alg !== "HS256") return false; - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signed = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(`${header}.${payload}`), - ); - return bytesEqual(new Uint8Array(signed), base64UrlToBytes(signature!)); +// Hybrid JWT verification: asymmetric (ES256 | RS256) tokens are verified +// against the JWKS, with the legacy symmetric secret (HS256) as a fallback. +// Mirrors the Go CLI runtime (supabase/cli#4721, #4985) during the migration +// to the new asymmetric JWT keys. +async function verifyHybridJwt(config: any, jwt: string) { + const jose = await loadJose(); + + let alg: string | undefined; + try { + ({ alg } = jose.decodeProtectedHeader(jwt)); + } catch (error) { + console.error("Failed to decode JWT header", error); + return false; + } + + if (alg === "HS256") { + return isValidLegacyJwt(jose, config.jwtSecret, jwt); + } + if (alg === "ES256" || alg === "RS256") { + const jwksUrl = new URL("/auth/v1/.well-known/jwks.json", config.supabaseUrl).href; + return isValidAsymmetricJwt(jose, config.jwks, jwksUrl, jwt); + } + return false; } async function verifyRequest(req: Request, config: any, functionConfig: any) { @@ -79,11 +106,7 @@ async function verifyRequest(req: Request, config: any, functionConfig: any) { return Response.json({ msg: "Auth header is not 'Bearer {token}'" }, { status: 401 }); } - try { - if (await isValidLocalJwt(config.jwtSecret, token)) return null; - } catch (error) { - console.error("JWT verification failed", error); - } + if (await verifyHybridJwt(config, token)) return null; return Response.json({ msg: "Invalid JWT" }, { status: 401 }); } @@ -108,6 +131,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu SUPABASE_DB_URL: config.dbUrl, SUPABASE_PUBLISHABLE_KEYS: JSON.stringify({ default: config.publishableKey }), SUPABASE_SECRET_KEYS: JSON.stringify({ default: config.secretKey }), + SUPABASE_JWKS: config.jwks, }); try { diff --git a/packages/stack/src/services/jose-jsr.d.ts b/packages/stack/src/services/jose-jsr.d.ts new file mode 100644 index 0000000000..3751814946 --- /dev/null +++ b/packages/stack/src/services/jose-jsr.d.ts @@ -0,0 +1,11 @@ +// Minimal ambient typings for the Deno-resolved jose import used by +// edge-runtime-main.ts. The bun workspace type-checker cannot resolve the +// `jsr:` specifier, so we describe only the surface that runtime script relies +// on; jose itself is loaded at runtime inside the edge runtime. +declare module "jsr:@panva/jose@6" { + type JwksResolver = (...args: ReadonlyArray) => Promise; + export function decodeProtectedHeader(token: string): { readonly alg?: string }; + export function jwtVerify(jwt: string, key: Uint8Array | JwksResolver): Promise; + export function createLocalJWKSet(jwks: { readonly keys: ReadonlyArray }): JwksResolver; + export function createRemoteJWKSet(url: URL): JwksResolver; +} From b7fdcbd4ca32e345ee35ef55a54a41fbb15eabd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 05:41:12 +0000 Subject: [PATCH 2/9] test(functions): cover hybrid JWT verification through the edge runtime Add an e2e case that exercises the real Deno edge runtime: missing credentials and forged HS256 tokens are rejected, while a validly-signed HS256 token and the apikey -> minted sb-api-key compatibility path are accepted. https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR --- packages/stack/tests/createStack.e2e.test.ts | 31 +++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 887368503a..2f3ba8ea2a 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -4,9 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { createStack, type StackHandle } from "../src/node.ts"; +import { generateJwt } from "../src/JwtGenerator.ts"; import { setupTestTable } from "./helpers/e2e.ts"; const STACK_E2E_TEST_TIMEOUT_MS = 5_000; +const JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; describe("createStack e2e", () => { let stack: StackHandle; @@ -22,7 +24,7 @@ describe("createStack e2e", () => { stack = await createStack({ projectDir, functions: { noVerifyJwt: true }, - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtSecret: JWT_SECRET, postgres: { dataDir }, }); @@ -94,6 +96,33 @@ describe("createStack e2e", () => { expect(await res.text()).toBe("later"); }); + test("enforces hybrid JWT verification on Edge Functions", { timeout: 20_000 }, async () => { + writeFunction(projectDir, "secure", "secure"); + // verify_jwt defaults to true, so this turns verification back on. + await stack.reloadFunctions(); + const url = `${stack.url}/functions/v1/secure`; + + // Missing credentials are rejected before reaching the function. + const missing = await fetch(url); + expect(missing.status).toBe(401); + + // A well-formed token signed with the wrong secret fails signature checks. + const forged = generateJwt("a-different-secret-at-least-32-characters-long", "anon"); + const forgedRes = await fetch(url, { headers: { Authorization: `Bearer ${forged}` } }); + expect(forgedRes.status).toBe(401); + + // A valid HS256 token is accepted via the legacy/hybrid path. + const valid = generateJwt(JWT_SECRET, "anon"); + const bearerRes = await fetch(url, { headers: { Authorization: `Bearer ${valid}` } }); + expect(bearerRes.status).toBe(200); + expect(await bearerRes.text()).toBe("secure"); + + // The apikey -> minted sb-api-key compatibility path is accepted too. + const apiKeyRes = await fetch(url, { headers: { apikey: stack.publishableKey } }); + expect(apiKeyRes.status).toBe(200); + expect(await apiKeyRes.text()).toBe("secure"); + }); + test( "supports the auth signup and session golden path", { timeout: STACK_E2E_TEST_TIMEOUT_MS }, From 8d9a640712068374df78a8f90609776d7b034288 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:36:38 +0000 Subject: [PATCH 3/9] fix(functions): resolve jose typing and asymmetric JWKS fallback - Type the lazy jose import via a string specifier + hand-written interface so every workspace that compiles the text-embedded runtime file (including apps/cli) type-checks without resolving the Deno-only `jsr:` module. - Fall back to the remote well-known JWKS when the injected local JWKS has no key matching an asymmetric token, instead of failing closed. - Re-enable JWT verification explicitly in the e2e test; reloadFunctions() with no options preserves the original noVerifyJwt config. https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR --- packages/stack/package.json | 3 +- .../stack/src/services/edge-runtime-main.ts | 43 ++++++++++++++----- packages/stack/src/services/jose-jsr.d.ts | 11 ----- packages/stack/tests/createStack.e2e.test.ts | 5 ++- 4 files changed, 36 insertions(+), 26 deletions(-) delete mode 100644 packages/stack/src/services/jose-jsr.d.ts diff --git a/packages/stack/package.json b/packages/stack/package.json index 5a3948dd8f..c538b044bc 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -48,8 +48,7 @@ "@typescript/native-preview", "oxfmt", "oxlint", - "oxlint-tsgolint", - "jsr" + "oxlint-tsgolint" ], "ignoreBinaries": [ "nx", diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index df5c77bf43..b92abf4bcd 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -1,14 +1,26 @@ declare const Deno: any; declare const EdgeRuntime: any; -type Jose = typeof import("jsr:@panva/jose@6"); +// Surface of the Deno-only `jsr:@panva/jose@6` module used by this script. +// We hand-type it (rather than importing its types) because the bun workspace +// type-checker cannot resolve the `jsr:` specifier. +type JwksResolver = (...args: ReadonlyArray) => Promise; +type JwtVerifyKey = Uint8Array | JwksResolver; +interface Jose { + decodeProtectedHeader(token: string): { readonly alg?: string }; + jwtVerify(jwt: string, key: JwtVerifyKey): Promise; + createLocalJWKSet(jwks: { readonly keys: ReadonlyArray }): JwksResolver; + createRemoteJWKSet(url: URL): JwksResolver; +} -// jose is loaded lazily via dynamic import so the workspace test toolchain -// (which transforms this text-embedded file) never has to resolve the -// Deno-only `jsr:` specifier. The import only runs inside the edge runtime. +// jose is loaded lazily via dynamic import so it only resolves inside the edge +// runtime. The specifier is typed as `string` so the workspace type-checker +// (which also compiles this text-embedded file) does not try to resolve the +// Deno-only `jsr:` module. let josePromise: Promise | null = null; -function loadJose() { - return (josePromise ??= import("jsr:@panva/jose@6")); +function loadJose(): Promise { + const specifier: string = "jsr:@panva/jose@6"; + return (josePromise ??= import(specifier)); } const placeholder = { @@ -41,7 +53,7 @@ async function isValidLegacyJwt(jose: Jose, jwtSecret: string, jwt: string) { } } -function createLocalJwks(jose: Jose, jwks: string) { +function createLocalJwks(jose: Jose, jwks: string): JwksResolver | null { try { return jose.createLocalJWKSet(JSON.parse(jwks)); } catch { @@ -50,11 +62,20 @@ function createLocalJwks(jose: Jose, jwks: string) { } async function isValidAsymmetricJwt(jose: Jose, jwks: string, jwksUrl: string, jwt: string) { + // Prefer the JWKS injected by the CLI. If it has no key matching the token + // (e.g. an asymmetric key minted elsewhere is absent from the local set), + // fall back to the auth service's well-known JWKS endpoint. + const localJwks = createLocalJwks(jose, jwks); + if (localJwks) { + try { + await jose.jwtVerify(jwt, localJwks); + return true; + } catch { + // No matching/valid local key — try the remote JWKS below. + } + } try { - // Prefer the JWKS injected by the CLI; fall back to fetching it from the - // local auth service's well-known endpoint for keys minted elsewhere. - const keySet = createLocalJwks(jose, jwks) ?? jose.createRemoteJWKSet(new URL(jwksUrl)); - await jose.jwtVerify(jwt, keySet); + await jose.jwtVerify(jwt, jose.createRemoteJWKSet(new URL(jwksUrl))); return true; } catch (error) { console.error("Asymmetric JWT verification failed", error); diff --git a/packages/stack/src/services/jose-jsr.d.ts b/packages/stack/src/services/jose-jsr.d.ts deleted file mode 100644 index 3751814946..0000000000 --- a/packages/stack/src/services/jose-jsr.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Minimal ambient typings for the Deno-resolved jose import used by -// edge-runtime-main.ts. The bun workspace type-checker cannot resolve the -// `jsr:` specifier, so we describe only the surface that runtime script relies -// on; jose itself is loaded at runtime inside the edge runtime. -declare module "jsr:@panva/jose@6" { - type JwksResolver = (...args: ReadonlyArray) => Promise; - export function decodeProtectedHeader(token: string): { readonly alg?: string }; - export function jwtVerify(jwt: string, key: Uint8Array | JwksResolver): Promise; - export function createLocalJWKSet(jwks: { readonly keys: ReadonlyArray }): JwksResolver; - export function createRemoteJWKSet(url: URL): JwksResolver; -} diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 2f3ba8ea2a..ac38d4d607 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -98,8 +98,9 @@ describe("createStack e2e", () => { test("enforces hybrid JWT verification on Edge Functions", { timeout: 20_000 }, async () => { writeFunction(projectDir, "secure", "secure"); - // verify_jwt defaults to true, so this turns verification back on. - await stack.reloadFunctions(); + // The stack was created with noVerifyJwt; re-enable verification explicitly + // (reloadFunctions() with no opts would keep the original config). + await stack.reloadFunctions({ noVerifyJwt: false }); const url = `${stack.url}/functions/v1/secure`; // Missing credentials are rejected before reaching the function. From 3ab48f16e0867536d638d000cb720e3fcec64c1d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:50:21 +0000 Subject: [PATCH 4/9] fix(functions): load jose via injected static import for edge runtime The lazy dynamic import used a non-literal specifier, which the edge runtime cannot statically discover and pre-load, so jose failed to import at runtime and JWT verification threw (HTTP 500 instead of 401). Inject a static `import * as jose from "jsr:@panva/jose@6"` when materializing the runtime script, and consume it as an ambient `jose` global in the source. This matches the Go template's runtime import while keeping the `jsr:` specifier out of the bun-typed source entirely (no .d.ts, triple-slash, or knip ignore). https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR --- .../stack/src/services/edge-runtime-main.ts | 39 +++++++------------ packages/stack/src/services/edge-runtime.ts | 7 +++- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index b92abf4bcd..017a78472c 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -1,27 +1,18 @@ declare const Deno: any; declare const EdgeRuntime: any; -// Surface of the Deno-only `jsr:@panva/jose@6` module used by this script. -// We hand-type it (rather than importing its types) because the bun workspace -// type-checker cannot resolve the `jsr:` specifier. +// `jose` is provided by a static `import * as jose from "jsr:@panva/jose@6"` +// that edge-runtime.ts prepends when materializing this script for the Deno +// edge runtime. Keeping the `jsr:` specifier out of this file's source lets the +// bun workspace type-check, lint, and bundle it without resolving a Deno-only +// module. It is declared here for type-checking only. type JwksResolver = (...args: ReadonlyArray) => Promise; -type JwtVerifyKey = Uint8Array | JwksResolver; -interface Jose { +declare const jose: { decodeProtectedHeader(token: string): { readonly alg?: string }; - jwtVerify(jwt: string, key: JwtVerifyKey): Promise; + jwtVerify(jwt: string, key: Uint8Array | JwksResolver): Promise; createLocalJWKSet(jwks: { readonly keys: ReadonlyArray }): JwksResolver; createRemoteJWKSet(url: URL): JwksResolver; -} - -// jose is loaded lazily via dynamic import so it only resolves inside the edge -// runtime. The specifier is typed as `string` so the workspace type-checker -// (which also compiles this text-embedded file) does not try to resolve the -// Deno-only `jsr:` module. -let josePromise: Promise | null = null; -function loadJose(): Promise { - const specifier: string = "jsr:@panva/jose@6"; - return (josePromise ??= import(specifier)); -} +}; const placeholder = { code: "FUNCTIONS_NOT_CONFIGURED", @@ -43,7 +34,7 @@ async function loadConfig() { } } -async function isValidLegacyJwt(jose: Jose, jwtSecret: string, jwt: string) { +async function isValidLegacyJwt(jwtSecret: string, jwt: string) { try { await jose.jwtVerify(jwt, new TextEncoder().encode(jwtSecret)); return true; @@ -53,7 +44,7 @@ async function isValidLegacyJwt(jose: Jose, jwtSecret: string, jwt: string) { } } -function createLocalJwks(jose: Jose, jwks: string): JwksResolver | null { +function createLocalJwks(jwks: string): JwksResolver | null { try { return jose.createLocalJWKSet(JSON.parse(jwks)); } catch { @@ -61,11 +52,11 @@ function createLocalJwks(jose: Jose, jwks: string): JwksResolver | null { } } -async function isValidAsymmetricJwt(jose: Jose, jwks: string, jwksUrl: string, jwt: string) { +async function isValidAsymmetricJwt(jwks: string, jwksUrl: string, jwt: string) { // Prefer the JWKS injected by the CLI. If it has no key matching the token // (e.g. an asymmetric key minted elsewhere is absent from the local set), // fall back to the auth service's well-known JWKS endpoint. - const localJwks = createLocalJwks(jose, jwks); + const localJwks = createLocalJwks(jwks); if (localJwks) { try { await jose.jwtVerify(jwt, localJwks); @@ -88,8 +79,6 @@ async function isValidAsymmetricJwt(jose: Jose, jwks: string, jwksUrl: string, j // Mirrors the Go CLI runtime (supabase/cli#4721, #4985) during the migration // to the new asymmetric JWT keys. async function verifyHybridJwt(config: any, jwt: string) { - const jose = await loadJose(); - let alg: string | undefined; try { ({ alg } = jose.decodeProtectedHeader(jwt)); @@ -99,11 +88,11 @@ async function verifyHybridJwt(config: any, jwt: string) { } if (alg === "HS256") { - return isValidLegacyJwt(jose, config.jwtSecret, jwt); + return isValidLegacyJwt(config.jwtSecret, jwt); } if (alg === "ES256" || alg === "RS256") { const jwksUrl = new URL("/auth/v1/.well-known/jwks.json", config.supabaseUrl).href; - return isValidAsymmetricJwt(jose, config.jwks, jwksUrl, jwt); + return isValidAsymmetricJwt(config.jwks, jwksUrl, jwt); } return false; } diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 4132cf4787..031f5204a6 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -27,8 +27,13 @@ interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions { const bootstrapFileName = "index.ts"; const bootstrapMountDir = "/workspace"; const bootstrapSourcePath = new URL("./edge-runtime-main.ts", import.meta.url); +// jose is imported here (rather than in edge-runtime-main.ts) so the bun +// workspace toolchain never has to resolve the Deno-only `jsr:` specifier; the +// runtime script consumes it as an ambient `jose` global. See edge-runtime-main.ts. +const joseImport = 'import * as jose from "jsr:@panva/jose@6";\n'; const resolvedBootstrapSource = - bootstrapSource === "" ? readFileSync(bootstrapSourcePath, "utf8") : bootstrapSource; + joseImport + + (bootstrapSource === "" ? readFileSync(bootstrapSourcePath, "utf8") : bootstrapSource); function ensureBootstrapScript(runtimeRoot: string): string { const bootstrapDir = join(runtimeRoot, "edge-runtime"); From f66460ecc73ef560e920e8d2fa4417439b30f8a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 08:34:32 +0000 Subject: [PATCH 5/9] fix(functions): verify JWTs with WebCrypto instead of jose Replace the jose/jsr dependency with self-contained WebCrypto verification: HS256 via crypto.subtle HMAC (restoring the proven legacy path) and ES256/RS256 by importing JWK public keys from the local JWKS, falling back to the auth service's well-known endpoint. The remote JWKS is cached per URL. This removes the runtime failure where a valid HS256 token was rejected, and avoids resolving a Deno-only jsr module at edge-runtime startup (so stacks with no functions, noVerifyJwt, or no network still boot). https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR --- .../stack/src/services/edge-runtime-main.ts | 166 ++++++++++++------ packages/stack/src/services/edge-runtime.ts | 7 +- 2 files changed, 117 insertions(+), 56 deletions(-) diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 017a78472c..c347a201cf 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -1,19 +1,6 @@ declare const Deno: any; declare const EdgeRuntime: any; -// `jose` is provided by a static `import * as jose from "jsr:@panva/jose@6"` -// that edge-runtime.ts prepends when materializing this script for the Deno -// edge runtime. Keeping the `jsr:` specifier out of this file's source lets the -// bun workspace type-check, lint, and bundle it without resolving a Deno-only -// module. It is declared here for type-checking only. -type JwksResolver = (...args: ReadonlyArray) => Promise; -declare const jose: { - decodeProtectedHeader(token: string): { readonly alg?: string }; - jwtVerify(jwt: string, key: Uint8Array | JwksResolver): Promise; - createLocalJWKSet(jwks: { readonly keys: ReadonlyArray }): JwksResolver; - createRemoteJWKSet(url: URL): JwksResolver; -}; - const placeholder = { code: "FUNCTIONS_NOT_CONFIGURED", message: "Edge Functions are not configured for this local stack yet.", @@ -34,65 +21,144 @@ async function loadConfig() { } } -async function isValidLegacyJwt(jwtSecret: string, jwt: string) { - try { - await jose.jwtVerify(jwt, new TextEncoder().encode(jwtSecret)); - return true; - } catch (error) { - console.error("Symmetric legacy JWT verification failed", error); - return false; - } +function base64UrlToBytes(value: string) { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); } -function createLocalJwks(jwks: string): JwksResolver | null { - try { - return jose.createLocalJWKSet(JSON.parse(jwks)); - } catch { - return null; - } +interface Jwk { + readonly kty?: string; + readonly kid?: string; +} + +interface Jwks { + readonly keys: ReadonlyArray; } -async function isValidAsymmetricJwt(jwks: string, jwksUrl: string, jwt: string) { - // Prefer the JWKS injected by the CLI. If it has no key matching the token - // (e.g. an asymmetric key minted elsewhere is absent from the local set), - // fall back to the auth service's well-known JWKS endpoint. - const localJwks = createLocalJwks(jwks); - if (localJwks) { +// Asymmetric algorithms supported during the migration to new JWT keys, mapped +// to the WebCrypto import/verify parameters used to validate their signatures. +const ASYMMETRIC_ALGORITHMS = { + ES256: { + kty: "EC", + importParams: { name: "ECDSA", namedCurve: "P-256" }, + verifyParams: { name: "ECDSA", hash: "SHA-256" }, + }, + RS256: { + kty: "RSA", + importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + verifyParams: { name: "RSASSA-PKCS1-v1_5" }, + }, +}; + +type AsymmetricAlgorithm = keyof typeof ASYMMETRIC_ALGORITHMS; + +async function verifyLegacyHs256(secret: string, signingInput: string, signature: string) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + return crypto.subtle.verify( + "HMAC", + key, + base64UrlToBytes(signature), + new TextEncoder().encode(signingInput), + ); +} + +async function verifyWithJwks( + alg: AsymmetricAlgorithm, + kid: string | undefined, + signingInput: string, + signature: string, + jwks: Jwks, +) { + const spec = ASYMMETRIC_ALGORITHMS[alg]; + const data = new TextEncoder().encode(signingInput); + const sig = base64UrlToBytes(signature); + const candidates = jwks.keys.filter( + (key) => key.kty === spec.kty && (kid === undefined || key.kid === kid), + ); + for (const jwk of candidates) { try { - await jose.jwtVerify(jwt, localJwks); - return true; - } catch { - // No matching/valid local key — try the remote JWKS below. + const key = await crypto.subtle.importKey("jwk", jwk, spec.importParams, false, ["verify"]); + if (await crypto.subtle.verify(spec.verifyParams, key, sig, data)) return true; + } catch (error) { + console.error("Asymmetric JWK verification failed", error); } } + return false; +} + +function toJwks(value: unknown): Jwks { + if (value !== null && typeof value === "object" && "keys" in value && Array.isArray(value.keys)) { + return { keys: value.keys }; + } + return { keys: [] }; +} + +function parseLocalJwks(jwks: string): Jwks { try { - await jose.jwtVerify(jwt, jose.createRemoteJWKSet(new URL(jwksUrl))); - return true; - } catch (error) { - console.error("Asymmetric JWT verification failed", error); - return false; + return toJwks(JSON.parse(jwks)); + } catch { + return { keys: [] }; } } +// Cache the well-known JWKS per URL so asymmetric verification does not refetch +// `/auth/v1/.well-known/jwks.json` on every request. +const remoteJwksCache = new Map>(); + +function fetchRemoteJwks(jwksUrl: string): Promise { + const cached = remoteJwksCache.get(jwksUrl); + if (cached) return cached; + const pending = fetch(jwksUrl) + .then((res) => res.json()) + .then(toJwks); + pending.catch(() => remoteJwksCache.delete(jwksUrl)); + remoteJwksCache.set(jwksUrl, pending); + return pending; +} + // Hybrid JWT verification: asymmetric (ES256 | RS256) tokens are verified // against the JWKS, with the legacy symmetric secret (HS256) as a fallback. // Mirrors the Go CLI runtime (supabase/cli#4721, #4985) during the migration // to the new asymmetric JWT keys. async function verifyHybridJwt(config: any, jwt: string) { - let alg: string | undefined; + const parts = jwt.split("."); + if (parts.length !== 3) return false; + const [header, payload, signature] = parts; + const signingInput = `${header}.${payload}`; + + let decodedHeader: { alg?: string; kid?: string }; try { - ({ alg } = jose.decodeProtectedHeader(jwt)); + decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(header!))); } catch (error) { console.error("Failed to decode JWT header", error); return false; } - if (alg === "HS256") { - return isValidLegacyJwt(config.jwtSecret, jwt); - } - if (alg === "ES256" || alg === "RS256") { - const jwksUrl = new URL("/auth/v1/.well-known/jwks.json", config.supabaseUrl).href; - return isValidAsymmetricJwt(config.jwks, jwksUrl, jwt); + try { + if (decodedHeader.alg === "HS256") { + return await verifyLegacyHs256(config.jwtSecret, signingInput, signature!); + } + if (decodedHeader.alg === "ES256" || decodedHeader.alg === "RS256") { + const { alg, kid } = decodedHeader; + // Prefer the JWKS injected by the CLI; fall back to the auth service's + // well-known endpoint for keys minted elsewhere. + const localJwks = parseLocalJwks(config.jwks); + if (await verifyWithJwks(alg, kid, signingInput, signature!, localJwks)) { + return true; + } + const jwksUrl = new URL("/auth/v1/.well-known/jwks.json", config.supabaseUrl).href; + const remoteJwks = await fetchRemoteJwks(jwksUrl); + return await verifyWithJwks(alg, kid, signingInput, signature!, remoteJwks); + } + } catch (error) { + console.error("JWT verification failed", error); } return false; } diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 031f5204a6..4132cf4787 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -27,13 +27,8 @@ interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions { const bootstrapFileName = "index.ts"; const bootstrapMountDir = "/workspace"; const bootstrapSourcePath = new URL("./edge-runtime-main.ts", import.meta.url); -// jose is imported here (rather than in edge-runtime-main.ts) so the bun -// workspace toolchain never has to resolve the Deno-only `jsr:` specifier; the -// runtime script consumes it as an ambient `jose` global. See edge-runtime-main.ts. -const joseImport = 'import * as jose from "jsr:@panva/jose@6";\n'; const resolvedBootstrapSource = - joseImport + - (bootstrapSource === "" ? readFileSync(bootstrapSourcePath, "utf8") : bootstrapSource); + bootstrapSource === "" ? readFileSync(bootstrapSourcePath, "utf8") : bootstrapSource; function ensureBootstrapScript(runtimeRoot: string): string { const bootstrapDir = join(runtimeRoot, "edge-runtime"); From c83bda6b6c27dd33724bb0fe74d3e1fc539c76d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 09:42:09 +0000 Subject: [PATCH 6/9] fix(functions): validate JWT claims and harden JWKS handling - Reject expired (exp) / not-yet-valid (nbf) tokens with clock skew, matching the Go/Docker runtime behavior - Skip caching empty/failed JWKS responses (res.ok check + empty-set eviction) - Match keyless JWKS entries so single-key sets that omit kid still verify - Drop per-candidate verification log noise - Export verifyHybridJwt/verifyWithJwks/areClaimsValid and add unit coverage for the ES256/RS256, kid-filtering, remote-fallback, and claim paths - Restore noVerifyJwt on the shared e2e stack after the hybrid test --- .../stack/src/services/edge-runtime-main.ts | 57 ++++- .../services/edge-runtime-main.unit.test.ts | 199 ++++++++++++++++++ packages/stack/tests/createStack.e2e.test.ts | 4 + 3 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 packages/stack/src/services/edge-runtime-main.unit.test.ts diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index c347a201cf..b53dad0037 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -69,7 +69,7 @@ async function verifyLegacyHs256(secret: string, signingInput: string, signature ); } -async function verifyWithJwks( +export async function verifyWithJwks( alg: AsymmetricAlgorithm, kid: string | undefined, signingInput: string, @@ -79,15 +79,20 @@ async function verifyWithJwks( const spec = ASYMMETRIC_ALGORITHMS[alg]; const data = new TextEncoder().encode(signingInput); const sig = base64UrlToBytes(signature); + // Match by key type, and by `kid` when both the token and the key carry one. + // Keys without a `kid` stay eligible so single-key sets that omit it (some + // issuers do) still verify — mirroring the more lenient Go-side behavior. const candidates = jwks.keys.filter( - (key) => key.kty === spec.kty && (kid === undefined || key.kid === kid), + (key) => + key.kty === spec.kty && (kid === undefined || key.kid === undefined || key.kid === kid), ); for (const jwk of candidates) { try { const key = await crypto.subtle.importKey("jwk", jwk, spec.importParams, false, ["verify"]); if (await crypto.subtle.verify(spec.verifyParams, key, sig, data)) return true; - } catch (error) { - console.error("Asymmetric JWK verification failed", error); + } catch { + // Not a usable/matching key (bad import or wrong signature) — try the next + // candidate. Logging here fires per key per request, so stay quiet. } } return false; @@ -116,18 +121,47 @@ function fetchRemoteJwks(jwksUrl: string): Promise { const cached = remoteJwksCache.get(jwksUrl); if (cached) return cached; const pending = fetch(jwksUrl) - .then((res) => res.json()) - .then(toJwks); + .then((res) => { + // Without this, a non-2xx response with a JSON body (e.g. a 502/404 from + // the gateway during startup) would parse into an empty key set and be + // cached permanently, rejecting every asymmetric token until restart. + if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`); + return res.json(); + }) + .then(toJwks) + .then((jwks) => { + // Don't cache an empty key set: the auth service may not have published + // its keys yet, so allow a refetch on the next request instead. + if (jwks.keys.length === 0) remoteJwksCache.delete(jwksUrl); + return jwks; + }); pending.catch(() => remoteJwksCache.delete(jwksUrl)); remoteJwksCache.set(jwksUrl, pending); return pending; } +// Reject expired (`exp`) or not-yet-valid (`nbf`) tokens regardless of +// signature, matching the Go/Docker runtime (jose / golang-jwt validate these +// by default). A small clock-skew window avoids spurious failures locally. +export function areClaimsValid(payload: string): boolean { + let claims: { exp?: unknown; nbf?: unknown }; + try { + claims = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))); + } catch { + return false; + } + const now = Math.floor(Date.now() / 1000); + const skew = 60; + if (typeof claims.exp === "number" && claims.exp + skew < now) return false; + if (typeof claims.nbf === "number" && claims.nbf - skew > now) return false; + return true; +} + // Hybrid JWT verification: asymmetric (ES256 | RS256) tokens are verified // against the JWKS, with the legacy symmetric secret (HS256) as a fallback. // Mirrors the Go CLI runtime (supabase/cli#4721, #4985) during the migration // to the new asymmetric JWT keys. -async function verifyHybridJwt(config: any, jwt: string) { +export async function verifyHybridJwt(config: any, jwt: string) { const parts = jwt.split("."); if (parts.length !== 3) return false; const [header, payload, signature] = parts; @@ -141,14 +175,19 @@ async function verifyHybridJwt(config: any, jwt: string) { return false; } + if (!areClaimsValid(payload!)) return false; + try { if (decodedHeader.alg === "HS256") { return await verifyLegacyHs256(config.jwtSecret, signingInput, signature!); } if (decodedHeader.alg === "ES256" || decodedHeader.alg === "RS256") { const { alg, kid } = decodedHeader; - // Prefer the JWKS injected by the CLI; fall back to the auth service's - // well-known endpoint for keys minted elsewhere. + // The CLI-injected `config.jwks` is currently symmetric-only (an `oct` + // key derived from jwtSecret), so it never matches an ES256/RS256 token + // today — this local check is future-proofing for when the stack config + // grows asymmetric signing keys. Real asymmetric verification currently + // resolves through the auth service's well-known endpoint below. const localJwks = parseLocalJwks(config.jwks); if (await verifyWithJwks(alg, kid, signingInput, signature!, localJwks)) { return true; diff --git a/packages/stack/src/services/edge-runtime-main.unit.test.ts b/packages/stack/src/services/edge-runtime-main.unit.test.ts new file mode 100644 index 0000000000..d3bd48f7c7 --- /dev/null +++ b/packages/stack/src/services/edge-runtime-main.unit.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { generateJwks, generateJwt } from "../JwtGenerator.ts"; +import { areClaimsValid, verifyHybridJwt } from "./edge-runtime-main.ts"; + +const JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; +const SUPABASE_URL = "http://localhost:54321"; + +function b64url(value: string | Uint8Array): string { + return Buffer.from(value as Uint8Array).toString("base64url"); +} + +const asymmetricParams = { + ES256: { + generate: { name: "ECDSA", namedCurve: "P-256" }, + sign: { name: "ECDSA", hash: "SHA-256" }, + }, + RS256: { + generate: { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + sign: { name: "RSASSA-PKCS1-v1_5" }, + }, +} as const; + +async function generateAsymmetricKey(alg: keyof typeof asymmetricParams, kid?: string) { + const pair = await crypto.subtle.generateKey(asymmetricParams[alg].generate, true, [ + "sign", + "verify", + ]); + const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); + return { privateKey: pair.privateKey, jwk: { ...jwk, ...(kid ? { kid } : {}) } }; +} + +async function mintAsymmetricJwt(opts: { + alg: keyof typeof asymmetricParams; + privateKey: CryptoKey; + kid?: string; + claims?: Record; +}): Promise { + const now = Math.floor(Date.now() / 1000); + const header = { alg: opts.alg, typ: "JWT", ...(opts.kid ? { kid: opts.kid } : {}) }; + const payload = { + role: "authenticated", + iss: "supabase", + iat: now, + exp: now + 3600, + ...opts.claims, + }; + const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`; + const signature = await crypto.subtle.sign( + asymmetricParams[opts.alg].sign, + opts.privateKey, + new TextEncoder().encode(signingInput), + ); + return `${signingInput}.${b64url(new Uint8Array(signature))}`; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("verifyHybridJwt — HS256 legacy path", () => { + const config = { + jwtSecret: JWT_SECRET, + jwks: generateJwks(JWT_SECRET), + supabaseUrl: SUPABASE_URL, + }; + + it("accepts a token signed with the symmetric secret", async () => { + const token = generateJwt(JWT_SECRET, "anon"); + expect(await verifyHybridJwt(config, token)).toBe(true); + }); + + it("rejects a token signed with the wrong secret", async () => { + const token = generateJwt("a-different-secret-at-least-32-characters-long", "anon"); + expect(await verifyHybridJwt(config, token)).toBe(false); + }); + + it("rejects a malformed token", async () => { + expect(await verifyHybridJwt(config, "not-a-jwt")).toBe(false); + }); +}); + +describe.each(["ES256", "RS256"] as const)("verifyHybridJwt — %s asymmetric path", (alg) => { + it("accepts a token verified against the local JWKS", async () => { + const { privateKey, jwk } = await generateAsymmetricKey(alg, "key-1"); + const token = await mintAsymmetricJwt({ alg, privateKey, kid: "key-1" }); + const config = { + jwtSecret: JWT_SECRET, + jwks: JSON.stringify({ keys: [jwk] }), + supabaseUrl: SUPABASE_URL, + }; + expect(await verifyHybridJwt(config, token)).toBe(true); + }); + + it("rejects a token signed by a key absent from the JWKS", async () => { + const signer = await generateAsymmetricKey(alg, "key-1"); + const other = await generateAsymmetricKey(alg, "key-2"); + const token = await mintAsymmetricJwt({ alg, privateKey: signer.privateKey, kid: "key-1" }); + const config = { + jwtSecret: JWT_SECRET, + jwks: JSON.stringify({ keys: [other.jwk] }), + supabaseUrl: SUPABASE_URL, + }; + expect(await verifyHybridJwt(config, token)).toBe(false); + }); + + it("excludes keys whose kid does not match the token", async () => { + const { privateKey, jwk } = await generateAsymmetricKey(alg, "real-kid"); + const token = await mintAsymmetricJwt({ alg, privateKey, kid: "wrong-kid" }); + const config = { + jwtSecret: JWT_SECRET, + jwks: JSON.stringify({ keys: [jwk] }), + supabaseUrl: SUPABASE_URL, + }; + expect(await verifyHybridJwt(config, token)).toBe(false); + }); + + it("matches keyless JWKS entries when the token carries a kid", async () => { + const { privateKey, jwk } = await generateAsymmetricKey(alg); + const token = await mintAsymmetricJwt({ alg, privateKey, kid: "any-kid" }); + const config = { + jwtSecret: JWT_SECRET, + jwks: JSON.stringify({ keys: [jwk] }), + supabaseUrl: SUPABASE_URL, + }; + expect(await verifyHybridJwt(config, token)).toBe(true); + }); + + it("falls back to the remote well-known JWKS when the local set has no match", async () => { + const { privateKey, jwk } = await generateAsymmetricKey(alg, "remote-kid"); + const token = await mintAsymmetricJwt({ alg, privateKey, kid: "remote-kid" }); + const fetchMock = vi.fn(async () => Response.json({ keys: [jwk] })); + vi.stubGlobal("fetch", fetchMock); + // Unique per alg so the module-level remote JWKS cache (keyed by URL) does + // not leak the other parametrized run's key set into this one. + const supabaseUrl = `http://remote-fallback-${alg.toLowerCase()}.test`; + const config = { + jwtSecret: JWT_SECRET, + // oct-only local set never matches an asymmetric token, forcing fallback. + jwks: generateJwks(JWT_SECRET), + supabaseUrl, + }; + expect(await verifyHybridJwt(config, token)).toBe(true); + expect(fetchMock).toHaveBeenCalledWith(`${supabaseUrl}/auth/v1/.well-known/jwks.json`); + }); +}); + +describe("verifyHybridJwt — claim validation", () => { + const config = { + jwtSecret: JWT_SECRET, + jwks: generateJwks(JWT_SECRET), + supabaseUrl: SUPABASE_URL, + }; + + it("rejects an expired token even with a valid signature", async () => { + const now = Math.floor(Date.now() / 1000); + const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = b64url(JSON.stringify({ role: "anon", iat: now - 7200, exp: now - 3600 })); + const { createHmac } = await import("node:crypto"); + const signature = createHmac("sha256", JWT_SECRET) + .update(`${header}.${payload}`) + .digest("base64url"); + expect(await verifyHybridJwt(config, `${header}.${payload}.${signature}`)).toBe(false); + }); + + it("rejects a not-yet-valid (nbf) token", async () => { + const now = Math.floor(Date.now() / 1000); + const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = b64url( + JSON.stringify({ role: "anon", iat: now, nbf: now + 3600, exp: now + 7200 }), + ); + const { createHmac } = await import("node:crypto"); + const signature = createHmac("sha256", JWT_SECRET) + .update(`${header}.${payload}`) + .digest("base64url"); + expect(await verifyHybridJwt(config, `${header}.${payload}.${signature}`)).toBe(false); + }); +}); + +describe("areClaimsValid", () => { + const now = Math.floor(Date.now() / 1000); + + it("accepts a token with no temporal claims", () => { + expect(areClaimsValid(b64url(JSON.stringify({ role: "anon" })))).toBe(true); + }); + + it("tolerates clock skew around exp", () => { + expect(areClaimsValid(b64url(JSON.stringify({ exp: now - 30 })))).toBe(true); + expect(areClaimsValid(b64url(JSON.stringify({ exp: now - 120 })))).toBe(false); + }); + + it("returns false for an undecodable payload", () => { + expect(areClaimsValid("!!!not-base64-json")).toBe(false); + }); +}); diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index ac38d4d607..c2f0ccc676 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -122,6 +122,10 @@ describe("createStack e2e", () => { const apiKeyRes = await fetch(url, { headers: { apikey: stack.publishableKey } }); expect(apiKeyRes.status).toBe(200); expect(await apiKeyRes.text()).toBe("secure"); + + // Restore the shared stack's open-functions default so later tests don't + // inherit JWT enforcement from this one. + await stack.reloadFunctions({ noVerifyJwt: true }); }); test( From 80e5620b8a4303c24860578282d8266a7f711d41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 09:53:31 +0000 Subject: [PATCH 7/9] fix(functions): bound JWKS cache with TTL and harden claim parsing - Add a 5-minute TTL to the remote JWKS cache so rotated/revoked signing keys stop verifying against a stale cached set without a runtime restart - Reject non-object JWT payloads (e.g. JSON null) so they fail the auth gate with a 401 instead of throwing into a 500 - Reject non-numeric exp/nbf claims rather than silently skipping them - Cover the TTL refetch, null payload, and malformed-claim cases in unit tests --- .../stack/src/services/edge-runtime-main.ts | 38 ++++++++------ .../services/edge-runtime-main.unit.test.ts | 50 +++++++++++++++++++ 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index b53dad0037..8b51bcddde 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -114,46 +114,56 @@ function parseLocalJwks(jwks: string): Jwks { } // Cache the well-known JWKS per URL so asymmetric verification does not refetch -// `/auth/v1/.well-known/jwks.json` on every request. -const remoteJwksCache = new Map>(); +// `/auth/v1/.well-known/jwks.json` on every request. A short TTL bounds how long +// a rotated or revoked signing key can keep verifying against a stale cached set +// before the runtime picks up the issuer's current keys. +const REMOTE_JWKS_TTL_MS = 5 * 60 * 1000; +const remoteJwksCache = new Map }>(); function fetchRemoteJwks(jwksUrl: string): Promise { const cached = remoteJwksCache.get(jwksUrl); - if (cached) return cached; - const pending = fetch(jwksUrl) + if (cached && Date.now() - cached.cachedAt < REMOTE_JWKS_TTL_MS) return cached.jwks; + const cachedAt = Date.now(); + const jwks = fetch(jwksUrl) .then((res) => { // Without this, a non-2xx response with a JSON body (e.g. a 502/404 from // the gateway during startup) would parse into an empty key set and be - // cached permanently, rejecting every asymmetric token until restart. + // cached, rejecting every asymmetric token until the entry expires. if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`); return res.json(); }) .then(toJwks) - .then((jwks) => { + .then((parsed) => { // Don't cache an empty key set: the auth service may not have published // its keys yet, so allow a refetch on the next request instead. - if (jwks.keys.length === 0) remoteJwksCache.delete(jwksUrl); - return jwks; + if (parsed.keys.length === 0) remoteJwksCache.delete(jwksUrl); + return parsed; }); - pending.catch(() => remoteJwksCache.delete(jwksUrl)); - remoteJwksCache.set(jwksUrl, pending); - return pending; + jwks.catch(() => remoteJwksCache.delete(jwksUrl)); + remoteJwksCache.set(jwksUrl, { cachedAt, jwks }); + return jwks; } // Reject expired (`exp`) or not-yet-valid (`nbf`) tokens regardless of // signature, matching the Go/Docker runtime (jose / golang-jwt validate these // by default). A small clock-skew window avoids spurious failures locally. export function areClaimsValid(payload: string): boolean { - let claims: { exp?: unknown; nbf?: unknown }; + let claims: unknown; try { claims = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))); } catch { return false; } + // A valid JWT payload must be a JSON object; reject anything else (e.g. `null` + // or a bare string) rather than dereferencing it and throwing past the gate. + if (typeof claims !== "object" || claims === null) return false; + const { exp, nbf } = claims as { exp?: unknown; nbf?: unknown }; const now = Math.floor(Date.now() / 1000); const skew = 60; - if (typeof claims.exp === "number" && claims.exp + skew < now) return false; - if (typeof claims.nbf === "number" && claims.nbf - skew > now) return false; + // exp/nbf must be NumericDate (a number) per the JWT spec — reject malformed + // non-numeric values instead of silently skipping the check. + if (exp !== undefined && (typeof exp !== "number" || exp + skew < now)) return false; + if (nbf !== undefined && (typeof nbf !== "number" || nbf - skew > now)) return false; return true; } diff --git a/packages/stack/src/services/edge-runtime-main.unit.test.ts b/packages/stack/src/services/edge-runtime-main.unit.test.ts index d3bd48f7c7..c25f0d002f 100644 --- a/packages/stack/src/services/edge-runtime-main.unit.test.ts +++ b/packages/stack/src/services/edge-runtime-main.unit.test.ts @@ -60,6 +60,7 @@ async function mintAsymmetricJwt(opts: { afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); describe("verifyHybridJwt — HS256 legacy path", () => { @@ -130,6 +131,35 @@ describe.each(["ES256", "RS256"] as const)("verifyHybridJwt — %s asymmetric pa expect(await verifyHybridJwt(config, token)).toBe(true); }); + it("refetches the remote JWKS after the cache TTL expires", async () => { + const signer = await generateAsymmetricKey(alg, "rotated"); + const stale = await generateAsymmetricKey(alg, "old"); + const token = await mintAsymmetricJwt({ alg, privateKey: signer.privateKey, kid: "rotated" }); + const supabaseUrl = `http://remote-rotation-${alg.toLowerCase()}.test`; + const config = { jwtSecret: JWT_SECRET, jwks: generateJwks(JWT_SECRET), supabaseUrl }; + + let currentKeys = [stale.jwk]; + const fetchMock = vi.fn(async () => Response.json({ keys: currentKeys })); + vi.stubGlobal("fetch", fetchMock); + let clock = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => clock); + + // Only the stale key is published, so the token misses and is rejected. + expect(await verifyHybridJwt(config, token)).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Within the TTL the cached (stale) set is reused — still a miss, no refetch. + clock += 60_000; + expect(await verifyHybridJwt(config, token)).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // After the TTL elapses the rotated-in key is fetched and verifies. + currentKeys = [signer.jwk]; + clock += 5 * 60 * 1000; + expect(await verifyHybridJwt(config, token)).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("falls back to the remote well-known JWKS when the local set has no match", async () => { const { privateKey, jwk } = await generateAsymmetricKey(alg, "remote-kid"); const token = await mintAsymmetricJwt({ alg, privateKey, kid: "remote-kid" }); @@ -179,6 +209,16 @@ describe("verifyHybridJwt — claim validation", () => { .digest("base64url"); expect(await verifyHybridJwt(config, `${header}.${payload}.${signature}`)).toBe(false); }); + + it("rejects a JSON null payload without throwing past the gate", async () => { + const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = b64url("null"); + const { createHmac } = await import("node:crypto"); + const signature = createHmac("sha256", JWT_SECRET) + .update(`${header}.${payload}`) + .digest("base64url"); + expect(await verifyHybridJwt(config, `${header}.${payload}.${signature}`)).toBe(false); + }); }); describe("areClaimsValid", () => { @@ -193,6 +233,16 @@ describe("areClaimsValid", () => { expect(areClaimsValid(b64url(JSON.stringify({ exp: now - 120 })))).toBe(false); }); + it("rejects non-numeric exp/nbf claims", () => { + expect(areClaimsValid(b64url(JSON.stringify({ exp: "9999999999" })))).toBe(false); + expect(areClaimsValid(b64url(JSON.stringify({ nbf: "1" })))).toBe(false); + }); + + it("rejects non-object payloads", () => { + expect(areClaimsValid(b64url("null"))).toBe(false); + expect(areClaimsValid(b64url(JSON.stringify("a-string")))).toBe(false); + }); + it("returns false for an undecodable payload", () => { expect(areClaimsValid("!!!not-base64-json")).toBe(false); }); From a211129cc5dfc28b1734297120224c351b85a250 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 10:01:45 +0000 Subject: [PATCH 8/9] fix(functions): reject array JWT payloads in claim validation A JWT claims set must be a JSON object; an array payload (e.g. []) was treated as an object and skipped the temporal-claim checks. --- packages/stack/src/services/edge-runtime-main.ts | 7 ++++--- packages/stack/src/services/edge-runtime-main.unit.test.ts | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 8b51bcddde..546406010a 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -154,9 +154,10 @@ export function areClaimsValid(payload: string): boolean { } catch { return false; } - // A valid JWT payload must be a JSON object; reject anything else (e.g. `null` - // or a bare string) rather than dereferencing it and throwing past the gate. - if (typeof claims !== "object" || claims === null) return false; + // A valid JWT payload must be a JSON object; reject anything else (e.g. `null`, + // a bare string, or an array) rather than dereferencing it and throwing past + // the gate or treating a non-claims-set as valid. + if (typeof claims !== "object" || claims === null || Array.isArray(claims)) return false; const { exp, nbf } = claims as { exp?: unknown; nbf?: unknown }; const now = Math.floor(Date.now() / 1000); const skew = 60; diff --git a/packages/stack/src/services/edge-runtime-main.unit.test.ts b/packages/stack/src/services/edge-runtime-main.unit.test.ts index c25f0d002f..c3b3bb4ca1 100644 --- a/packages/stack/src/services/edge-runtime-main.unit.test.ts +++ b/packages/stack/src/services/edge-runtime-main.unit.test.ts @@ -241,6 +241,7 @@ describe("areClaimsValid", () => { it("rejects non-object payloads", () => { expect(areClaimsValid(b64url("null"))).toBe(false); expect(areClaimsValid(b64url(JSON.stringify("a-string")))).toBe(false); + expect(areClaimsValid(b64url(JSON.stringify([])))).toBe(false); }); it("returns false for an undecodable payload", () => { From d2391805d54bfe997a6b9cdacd011c02d358ce05 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 14:30:22 +0000 Subject: [PATCH 9/9] refactor(functions): drop dead local-JWKS branch, document SUPABASE_JWKS gap The CLI-injected JWKS is symmetric-only (oct), so it could never verify an ES256/RS256 token; remove the dead local-JWKS-first branch and verify asymmetric tokens against the auth service's well-known JWKS only. Keep the oct JWKS in SUPABASE_JWKS (correct for the default local HS256 issuer, matching the Go CLI's ResolveJWKS fallback) and add a TODO to also resolve signing_keys_path / third_party keys. Rework the asymmetric unit tests to exercise the remote path. --- .../stack/src/services/edge-runtime-main.ts | 27 ++++------ .../services/edge-runtime-main.unit.test.ts | 54 +++++++++---------- 2 files changed, 34 insertions(+), 47 deletions(-) diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 546406010a..48c8bd94e9 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -105,14 +105,6 @@ function toJwks(value: unknown): Jwks { return { keys: [] }; } -function parseLocalJwks(jwks: string): Jwks { - try { - return toJwks(JSON.parse(jwks)); - } catch { - return { keys: [] }; - } -} - // Cache the well-known JWKS per URL so asymmetric verification does not refetch // `/auth/v1/.well-known/jwks.json` on every request. A short TTL bounds how long // a rotated or revoked signing key can keep verifying against a stale cached set @@ -194,15 +186,11 @@ export async function verifyHybridJwt(config: any, jwt: string) { } if (decodedHeader.alg === "ES256" || decodedHeader.alg === "RS256") { const { alg, kid } = decodedHeader; - // The CLI-injected `config.jwks` is currently symmetric-only (an `oct` - // key derived from jwtSecret), so it never matches an ES256/RS256 token - // today — this local check is future-proofing for when the stack config - // grows asymmetric signing keys. Real asymmetric verification currently - // resolves through the auth service's well-known endpoint below. - const localJwks = parseLocalJwks(config.jwks); - if (await verifyWithJwks(alg, kid, signingInput, signature!, localJwks)) { - return true; - } + // The local auth service signs HS256 with the symmetric secret and does not + // publish asymmetric signing keys, so ES256/RS256 tokens are verified against + // its well-known JWKS (covering keys minted elsewhere). The CLI does not yet + // resolve `auth.signing_keys_path` / `auth.third_party` locally — see the + // SUPABASE_JWKS TODO in serveFunction. const jwksUrl = new URL("/auth/v1/.well-known/jwks.json", config.supabaseUrl).href; const remoteJwks = await fetchRemoteJwks(jwksUrl); return await verifyWithJwks(alg, kid, signingInput, signature!, remoteJwks); @@ -257,6 +245,11 @@ async function serveFunction(req: Request, config: any, functionName: string, fu SUPABASE_DB_URL: config.dbUrl, SUPABASE_PUBLISHABLE_KEYS: JSON.stringify({ default: config.publishableKey }), SUPABASE_SECRET_KEYS: JSON.stringify({ default: config.secretKey }), + // The oct(jwtSecret) JWKS the Server SDK uses to verify user JWTs — correct + // for the default local HS256 issuer. + // TODO: also resolve from auth.signing_keys_path / auth.third_party (like the + // Go CLI's Auth.ResolveJWKS) so SDK `auth: "user"` works for projects + // configured with asymmetric signing keys. SUPABASE_JWKS: config.jwks, }); diff --git a/packages/stack/src/services/edge-runtime-main.unit.test.ts b/packages/stack/src/services/edge-runtime-main.unit.test.ts index c3b3bb4ca1..1a30c92aab 100644 --- a/packages/stack/src/services/edge-runtime-main.unit.test.ts +++ b/packages/stack/src/services/edge-runtime-main.unit.test.ts @@ -86,25 +86,29 @@ describe("verifyHybridJwt — HS256 legacy path", () => { }); describe.each(["ES256", "RS256"] as const)("verifyHybridJwt — %s asymmetric path", (alg) => { - it("accepts a token verified against the local JWKS", async () => { + it("verifies a token against the auth service's well-known JWKS", async () => { const { privateKey, jwk } = await generateAsymmetricKey(alg, "key-1"); const token = await mintAsymmetricJwt({ alg, privateKey, kid: "key-1" }); - const config = { - jwtSecret: JWT_SECRET, - jwks: JSON.stringify({ keys: [jwk] }), - supabaseUrl: SUPABASE_URL, - }; + const fetchMock = vi.fn(async () => Response.json({ keys: [jwk] })); + vi.stubGlobal("fetch", fetchMock); + const supabaseUrl = `http://asym-accept-${alg.toLowerCase()}.test`; + const config = { jwtSecret: JWT_SECRET, jwks: generateJwks(JWT_SECRET), supabaseUrl }; expect(await verifyHybridJwt(config, token)).toBe(true); + expect(fetchMock).toHaveBeenCalledWith(`${supabaseUrl}/auth/v1/.well-known/jwks.json`); }); it("rejects a token signed by a key absent from the JWKS", async () => { const signer = await generateAsymmetricKey(alg, "key-1"); const other = await generateAsymmetricKey(alg, "key-2"); const token = await mintAsymmetricJwt({ alg, privateKey: signer.privateKey, kid: "key-1" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ keys: [other.jwk] })), + ); const config = { jwtSecret: JWT_SECRET, - jwks: JSON.stringify({ keys: [other.jwk] }), - supabaseUrl: SUPABASE_URL, + jwks: generateJwks(JWT_SECRET), + supabaseUrl: `http://asym-absent-${alg.toLowerCase()}.test`, }; expect(await verifyHybridJwt(config, token)).toBe(false); }); @@ -112,10 +116,14 @@ describe.each(["ES256", "RS256"] as const)("verifyHybridJwt — %s asymmetric pa it("excludes keys whose kid does not match the token", async () => { const { privateKey, jwk } = await generateAsymmetricKey(alg, "real-kid"); const token = await mintAsymmetricJwt({ alg, privateKey, kid: "wrong-kid" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ keys: [jwk] })), + ); const config = { jwtSecret: JWT_SECRET, - jwks: JSON.stringify({ keys: [jwk] }), - supabaseUrl: SUPABASE_URL, + jwks: generateJwks(JWT_SECRET), + supabaseUrl: `http://asym-kid-${alg.toLowerCase()}.test`, }; expect(await verifyHybridJwt(config, token)).toBe(false); }); @@ -123,10 +131,14 @@ describe.each(["ES256", "RS256"] as const)("verifyHybridJwt — %s asymmetric pa it("matches keyless JWKS entries when the token carries a kid", async () => { const { privateKey, jwk } = await generateAsymmetricKey(alg); const token = await mintAsymmetricJwt({ alg, privateKey, kid: "any-kid" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ keys: [jwk] })), + ); const config = { jwtSecret: JWT_SECRET, - jwks: JSON.stringify({ keys: [jwk] }), - supabaseUrl: SUPABASE_URL, + jwks: generateJwks(JWT_SECRET), + supabaseUrl: `http://asym-keyless-${alg.toLowerCase()}.test`, }; expect(await verifyHybridJwt(config, token)).toBe(true); }); @@ -159,24 +171,6 @@ describe.each(["ES256", "RS256"] as const)("verifyHybridJwt — %s asymmetric pa expect(await verifyHybridJwt(config, token)).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(2); }); - - it("falls back to the remote well-known JWKS when the local set has no match", async () => { - const { privateKey, jwk } = await generateAsymmetricKey(alg, "remote-kid"); - const token = await mintAsymmetricJwt({ alg, privateKey, kid: "remote-kid" }); - const fetchMock = vi.fn(async () => Response.json({ keys: [jwk] })); - vi.stubGlobal("fetch", fetchMock); - // Unique per alg so the module-level remote JWKS cache (keyed by URL) does - // not leak the other parametrized run's key set into this one. - const supabaseUrl = `http://remote-fallback-${alg.toLowerCase()}.test`; - const config = { - jwtSecret: JWT_SECRET, - // oct-only local set never matches an asymmetric token, forcing fallback. - jwks: generateJwks(JWT_SECRET), - supabaseUrl, - }; - expect(await verifyHybridJwt(config, token)).toBe(true); - expect(fetchMock).toHaveBeenCalledWith(`${supabaseUrl}/auth/v1/.well-known/jwks.json`); - }); }); describe("verifyHybridJwt — claim validation", () => {