From 1cffe632e3e53e92a6bdc2dd030d180116f809ec Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:36:36 +0530 Subject: [PATCH] fix: webhook apikey (#47317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR Database webhooks/Cron jobs now add `apikey: ` for edge function auth.. ## ref: - related to: https://github.com/supabase/supabase/pull/46890 - towards COM-269 ## Summary by CodeRabbit ## New Features * Improved edge function webhook authentication by automatically selecting the appropriate API key or authorization header format. * Authorization headers are now added or normalized when required, while preserving existing custom headers and supported credentials. ## Improvements * Simplified “Add header” and “Add parameter” controls with clearer labels. * Updated authentication actions to clearly describe the selected header type. ## Tests * Expanded coverage for key formats, authorization behavior, header preservation, and revised control labels. --------- Co-authored-by: Tomás Pozo Co-authored-by: Joshen Lim --- .../Database/Hooks/FormContents.tsx | 38 ++--- .../interfaces/Database/Hooks/HTTPHeaders.tsx | 3 +- .../Database/Hooks/HTTPParameters.test.tsx | 2 +- .../Database/Hooks/HTTPParameters.tsx | 2 +- .../Functions/httpHeaderAddActions.test.ts | 153 ++++++++++++++++-- .../Functions/httpHeaderAddActions.ts | 94 ++++++++--- .../CronJobs/HttpHeaderFieldsSection.tsx | 3 +- e2e/studio/features/database-webhooks.spec.ts | 2 +- 8 files changed, 232 insertions(+), 65 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx index 84fdc1d909e..00b586b202b 100644 --- a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx @@ -27,12 +27,13 @@ import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants' import { HTTPHeaders } from './HTTPHeaders' import { HTTPParameters } from './HTTPParameters' import { HTTPRequestConfig } from './HTTPRequestConfig' +import { ensureEdgeFunctionAuthorizationHeader } from '@/components/interfaces/Functions/httpHeaderAddActions' import { FormSection, FormSectionContent, FormSectionLabel, } from '@/components/ui/Forms/FormSection' -import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query' +import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' import { useTableNamesQuery } from '@/data/tables/table-names-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -60,7 +61,8 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => { projectRef: ref, }) - const legacyServiceRole = keys.find((x) => x.name === 'service_role')?.api_key ?? '[YOUR API KEY]' + const { serviceKey } = getKeys(keys) + const serviceRoleKey = serviceKey?.api_key const httpUrl = useWatch({ control: form.control, name: 'http_url' }) const httpHeaders = useWatch({ control: form.control, name: 'httpHeaders' }) @@ -79,34 +81,18 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => { if (httpUrl && isEdgeFunctionSelected) { const fnSlug = httpUrl.split('/').at(-1) const fn = functions.find((x) => x.slug === fnSlug) - const authorizationHeader = httpHeaders.find((x) => x.name === 'Authorization') - const edgeFunctionAuthHeaderVal = `Bearer ${legacyServiceRole}` + const updatedHttpHeaders = ensureEdgeFunctionAuthorizationHeader({ + headers: httpHeaders, + serviceRoleKey, + verifyJwt: fn?.verify_jwt, + createRow: (name, value) => ({ id: uuidv4(), name, value }), + }) - if (fn?.verify_jwt && authorizationHeader == null) { - const newAuthHeader = { - id: uuidv4(), - name: 'Authorization', - value: edgeFunctionAuthHeaderVal, - } - form.setValue('httpHeaders', [...httpHeaders, newAuthHeader]) - } else if (fn?.verify_jwt && authorizationHeader?.value !== edgeFunctionAuthHeaderVal) { - const updatedHttpHeaders = httpHeaders.map((x) => { - if (x.name === 'Authorization') return { ...x, value: edgeFunctionAuthHeaderVal } - else return x - }) + if (updatedHttpHeaders !== httpHeaders) { form.setValue('httpHeaders', updatedHttpHeaders) } } - }, [ - form, - functions, - httpHeaders, - httpUrl, - isSuccessEdgeFunctions, - legacyServiceRole, - ref, - restUrl, - ]) + }, [form, functions, httpHeaders, httpUrl, isSuccessEdgeFunctions, serviceRoleKey, ref, restUrl]) return (
diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPHeaders.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPHeaders.tsx index b22344bbec2..a0bf26bd380 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPHeaders.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPHeaders.tsx @@ -38,7 +38,6 @@ export const HTTPHeaders = ({ form }: HTTPHeadersProps) => { functionType === 'supabase_function' ? buildEdgeFunctionHeaderAddActions({ apiKey, - includeApiKeyHeader: serviceKey?.type === 'secret', createRow: (name: string, value: string) => ({ id: uuidv4(), name, value }), }) : [] @@ -56,7 +55,7 @@ export const HTTPHeaders = ({ form }: HTTPHeadersProps) => { createEmptyRow={() => ({ id: uuidv4(), name: '', value: '' })} keyPlaceholder="Header name" valuePlaceholder="Header value" - addLabel="Add a new header" + addLabel="Add header" addActions={addActions} /> diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPParameters.test.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPParameters.test.tsx index 81c69533117..03c87619bed 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPParameters.test.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPParameters.test.tsx @@ -35,7 +35,7 @@ describe('HTTPParameters', () => { render() - await user.click(screen.getByRole('button', { name: 'Add a new parameter' })) + await user.click(screen.getByRole('button', { name: 'Add parameter' })) expect(screen.getAllByPlaceholderText('Parameter name')).toHaveLength(2) expect(screen.getAllByPlaceholderText('Parameter value')).toHaveLength(2) diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPParameters.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPParameters.tsx index 50d0e9308f9..6b53ac43b02 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPParameters.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPParameters.tsx @@ -27,7 +27,7 @@ export const HTTPParameters = ({ form }: HTTPParametersProps) => { createEmptyRow={() => ({ id: uuidv4(), name: '', value: '' })} keyPlaceholder="Parameter name" valuePlaceholder="Parameter value" - addLabel="Add a new parameter" + addLabel="Add parameter" removeLabel="Remove parameter" /> diff --git a/apps/studio/components/interfaces/Functions/httpHeaderAddActions.test.ts b/apps/studio/components/interfaces/Functions/httpHeaderAddActions.test.ts index ceb81d01648..f9089b9c680 100644 --- a/apps/studio/components/interfaces/Functions/httpHeaderAddActions.test.ts +++ b/apps/studio/components/interfaces/Functions/httpHeaderAddActions.test.ts @@ -1,30 +1,159 @@ import { describe, expect, it } from 'vitest' -import { buildEdgeFunctionHeaderAddActions } from './httpHeaderAddActions' +import { + buildEdgeFunctionHeaderAddActions, + ensureEdgeFunctionAuthorizationHeader, + getEdgeFunctionAuthHeader, +} from './httpHeaderAddActions' describe('buildEdgeFunctionHeaderAddActions', () => { - it('includes the apikey header when requested', () => { + it('includes the apikey header for secret keys', () => { const [authAction] = buildEdgeFunctionHeaderAddActions({ - apiKey: 'secret-key', - includeApiKeyHeader: true, + apiKey: 'sb_secret_123', createRow: (name, value) => ({ name, value }), }) - expect(authAction.createRows()).toEqual([ - { name: 'Authorization', value: 'Bearer secret-key' }, - { name: 'apikey', value: 'secret-key' }, - ]) + expect(authAction.label).toBe('Add secret key') + expect(authAction.description).toBe( + 'Requires JWT verification to be disabled and authorization handled by the function' + ) + expect(authAction.createRows()).toEqual([{ name: 'apikey', value: 'sb_secret_123' }]) }) - it('omits the apikey header when not requested', () => { + it('falls back to the Authorization header for legacy keys', () => { const [authAction] = buildEdgeFunctionHeaderAddActions({ - apiKey: 'service-key', - includeApiKeyHeader: false, + apiKey: 'legacy-service-role-jwt', createRow: (name, value) => ({ name, value }), }) + expect(authAction.label).toBe('Add secret key') + expect(authAction.description).toBe('Required for edge functions that enforce JWT verification') expect(authAction.createRows()).toEqual([ - { name: 'Authorization', value: 'Bearer service-key' }, + { name: 'Authorization', value: 'Bearer legacy-service-role-jwt' }, ]) }) }) + +describe('getEdgeFunctionAuthHeader', () => { + it('returns the apikey header for publishable keys', () => { + expect(getEdgeFunctionAuthHeader('sb_publishable_123')).toEqual({ + name: 'apikey', + value: 'sb_publishable_123', + }) + }) + + it('returns the Authorization header for non-prefixed keys', () => { + expect(getEdgeFunctionAuthHeader('legacy-service-role-jwt')).toEqual({ + name: 'Authorization', + value: 'Bearer legacy-service-role-jwt', + }) + }) + + it.each(['sb_secretly_123', 'sb_publishableish_123'])( + 'does not treat %s as a new API key', + (apiKey) => { + expect(getEdgeFunctionAuthHeader(apiKey)).toEqual({ + name: 'Authorization', + value: `Bearer ${apiKey}`, + }) + } + ) +}) + +describe('ensureEdgeFunctionAuthorizationHeader', () => { + const createRow = (name: string, value: string) => ({ id: 'new', name, value }) + + it('preserves apikey while normalizing Authorization rows', () => { + const headers = [ + { id: 'custom-before', name: 'X-Before', value: 'before' }, + { id: 'authorization', name: ' authorization ', value: 'Bearer old-key' }, + { id: 'apikey', name: 'apikey', value: 'sb_secret_123' }, + { id: 'duplicate', name: 'AUTHORIZATION', value: 'Bearer stale-key' }, + { id: 'custom-after', name: 'X-After', value: 'after' }, + ] + + expect( + ensureEdgeFunctionAuthorizationHeader({ + headers, + serviceRoleKey: 'legacy-service-role-jwt', + verifyJwt: true, + createRow, + }) + ).toEqual([ + { id: 'custom-before', name: 'X-Before', value: 'before' }, + { + id: 'authorization', + name: 'Authorization', + value: 'Bearer old-key', + }, + { id: 'apikey', name: 'apikey', value: 'sb_secret_123' }, + { id: 'custom-after', name: 'X-After', value: 'after' }, + ]) + }) + + it('adds Authorization alongside apikey when JWT verification is enabled', () => { + const headers = [{ id: 'apikey', name: 'apikey', value: 'sb_secret_123' }] + + expect( + ensureEdgeFunctionAuthorizationHeader({ + headers, + serviceRoleKey: 'legacy-service-role-jwt', + verifyJwt: true, + createRow, + }) + ).toEqual([ + { id: 'apikey', name: 'apikey', value: 'sb_secret_123' }, + { id: 'new', name: 'Authorization', value: 'Bearer legacy-service-role-jwt' }, + ]) + }) + + it('does not change headers without an applicable service role key', () => { + const headers = [ + { id: 'authorization', name: 'Authorization', value: 'Bearer existing-key' }, + { id: 'apikey', name: 'apikey', value: 'sb_secret_123' }, + ] + + expect( + ensureEdgeFunctionAuthorizationHeader({ + headers, + serviceRoleKey: 'legacy-service-role-jwt', + verifyJwt: false, + createRow, + }) + ).toBe(headers) + expect(ensureEdgeFunctionAuthorizationHeader({ headers, verifyJwt: true, createRow })).toBe( + headers + ) + expect( + ensureEdgeFunctionAuthorizationHeader({ + headers, + serviceRoleKey: 'sb_secret_456', + verifyJwt: true, + createRow, + }) + ).toBe(headers) + }) + + it.each(['Bearer user-entered-token', ''])( + 'preserves a user-entered Authorization value: %j', + (value) => { + const headers = [ + { + id: 'authorization', + name: 'Authorization', + value, + }, + { id: 'apikey', name: 'apikey', value: 'sb_secret_123' }, + ] + + expect( + ensureEdgeFunctionAuthorizationHeader({ + headers, + serviceRoleKey: 'legacy-service-role-jwt', + verifyJwt: true, + createRow, + }) + ).toBe(headers) + } + ) +}) diff --git a/apps/studio/components/interfaces/Functions/httpHeaderAddActions.ts b/apps/studio/components/interfaces/Functions/httpHeaderAddActions.ts index 62a6cf3fcff..75e446c5e39 100644 --- a/apps/studio/components/interfaces/Functions/httpHeaderAddActions.ts +++ b/apps/studio/components/interfaces/Functions/httpHeaderAddActions.ts @@ -2,29 +2,83 @@ import type { KeyValueFieldArrayAction } from 'ui-patterns/form/KeyValueFieldArr interface BuildEdgeFunctionHeaderAddActionsParams { apiKey: string - includeApiKeyHeader?: boolean createRow: (name: string, value: string) => TRow } +interface HTTPHeader { + name: string + value: string +} + +interface EnsureEdgeFunctionAuthorizationHeaderParams { + headers: TRow[] + serviceRoleKey?: string + verifyJwt?: boolean + createRow: (name: string, value: string) => TRow +} + +const isNewApiKey = (apiKey: string) => + apiKey.startsWith('sb_secret_') || apiKey.startsWith('sb_publishable_') + +export const getEdgeFunctionAuthHeader = (apiKey: string) => + isNewApiKey(apiKey) + ? { name: 'apikey', value: apiKey } + : { name: 'Authorization', value: `Bearer ${apiKey}` } + +export const ensureEdgeFunctionAuthorizationHeader = ({ + headers, + serviceRoleKey, + verifyJwt, + createRow, +}: EnsureEdgeFunctionAuthorizationHeaderParams): TRow[] => { + if (!verifyJwt || !serviceRoleKey || isNewApiKey(serviceRoleKey)) return headers + + const isAuthorization = (header: HTTPHeader) => + header.name.trim().toLowerCase() === 'authorization' + const authorizationIndex = headers.findIndex(isAuthorization) + + if (authorizationIndex === -1) { + return [...headers, createRow('Authorization', `Bearer ${serviceRoleKey}`)] + } + + const authorizationHeader = headers[authorizationIndex] + const normalizedHeaders = headers.filter( + (header, index) => index === authorizationIndex || !isAuthorization(header) + ) + + if (normalizedHeaders.length === headers.length && authorizationHeader.name === 'Authorization') { + return headers + } + + normalizedHeaders[authorizationIndex] = { + ...authorizationHeader, + name: 'Authorization', + } + return normalizedHeaders +} + export const buildEdgeFunctionHeaderAddActions = ({ apiKey, - includeApiKeyHeader = false, createRow, -}: BuildEdgeFunctionHeaderAddActionsParams): KeyValueFieldArrayAction[] => [ - { - key: 'add-auth-header', - label: 'Add auth header with secret key', - description: 'Required if your edge function enforces JWT verification', - createRows: () => [ - createRow('Authorization', `Bearer ${apiKey}`), - ...(includeApiKeyHeader ? [createRow('apikey', apiKey)] : []), - ], - }, - { - key: 'add-source-header', - label: 'Add custom source header', - description: 'Useful to verify that the edge function was triggered from this webhook', - createRows: () => createRow('x-supabase-webhook-source', '[Use a secret value]'), - separatorAbove: true, - }, -] +}: BuildEdgeFunctionHeaderAddActionsParams): KeyValueFieldArrayAction[] => { + const authHeader = getEdgeFunctionAuthHeader(apiKey) + + return [ + { + key: 'add-auth-header', + label: 'Add secret key', + description: + authHeader.name === 'apikey' + ? 'Requires JWT verification to be disabled and authorization handled by the function' + : 'Required for edge functions that enforce JWT verification', + createRows: () => [createRow(authHeader.name, authHeader.value)], + }, + { + key: 'add-source-header', + label: 'Add custom source', + description: 'Useful to verify that the edge function was triggered from this webhook', + createRows: () => createRow('x-supabase-webhook-source', '[Use a secret value]'), + separatorAbove: true, + }, + ] +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx index 5212f876416..6d6fdb10263 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx @@ -29,7 +29,6 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp variant === 'edge_function' ? buildEdgeFunctionHeaderAddActions({ apiKey, - includeApiKeyHeader: serviceKey?.type === 'secret', createRow: (name: string, value: string) => ({ name, value }), }) : [] @@ -45,7 +44,7 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp createEmptyRow={() => ({ name: '', value: '' })} keyPlaceholder="Header name" valuePlaceholder="Header value" - addLabel="Add a new header" + addLabel="Add header" addActions={addActions} /> diff --git a/e2e/studio/features/database-webhooks.spec.ts b/e2e/studio/features/database-webhooks.spec.ts index 04849884fc5..b6bd2e523fe 100644 --- a/e2e/studio/features/database-webhooks.spec.ts +++ b/e2e/studio/features/database-webhooks.spec.ts @@ -56,7 +56,7 @@ const openWebhookEditor = async (page: Page, hookName: string) => { } const addCustomHeader = async (page: Page, name: string, value: string) => { - await page.getByRole('button', { name: 'Add a new header' }).click() + await page.getByRole('button', { name: 'Add header' }).click() await page.getByPlaceholder('Header name').last().fill(name) await page.getByPlaceholder('Header value').last().fill(value) }