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..48c8bd94e9 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -27,37 +27,178 @@ function base64UrlToBytes(value: string) { return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); } -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]!; - } - return result === 0; +interface Jwk { + readonly kty?: string; + readonly kid?: string; } -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!))); +interface Jwks { + readonly keys: ReadonlyArray; +} - // 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; +// 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, - ["sign"], + ["verify"], ); - const signed = await crypto.subtle.sign( + return crypto.subtle.verify( "HMAC", key, - new TextEncoder().encode(`${header}.${payload}`), + base64UrlToBytes(signature), + new TextEncoder().encode(signingInput), + ); +} + +export 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); + // 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 === undefined || key.kid === kid), ); - return bytesEqual(new Uint8Array(signed), base64UrlToBytes(signature!)); + 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 { + // 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; +} + +function toJwks(value: unknown): Jwks { + if (value !== null && typeof value === "object" && "keys" in value && Array.isArray(value.keys)) { + return { keys: value.keys }; + } + 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 +// 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 && 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, 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((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 (parsed.keys.length === 0) remoteJwksCache.delete(jwksUrl); + return parsed; + }); + 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: 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`, + // 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; + // 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; +} + +// 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. +export async function verifyHybridJwt(config: any, jwt: string) { + 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 { + decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(header!))); + } catch (error) { + console.error("Failed to decode JWT header", error); + 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; + // 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); + } + } catch (error) { + console.error("JWT verification failed", error); + } + return false; } async function verifyRequest(req: Request, config: any, functionConfig: any) { @@ -79,11 +220,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 +245,12 @@ 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, }); try { 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..1a30c92aab --- /dev/null +++ b/packages/stack/src/services/edge-runtime-main.unit.test.ts @@ -0,0 +1,244 @@ +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(); + vi.restoreAllMocks(); +}); + +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("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 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: generateJwks(JWT_SECRET), + supabaseUrl: `http://asym-absent-${alg.toLowerCase()}.test`, + }; + 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" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ keys: [jwk] })), + ); + const config = { + jwtSecret: JWT_SECRET, + jwks: generateJwks(JWT_SECRET), + supabaseUrl: `http://asym-kid-${alg.toLowerCase()}.test`, + }; + 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" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ keys: [jwk] })), + ); + const config = { + jwtSecret: JWT_SECRET, + jwks: generateJwks(JWT_SECRET), + supabaseUrl: `http://asym-keyless-${alg.toLowerCase()}.test`, + }; + 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); + }); +}); + +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); + }); + + 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", () => { + 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("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); + expect(areClaimsValid(b64url(JSON.stringify([])))).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 887368503a..c2f0ccc676 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,38 @@ 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"); + // 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. + 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"); + + // 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( "supports the auth signup and session golden path", { timeout: STACK_E2E_TEST_TIMEOUT_MS },