Skip to content
47 changes: 43 additions & 4 deletions apps/cli-go/internal/functions/serve/templates/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,40 @@ const functionsConfig: Record<string, FunctionConfig> = (() => {
}
})();

/* --- JWT verification --- */
export function extractBearerToken(rawToken: string) {
const tokenParts = rawToken.split(' ')
const [bearer, token] = tokenParts
if (bearer !== 'Bearer' || tokenParts.length !== 2) {
return null
}

return token
}

function getAuthToken(req: Request) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
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");
}
const [bearer, token] = authHeader.split(" ");
if (bearer !== "Bearer") {

// NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match:
// - API proxy mints a temp token
// - Original bearer is not present or is ApiKey
const bearerToken = extractBearerToken(authHeader ?? '')
const token = (!bearerToken || bearerToken.startsWith('sb_'))
? cleanSbApiKeyCompatibilityToken
: bearerToken

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

return token;
}

Expand Down Expand Up @@ -180,6 +205,19 @@ async function shouldUsePackageJsonDiscovery({ entrypointPath, importMapPath }:
return true
}

export function prepareUserRequest(req: Request): Request {
const clonedURL = new URL(req.url)
const forwardedHost = req.headers.get('x-forwarded-host')
clonedURL.hostname = forwardedHost ?? clonedURL.hostname
const clonedReq = new Request(clonedURL, req.clone())

// remove custom api headers
clonedReq.headers.delete('sb-api-key')
EdgeRuntime.applySupabaseTag(req, clonedReq)

return clonedReq
}

Deno.serve({
handler: async (req: Request) => {
const url = new URL(req.url);
Expand Down Expand Up @@ -279,7 +317,8 @@ Deno.serve({
staticPatterns,
});

return await worker.fetch(req);
const userReq = prepareUserRequest(req)
return await worker.fetch(userReq);
} catch (e) {
console.error(e);

Expand Down
4 changes: 2 additions & 2 deletions apps/cli-go/internal/start/templates/kong.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,10 @@ services:
config:
add:
headers:
- "Authorization: {{ .BearerToken }}"
- "sb-api-key: {{ .BearerToken }}"

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.

remark(not-blocking):

this mints on every /functions request regardless of verify_jwt. Harmless now that it goes to sb-api-key and is stripped before the worker (and only read when verify_jwt is on), but it's wider than necessary could scope it later. (Underlying expression is in https://github.com/supabase/cli/blob/develop/apps/cli-go/internal/start/start.go#L451-L463 )

replace:
headers:
- "Authorization: {{ .BearerToken }}"
- "sb-api-key: {{ .BearerToken }}"
# Management API endpoints
- name: well-known-oauth
_comment: "GoTrue: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server"
Expand Down
19 changes: 14 additions & 5 deletions packages/stack/src/ApiProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,29 @@ export interface ProxyConfig {
readonly serviceRoleJwt: string;
}

function transformAuthorization(headers: Headers.Headers, config: ProxyConfig): Headers.Headers {
function transformAuthorization(
headers: Headers.Headers,
config: ProxyConfig,
useCustomHeader = false,
): Headers.Headers {
const auth = headers["authorization"];
const apikey = headers["apikey"];

const transformHeaderName = useCustomHeader ? "sb-api-key" : "authorization";
const transformPrefix = useCustomHeader ? "" : "Bearer ";

if (auth !== undefined && !auth.startsWith("Bearer sb_")) {
return headers;
}

if (apikey === config.publishableKey) {
return Headers.set(headers, "authorization", `Bearer ${config.anonJwt}`);
return Headers.set(headers, transformHeaderName, transformPrefix + config.anonJwt);
}
if (apikey === config.secretKey) {
return Headers.set(headers, "authorization", `Bearer ${config.serviceRoleJwt}`);
return Headers.set(headers, transformHeaderName, transformPrefix + config.serviceRoleJwt);
}
if (apikey !== undefined && apikey !== "") {
return Headers.set(headers, "authorization", apikey);
return Headers.set(headers, transformHeaderName, apikey);
}

return headers;
Expand Down Expand Up @@ -103,6 +110,7 @@ interface ProxyHandlerOptions {
readonly stripPrefix?: string;
readonly backendPath?: string;
readonly transformAuth?: boolean;
readonly transformAuthCustomHeader?: boolean;
readonly extraHeaders?: Record<string, string>;
}

Expand All @@ -126,7 +134,7 @@ function makeProxyHandler(

let outHeaders = req.headers;
if (opts.transformAuth === true) {
outHeaders = transformAuthorization(outHeaders, config);
outHeaders = transformAuthorization(outHeaders, config, opts.transformAuthCustomHeader);
}
outHeaders = addProxyHeaders(outHeaders, Option.getOrUndefined(req.remoteAddress));

Expand Down Expand Up @@ -254,6 +262,7 @@ export class ApiProxy extends Context.Service<
backendPort: config.edgeRuntimePort,
stripPrefix: "/functions/v1",
transformAuth: true,
transformAuthCustomHeader: true,
}),
),
HttpRouter.route(
Expand Down
27 changes: 21 additions & 6 deletions packages/stack/src/ApiProxy.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,13 +223,28 @@ describe("ApiProxy", () => {
expect(body.path).toBe("/users");
});

test("/functions/v1/test strips prefix and transforms auth", async () => {
const res = await fetch(`${proxyUrl}/functions/v1/test`, {
headers: { apikey: SECRET_KEY },
describe("/functions/v1/ test strips prefix and transforms auth", () => {
test("transforms to custom header", async () => {
const res = await fetch(`${proxyUrl}/functions/v1/test`, {
headers: { apikey: SECRET_KEY },
});
const body = (await res.json()) as { path: string; headers: Record<string, string> };
expect(body.path).toBe("/test");
expect(body.headers["sb-api-key"]).toBe(SERVICE_ROLE_JWT);
});

test("transforms to custom header without replacing original auth", async () => {
const res = await fetch(`${proxyUrl}/functions/v1/test`, {
headers: {
apikey: SECRET_KEY,
authorization: `Bearer ${SECRET_KEY}`,
},
});
const body = (await res.json()) as { path: string; headers: Record<string, string> };
expect(body.path).toBe("/test");
expect(body.headers["authorization"]).toBe(`Bearer ${SECRET_KEY}`);
expect(body.headers["sb-api-key"]).toBe(SERVICE_ROLE_JWT);
});
const body = (await res.json()) as { path: string; headers: Record<string, string> };
expect(body.path).toBe("/test");
expect(body.headers["authorization"]).toBe(`Bearer ${SERVICE_ROLE_JWT}`);
});

test("strips upstream content-encoding metadata from proxied function responses", async () => {
Expand Down
21 changes: 18 additions & 3 deletions packages/stack/src/services/edge-runtime-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ async function isValidLocalJwt(secret: string, jwt: string) {
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",
Expand All @@ -59,11 +62,23 @@ async function isValidLocalJwt(secret: string, jwt: string) {

async function verifyRequest(req: Request, config: any, functionConfig: any) {
if (!functionConfig.verifyJWT || req.method === "OPTIONS") return null;
const authHeader = req.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
const bearerToken = req.headers.get("authorization")?.slice("Bearer ".length);
const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key")?.replace("Bearer", "")?.trim();

if (!bearerToken && !sbApiKeyCompatibilityToken) {
return Response.json({ msg: "Missing authorization header" }, { status: 401 });
}
const token = authHeader.slice("Bearer ".length);

// NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match:
// - API proxy mints a temp token
// - Original bearer is not present or is ApiKey
const token =
!bearerToken || bearerToken.startsWith("sb_") ? sbApiKeyCompatibilityToken : bearerToken;

if (!token) {
return Response.json({ msg: "Auth header is not 'Bearer {token}'" }, { status: 401 });
}

try {
if (await isValidLocalJwt(config.jwtSecret, token)) return null;
} catch (error) {
Expand Down
Loading