Skip to content
Open
Show file tree
Hide file tree
Changes from all 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

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/cli-go/internal/start/templates/kong.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ services:
- /functions/v1/
plugins:
- name: cors
config:
exposed_headers:
- sb-error-code
Comment on lines +181 to +183

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue

Thanks for exposing sb-error-code, but configuring it globally in Kong introduces a CORS regression (found out while dogfooding locally).

Access-Control-Expose-Headers is the browser’s permission list for response headers. A user function may return:

  X-Custom-Id: abc123
  Access-Control-Expose-Headers: X-Custom-Id

This allows browser code to read:

  response.headers.get("X-Custom-Id")

With:

  config:
    exposed_headers:
      - sb-error-code

Kong uses set_header, not append/merge. It replaces the upstream value, producing:

  X-Custom-Id: abc123
  Access-Control-Expose-Headers: sb-error-code

X-Custom-Id still travels over the network, but browser JavaScript can no longer read it. Because this CORS plugin applies to the entire Functions service, it affects successful and user-defined responses not only Supabase JWT errors.

A safer fix is to remove the global Kong exposed_headers configuration and add: Access-Control-Expose-Headers: sb-error-code directly to the runtime-owned JWT error response. That exposes sb-error-code only where needed while preserving user functions’ CORS headers.

src/shared/functions/serve.main.ts, inside getAuthErrorResponse:

  function getAuthErrorResponse({ code, message }: AuthFailure) {
    return getResponse({ code, message, msg: message }, STATUS_CODE.Unauthorized, {
      "sb-error-code": code,
      "Access-Control-Expose-Headers": "sb-error-code",
    });
  }

This confines the exposure permission to Supabase-generated JWT errors.

Could we also add a behavioral regression test through Kong? The current auth E2E calls edge-runtime directly, so it cannot detect this. The test should verify:

  1. A user-function response with Access-Control-Expose-Headers: X-Custom still exposes X-Custom after passing through Kong.
  2. A JWT rejection passing through Kong exposes sb-error-code and allows browser clients to read it.

That would protect both the new error header and existing user-defined headers from future regressions, since it's a tricky one to spot.

- name: request-transformer
config:
add:
Expand Down
133 changes: 133 additions & 0 deletions apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,58 @@ function hasDocker(): boolean {
const dockerAvailable = hasDocker();
const SERVE_OFFLINE_STARTUP_TIMEOUT_MS = 60_000;
const SERVE_OFFLINE_TEST_TIMEOUT_MS = 120_000;
const AUTH_FUNCTIONS_CONFIG = JSON.stringify({
test: {
entrypointPath: "/tmp/test/index.ts",
importMapPath: "",
staticFiles: [],
verifyJWT: true,
},
});

function jwtWithInvalidSignature(algorithm?: string): string {
const header = Buffer.from(JSON.stringify({ alg: algorithm, typ: "JWT" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ sub: "test-user" })).toString("base64url");
return `${header}.${payload}.invalid`;
}

const authFailureCases = [
{
name: "missing authorization",
code: "UNAUTHORIZED_NO_AUTH_HEADER",
message: "Missing authorization header",
},
{
name: "invalid JWT format",
authorization: "Bearer not-a-jwt",
code: "UNAUTHORIZED_INVALID_JWT_FORMAT",
message: "Invalid JWT format",
},
{
name: "missing JWT algorithm",
authorization: `Bearer ${jwtWithInvalidSignature()}`,
code: "UNAUTHORIZED_INVALID_JWT_FORMAT",
message: "Invalid JWT format",
},
{
name: "invalid legacy JWT",
authorization: `Bearer ${jwtWithInvalidSignature("HS256")}`,
code: "UNAUTHORIZED_LEGACY_JWT",
message: "Invalid JWT",
},
{
name: "invalid asymmetric JWT",
authorization: `Bearer ${jwtWithInvalidSignature("ES256")}`,
code: "UNAUTHORIZED_ASYMMETRIC_JWT",
message: "Invalid JWT",
},
{
name: "unsupported JWT algorithm",
authorization: `Bearer ${jwtWithInvalidSignature("none")}`,
code: "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM",
message: "Unsupported JWT algorithm none",
},
];

function containerLogs(container: string): string {
const result = spawnSync("docker", ["logs", container], { encoding: "utf8" });
Expand Down Expand Up @@ -102,4 +154,85 @@ describe("functions serve runtime template (offline)", () => {
}
},
);

test.skipIf(!dockerAvailable)(
"returns canonical JWT auth failures",
{ timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS },
async () => {
const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-"));
const container = `supabase-serve-auth-e2e-${process.pid.toString()}`;
try {
await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate());

const run = spawnSync(
"docker",
[
"run",
"-d",
"--name",
container,
"-p",
"127.0.0.1::8081",
"-e",
"SUPABASE_INTERNAL_HOST_PORT=8081",
"-e",
"SUPABASE_INTERNAL_JWT_SECRET=auth-e2e",
"-e",
"SUPABASE_URL=http://127.0.0.1:54321",
"-e",
`SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${AUTH_FUNCTIONS_CONFIG}`,
"-e",
"SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400",
"-e",
'SUPABASE_JWKS={"keys":[]}',
"-v",
`${dir}:/app:ro`,
"--entrypoint",
"edge-runtime",
LEGACY_EDGE_RUNTIME_IMAGE,
"start",
"--main-service=/app",
"--port=8081",
],
{ encoding: "utf8" },
);
expect(run.status, run.stderr).toBe(0);

const portResult = spawnSync("docker", ["port", container, "8081/tcp"], {
encoding: "utf8",
});
expect(portResult.status, portResult.stderr).toBe(0);
const port = Number(portResult.stdout.trim().split(":").at(-1));
expect(port).toBeGreaterThan(0);
const url = `http://127.0.0.1:${port}/test`;

const deadline = Date.now() + SERVE_OFFLINE_STARTUP_TIMEOUT_MS;
let ready = false;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.status === 401) {
ready = true;
break;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, 250));
}
expect(ready, containerLogs(container)).toBe(true);

for (const { name, authorization, code, message } of authFailureCases) {
const response = await fetch(url, {
headers: authorization === undefined ? undefined : { authorization },
});
expect(response.status, name).toBe(401);
expect(response.headers.get("content-type"), name).toContain("application/json");
expect(response.headers.get("sb-error-code"), name).toBe(code);
expect(await response.json(), name).toEqual({ code, message, msg: message });
}
} finally {
spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
await rm(dir, { recursive: true, force: true });
}
},
);
});
90 changes: 73 additions & 17 deletions apps/cli/src/shared/functions/serve.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,41 @@ const DENO_SB_ERROR_MAP = new Map([
[Deno.errors.WorkerRequestCancelled, SB_SPECIFIC_ERROR_CODE.WorkerLimit],
]);
const GENERIC_FUNCTION_SERVE_MESSAGE = `Serving functions on http://127.0.0.1:${HOST_PORT}/functions/v1/<function-name>`;
const AUTH_ERROR_CODE = {
MissingAuthHeader: "UNAUTHORIZED_NO_AUTH_HEADER",
InvalidJwtFormat: "UNAUTHORIZED_INVALID_JWT_FORMAT",
LegacyJwt: "UNAUTHORIZED_LEGACY_JWT",
AsymmetricJwt: "UNAUTHORIZED_ASYMMETRIC_JWT",
UnsupportedTokenAlgorithm: "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM",
};

interface AuthFailure {
code: string;
message: string;
}

const AUTH_FAILURE = {
MissingAuthHeader: {
code: AUTH_ERROR_CODE.MissingAuthHeader,
message: "Missing authorization header",
},
InvalidJwtFormat: {
code: AUTH_ERROR_CODE.InvalidJwtFormat,
message: "Invalid JWT format",
},
LegacyJwt: {
code: AUTH_ERROR_CODE.LegacyJwt,
message: "Invalid JWT",
},
AsymmetricJwt: {
code: AUTH_ERROR_CODE.AsymmetricJwt,
message: "Invalid JWT",
},
UnsupportedTokenAlgorithm: (algorithm: string) => ({
code: AUTH_ERROR_CODE.UnsupportedTokenAlgorithm,
message: `Unsupported JWT algorithm ${algorithm}`,
}),
};

interface FunctionConfig {
entrypointPath: string;
Expand Down Expand Up @@ -74,6 +109,12 @@ function getResponse(payload: any, status: number, customHeaders = {}) {
return new Response(body, { status, headers });
}

function getAuthErrorResponse({ code, message }: AuthFailure) {
return getResponse({ code, message, msg: message }, STATUS_CODE.Unauthorized, {
Comment thread
7ttp marked this conversation as resolved.
Comment thread
7ttp marked this conversation as resolved.
"sb-error-code": code,
});
}

const functionsConfig: Record<string, FunctionConfig> = (() => {
try {
const functionsConfig = JSON.parse(FUNCTIONS_CONFIG_STRING);
Expand All @@ -99,15 +140,15 @@ export function extractBearerToken(rawToken: string) {
return token;
}

function getAuthToken(req: Request) {
function getAuthToken(req: Request): string | AuthFailure {
const authHeader = req.headers.get("authorization");
const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key");

// NOTE:(kallebysantos) Kong on legacy CLI stack pass it down as 'Bearer Token' format
const cleanSbApiKeyCompatibilityToken = sbApiKeyCompatibilityToken?.replace("Bearer", "")?.trim();

if (!authHeader && !cleanSbApiKeyCompatibilityToken) {
throw new Error("Missing authorization header");
return AUTH_FAILURE.MissingAuthHeader;
}

// NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match:
Expand All @@ -118,22 +159,22 @@ function getAuthToken(req: Request) {
!bearerToken || bearerToken.startsWith("sb_") ? cleanSbApiKeyCompatibilityToken : bearerToken;

if (!token) {
throw new Error(`Auth header is not 'Bearer {token}'`);
return AUTH_FAILURE.InvalidJwtFormat;
}

return token;
}

async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise<boolean> {
async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise<AuthFailure | null> {
const encoder = new TextEncoder();
const secretKey = encoder.encode(jwtSecret);
try {
await jose.jwtVerify(jwt, secretKey);
} catch (e) {
console.error("Symmetric Legacy JWT verification error", e);
return false;
return AUTH_FAILURE.LegacyJwt;
}
return true;
return null;
}

// Lazy-loading JWKs
Expand All @@ -146,7 +187,7 @@ let jwks = (() => {
}
})();

async function isValidJWT(jwksUrl: URL, jwt: string): Promise<boolean> {
async function isValidJWT(jwksUrl: URL, jwt: string): Promise<AuthFailure | null> {
try {
if (!jwks) {
// Loading from remote-url on fly
Expand All @@ -155,9 +196,9 @@ async function isValidJWT(jwksUrl: URL, jwt: string): Promise<boolean> {
await jose.jwtVerify(jwt, jwks);
} catch (e) {
console.error("Asymmetric JWT verification error", e);
return false;
return AUTH_FAILURE.AsymmetricJwt;
}
return true;
return null;
}

/**
Expand All @@ -168,8 +209,18 @@ export async function verifyHybridJWT(
jwtSecret: string,
jwksUrl: URL,
jwt: string,
): Promise<boolean> {
const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt);
): Promise<AuthFailure | null> {
let jwtAlgorithm: string | undefined;
try {
jwtAlgorithm = jose.decodeProtectedHeader(jwt).alg;
} catch (e) {
console.error("JWT format error", e);
return AUTH_FAILURE.InvalidJwtFormat;
}

if (!jwtAlgorithm) {
return AUTH_FAILURE.InvalidJwtFormat;
}

if (jwtAlgorithm === "HS256") {
console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`);
Expand All @@ -181,7 +232,7 @@ export async function verifyHybridJWT(
return await isValidJWT(jwksUrl, jwt);
}

return false;
return AUTH_FAILURE.UnsupportedTokenAlgorithm(jwtAlgorithm);
Comment thread
7ttp marked this conversation as resolved.
}

// Ref: https://docs.deno.com/examples/checking_file_existence/
Expand Down Expand Up @@ -242,14 +293,19 @@ Deno.serve({
if (req.method !== "OPTIONS" && functionsConfig[functionName].verifyJWT) {
try {
const token = getAuthToken(req);
const isValidJWT = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token);

if (!isValidJWT) {
return getResponse({ msg: "Invalid JWT" }, STATUS_CODE.Unauthorized);
if (typeof token !== "string") {
return getAuthErrorResponse(token);
}
const authFailure = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token);
if (authFailure) {
return getAuthErrorResponse(authFailure);
}
} catch (e) {
console.error(e);
return getResponse({ msg: e.toString() }, STATUS_CODE.Unauthorized);
return getAuthErrorResponse({
code: AUTH_ERROR_CODE.InvalidJwtFormat,
message: "Invalid JWT format",
});
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/stack/src/ApiProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const CORS_HEADERS: ReadonlyArray<readonly [string, string]> = [
["access-control-allow-origin", "*"],
["access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"],
["access-control-allow-headers", "Authorization, Content-Type, apikey, X-Client-Info"],
["access-control-expose-headers", "Content-Range, Range"],
["access-control-expose-headers", "Content-Range, Range, sb-error-code"],
["access-control-max-age", "86400"],
];

Expand Down
1 change: 1 addition & 0 deletions packages/stack/src/ApiProxy.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ describe("ApiProxy", () => {
expect(res.headers.get("access-control-allow-methods")).toContain("GET");
expect(res.headers.get("access-control-allow-headers")).toContain("apikey");
expect(res.headers.get("access-control-expose-headers")).toContain("Content-Range");
expect(res.headers.get("access-control-expose-headers")).toContain("sb-error-code");
expect(res.headers.get("access-control-max-age")).toBe("86400");
});

Expand Down
Loading
Loading