Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/stack/src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,6 +28,7 @@ export interface FunctionsRuntimeConfig {
readonly publishableKey: string;
readonly secretKey: string;
readonly jwtSecret: string;
readonly jwks: string;
readonly env: Readonly<Record<string, string>>;
readonly functions: Readonly<
Record<
Expand Down Expand Up @@ -198,6 +200,7 @@ export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* (
publishableKey: stackConfig.publishableKey,
secretKey: stackConfig.secretKey,
jwtSecret: stackConfig.jwtSecret,
jwks: generateJwks(stackConfig.jwtSecret),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build SUPABASE_JWKS from project auth config

For projects that configure asymmetric auth inputs such as auth.signing_keys_path or auth.third_party (both are loaded by @supabase/config), this writes a JWKS derived only from the legacy jwtSecret. As a result, ES256/RS256 tokens signed by the configured local signing key or third-party provider are absent from SUPABASE_JWKS; the fallback only asks the local GoTrue JWKS endpoint, not the configured provider/file, so Edge Functions reject tokens that the hybrid verifier is meant to support. Resolve the JWKS from the loaded project auth config rather than always calling generateJwks(stackConfig.jwtSecret).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one open as a known limitation rather than fixing it in this PR, because the local stack doesn't yet support asymmetric signing locally:

  • The local auth service is configured with the symmetric secret only — StackBuilder passes version, port, siteUrl, jwtExpiry, and externalUrl to the auth service, but not auth.signing_keys_path or auth.third_party. So local GoTrue mints HS256 tokens signed with jwtSecret, and generateJwks(stackConfig.jwtSecret) is the correct local key set for them.
  • Since no asymmetric key is wired into the local issuer, putting signing_keys_path/third_party keys into SUPABASE_JWKS wouldn't match any locally-minted token today.
  • Externally-minted asymmetric tokens are still handled by the remote /auth/v1/.well-known/jwks.json fallback for keys the local auth service is aware of.

Properly sourcing SUPABASE_JWKS from the configured signing keys / third-party providers requires first plumbing those into the local auth service so it actually signs with them — that's a larger, separate change. Happy to file a follow-up issue if you'd like to track it.


Generated by Claude Code

Comment thread
jgoux marked this conversation as resolved.
env,
functions: Object.fromEntries(
enabledManifest.map(([slug, config]) => [
Expand Down
4 changes: 4 additions & 0 deletions packages/stack/src/functions.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))),
Expand Down
148 changes: 124 additions & 24 deletions packages/stack/src/services/edge-runtime-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,37 +27,140 @@ 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<Jwk>;
}

// 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),
);
return bytesEqual(new Uint8Array(signed), base64UrlToBytes(signature!));
}

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 {
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;
Comment thread
avallete marked this conversation as resolved.
}

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 {
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<string, Promise<Jwks>>();

function fetchRemoteJwks(jwksUrl: string): Promise<Jwks> {
const cached = remoteJwksCache.get(jwksUrl);
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
if (cached) return cached;
Comment thread
avallete marked this conversation as resolved.
Outdated
const pending = fetch(jwksUrl)
.then((res) => res.json())
.then(toJwks);
pending.catch(() => remoteJwksCache.delete(jwksUrl));
remoteJwksCache.set(jwksUrl, pending);
return pending;
}
Comment thread
avallete marked this conversation as resolved.

// 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 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;
}

try {
if (decodedHeader.alg === "HS256") {
Comment thread
avallete marked this conversation as resolved.
Outdated
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;
}
Comment thread
avallete marked this conversation as resolved.
Outdated
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) {
Expand All @@ -79,11 +182,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 });
}

Expand All @@ -108,6 +207,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,
Comment thread
avallete marked this conversation as resolved.
});

try {
Expand Down
32 changes: 31 additions & 1 deletion packages/stack/tests/createStack.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 },
});

Expand Down Expand Up @@ -94,6 +96,34 @@ describe("createStack e2e", () => {
expect(await res.text()).toBe("later");
});

test("enforces hybrid JWT verification on Edge Functions", { timeout: 20_000 }, async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This e2e name says “hybrid JWT verification,” but the scenario only covers missing credentials, forged HS256, valid HS256, and the apikey compatibility path. The ES256/RS256 branch is only covered in unit tests with a mocked JWKS fetch, so we still do not have runtime proof that the actual edge-runtime + gateway + auth JWKS path works. Please add an asymmetric runtime smoke test or rename/scope this e2e to the HS256 legacy path it actually exercises.

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 });
Comment thread
avallete marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After this flips the shared stack to noVerifyJwt: false, any assertion failure before the restore at the end leaves JWT verification enabled for the remaining tests in this file. Please wrap the assertions in try/finally and restore noVerifyJwt: true in the finally block so later tests do not inherit hidden state after a failure.

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 },
Expand Down
Loading