Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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: 2 additions & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"@typescript/native-preview",
"oxfmt",
"oxlint",
"oxlint-tsgolint"
"oxlint-tsgolint",
"jsr"
],
"ignoreBinaries": [
"nx",
Expand Down
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
98 changes: 61 additions & 37 deletions packages/stack/src/services/edge-runtime-main.ts
Original file line number Diff line number Diff line change
@@ -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<Jose> | 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.",
Expand All @@ -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);
Comment thread
avallete marked this conversation as resolved.
Outdated
return true;
} catch (error) {
console.error("Asymmetric JWT verification failed", error);
return false;
}
return result === 0;
}
Comment thread
avallete marked this conversation as resolved.

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) {
Expand All @@ -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 });
}

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

try {
Expand Down
11 changes: 11 additions & 0 deletions packages/stack/src/services/jose-jsr.d.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => Promise<CryptoKey>;
export function decodeProtectedHeader(token: string): { readonly alg?: string };
export function jwtVerify(jwt: string, key: Uint8Array | JwksResolver): Promise<unknown>;
export function createLocalJWKSet(jwks: { readonly keys: ReadonlyArray<unknown> }): JwksResolver;
export function createRemoteJWKSet(url: URL): JwksResolver;
}
31 changes: 30 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 { 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 @@
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,33 @@
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");
// verify_jwt defaults to true, so this turns verification back on.
await stack.reloadFunctions();
Comment thread
avallete marked this conversation as resolved.
Outdated
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);

Check failure on line 107 in packages/stack/tests/createStack.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Run end-to-end tests (shard 1/3)

[e2e] tests/createStack.e2e.test.ts > createStack e2e > enforces hybrid JWT verification on Edge Functions

AssertionError: expected 200 to be 401 // Object.is equality - Expected + Received - 401 + 200 ❯ tests/createStack.e2e.test.ts:107:28

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