Files
supabase/docker/volumes/functions/main/index.ts
T

397 lines
12 KiB
TypeScript

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 <token>"
*
* @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<AuthFailure | null> {
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<AuthFailure | null> {
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<AuthFailure | null> {
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<Response> => {
// 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)
})