Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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;
}
Loading