import * as jose from 'jsr:@panva/jose@6' console.log('main function started') const MAX_WORKER_RETRIES = 3 const JWT_SECRET = Deno.env.get('JWT_SECRET') const SUPABASE_JWKS = parseJwks(Deno.env.get('SUPABASE_JWKS')) const LOCAL_JWKS = SUPABASE_JWKS ? jose.createLocalJWKSet(SUPABASE_JWKS) : null const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' type AuthFailure = { code: RequestErrors message?: string } type FunctionFailure = { code: RequestErrors message: string status: number } export enum RequestErrors { InvalidLegacyJWT = 'UNAUTHORIZED_LEGACY_JWT', InvalidAsymmetricJWT = 'UNAUTHORIZED_ASYMMETRIC_JWT', InvalidTokenFormat = 'UNAUTHORIZED_INVALID_JWT_FORMAT', UnsupportedTokenAlgorithm = 'UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM', MissingAuthHeader = 'UNAUTHORIZED_NO_AUTH_HEADER', NotFound = 'NOT_FOUND', BootError = 'BOOT_ERROR', EdgeFunctionError = 'EDGE_FUNCTION_ERROR', IdleTimeout = 'IDLE_TIMEOUT', WorkerResourceLimit = 'WORKER_RESOURCE_LIMIT', WorkerError = 'WORKER_ERROR', InvalidResponseStatusCode = 'INVALID_RESPONSE_STATUS_CODE', } function getFunctionErrorResponse({ code, message, status }: FunctionFailure): Response { return Response.json( { code, message }, { status, headers: { 'sb-error-code': code, 'Access-Control-Expose-Headers': 'sb-error-code', }, } ) } function handleWorkerResponse(response: Response): Response { if (response.status < 500) return response const headers = new Headers(response.headers) headers.set('sb-error-code', RequestErrors.EdgeFunctionError) const exposedHeaders = (headers.get('Access-Control-Expose-Headers') ?? '') .split(',') .map((name) => name.trim()) .filter(Boolean) if (!exposedHeaders.some((name) => name.toLowerCase() === 'sb-error-code')) { exposedHeaders.push('sb-error-code') } headers.set('Access-Control-Expose-Headers', exposedHeaders.join(', ')) return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }) } function resolveRuntimeError(e: unknown): FunctionFailure { // These error classes are supplied by Edge Runtime, rather than stock Deno. if (e instanceof Deno.errors.InvalidWorkerCreation) { return { code: RequestErrors.BootError, message: 'Function failed to start (please check logs)', status: 503, } } if (e instanceof Deno.errors.WorkerRequestCancelled) { return { code: RequestErrors.WorkerResourceLimit, message: 'Function failed due to not having enough compute resources (please check logs)', status: 546, } } if (e instanceof Deno.errors.WorkerRequestIdleTimeout) { return { code: RequestErrors.IdleTimeout, message: 'Request idle timeout limit (150s) reached', status: 504, } } // No dedicated runtime error class exists for invalid response statuses. // The Response constructor throws directly here or inside the user worker. if ( (e instanceof RangeError || e instanceof Deno.errors.InvalidWorkerResponse) && e.message.includes('is not equal to 101 and outside the range [200, 599]') ) { return { code: RequestErrors.InvalidResponseStatusCode, message: 'Function returned an invalid HTTP status code (please check logs)', status: 500, } } if ( e instanceof Deno.errors.WorkerAlreadyRetired || e instanceof Deno.errors.InvalidWorkerResponse ) { return { code: RequestErrors.WorkerError, message: 'Function exited due to an error (please check logs)', status: 500, } } return { code: RequestErrors.EdgeFunctionError, message: 'Internal Server Error', status: 500 } } // NOTE:(kallebysantos) We don't check for valid keys but just the bare array parsing, // let this for 'jose' lib verification export function parseJwks(raw: string | undefined): jose.JSONWebKeySet | null { if (!raw) return null try { const parsed = JSON.parse(raw) if (parsed?.keys && Array.isArray(parsed.keys)) { return parsed as jose.JSONWebKeySet } return null } catch { return null } } /** * Extract JWT token from 'Authorization' header or fallback to 'sb-api-key' compatibility * * Parses the Authorization header to extract the Bearer token. * Expects format: "Bearer " * * @param req - The HTTP request object * @returns The JWT token string or an authentication failure */ function extractBearerToken(authHeader: string | null): string | null { const tokenParts = (authHeader ?? '').trim().split(/\s+/) const [bearer, token] = tokenParts if (bearer.toLowerCase() !== 'bearer' || tokenParts.length !== 2 || !token) { return null } return token } function getAuthToken(req: Request): string | AuthFailure { const authHeader = req.headers.get('authorization') const sbApiKeyCompatibilityToken = req.headers.get('sb-api-key') if (!authHeader && !sbApiKeyCompatibilityToken) { return { code: RequestErrors.MissingAuthHeader, message: 'Missing authorization header', } } // 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_') ? sbApiKeyCompatibilityToken : bearerToken if (!token) { return { code: RequestErrors.InvalidTokenFormat, message: 'Invalid JWT format', } } return token } function getAuthErrorResponse({ code, message = 'Invalid JWT' }: AuthFailure) { return Response.json( { code, message, // DEPRECATED: Retained for backward compatibility. msg: message, }, { status: 401, headers: { 'sb-error-code': code, 'Access-Control-Expose-Headers': 'sb-error-code', }, } ) } async function isValidLegacyJWT(jwt: string): Promise { if (!JWT_SECRET) { console.error('JWT_SECRET not available for HS256 token verification') return { code: RequestErrors.InvalidLegacyJWT } } const encoder = new TextEncoder(); const secretKey = encoder.encode(JWT_SECRET); try { await jose.jwtVerify(jwt, secretKey); } catch (e) { console.error('Symmetric Legacy JWT verification error', e); return { code: RequestErrors.InvalidLegacyJWT } } return null } async function isValidJWT(jwt: string): Promise { if (!LOCAL_JWKS) { console.error('JWKS not available for ES256/RS256 token verification') return { code: RequestErrors.InvalidAsymmetricJWT } } try { await jose.jwtVerify(jwt, LOCAL_JWKS); } catch (e) { console.error('Asymmetric JWT verification error', e); return { code: RequestErrors.InvalidAsymmetricJWT } } return null } /** * Verify JWT token, handling both legacy (HS256) and newer (ES256/RS256) algorithms * * This function automatically detects the algorithm used in the token and applies * the appropriate verification method: * - HS256: Uses JWT_SECRET (symmetric key) * - ES256/RS256: Uses JWKS endpoint (asymmetric public keys) * * This fix ensures compatibility with both legacy tokens and newer asymmetric tokens, * resolving the "Key for the ES256 algorithm must be of type CryptoKey" error. * * @param jwt - The JWT token string to verify * @returns Authentication failure details, or null when verification succeeds */ async function isValidHybridJWT(jwt: string): Promise { let jwtAlgorithm: string | undefined try { jwtAlgorithm = jose.decodeProtectedHeader(jwt).alg } catch (e) { console.error('JWT format error', e) return { code: RequestErrors.InvalidTokenFormat, message: 'Invalid JWT format', } } if (!jwtAlgorithm) { return { code: RequestErrors.InvalidTokenFormat, message: 'Invalid JWT format', } } if (jwtAlgorithm === 'HS256') { console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`) return await isValidLegacyJWT(jwt) } if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') { return await isValidJWT(jwt) } return { code: RequestErrors.UnsupportedTokenAlgorithm, message: `Unsupported JWT algorithm ${jwtAlgorithm}`, } } Deno.serve(async (req: Request) => { if (req.method !== 'OPTIONS' && VERIFY_JWT) { try { const token = getAuthToken(req) if (typeof token !== 'string') { return getAuthErrorResponse(token) } const authFailure = await isValidHybridJWT(token) if (authFailure) { return getAuthErrorResponse(authFailure) } } catch (e) { console.error(e) return getAuthErrorResponse({ code: RequestErrors.InvalidTokenFormat, message: 'Invalid JWT format', }) } } const url = new URL(req.url) const { pathname } = url const path_parts = pathname.split('/') const service_name = path_parts[1] if (!service_name || service_name === '') { return getFunctionErrorResponse({ code: RequestErrors.NotFound, message: 'Requested function was not found', status: 404, }) } const servicePath = `/home/deno/functions/${service_name}` console.error(`serving the request with ${servicePath}`) try { const serviceInfo = await Deno.stat(servicePath) if (!serviceInfo.isDirectory) { return getFunctionErrorResponse({ code: RequestErrors.NotFound, message: 'Requested function was not found', status: 404, }) } } catch (e) { if (e instanceof Deno.errors.NotFound) { return getFunctionErrorResponse({ code: RequestErrors.NotFound, message: 'Requested function was not found', status: 404, }) } console.error(e) return getFunctionErrorResponse({ code: RequestErrors.BootError, message: 'Function failed to start (please check logs)', status: 503, }) } const memoryLimitMb = 150 // Keep the wall clock above the 150s request idle timeout configured in Compose. const workerTimeoutMs = 400_000 const requestAbsentTimeoutMs = 60_000 const noModuleCache = false // Using a common Import Map for all functions // to use a scope 'deno.json' it must be dinamically resolved base on the 'service_name' const importMapPath = `/home/deno/functions/deno.jsonc` // SUPABASE_FUNCTION_SLUG is listed after the container env snapshot so // nothing in it can shadow the value, and it is per-request because only this // worker knows which function the request resolved to. const envVarsObj = { ...Deno.env.toObject(), SUPABASE_FUNCTION_SLUG: service_name } const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) const callWorker = async (req: Request, retriesLeft = MAX_WORKER_RETRIES): Promise => { // Preserve the body before fetch() can consume it, even on a failed attempt. // Must run before `new Request(req)` below, which takes over the body. // The unread retry branch can buffer the entire body in main-worker memory, // even when the first attempt succeeds. const retryReq = retriesLeft > 0 ? req.clone() : null try { const worker = await EdgeRuntime.userWorkers.create({ servicePath, memoryLimitMb, workerTimeoutMs, context: { supervisor: { requestAbsentTimeoutMs } }, noModuleCache, importMapPath, envVars, }) // Gateway-minted internal JWT is for this router only; never expose it to user functions. const userReq = new Request(req) userReq.headers.delete('sb-api-key') EdgeRuntime.applySupabaseTag(req, userReq) return handleWorkerResponse(await worker.fetch(userReq)) } catch (e) { // Retirement rejects before dispatch, so user code has not run yet. if (e instanceof Deno.errors.WorkerAlreadyRetired && retryReq) { console.warn(`${service_name}: worker retired before dispatch; retrying (${retriesLeft} left)`) // Request.clone() does not copy the tag that connects streaming to the client. EdgeRuntime.applySupabaseTag(req, retryReq) return await callWorker(retryReq, retriesLeft - 1) } console.error(e) return getFunctionErrorResponse(resolveRuntimeError(e)) } } return await callWorker(req) })