mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
fix: webhook apikey (#47317)
## TL;DR Database webhooks/Cron jobs now add `apikey: <secret-key>` for edge function auth.. ## ref: - related to: https://github.com/supabase/supabase/pull/46890 - towards COM-269 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Tomás Pozo <tomaspozo@users.noreply.github.com> Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
8 files changed
+232
-65
No files matched your search
@@ -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 (
|
||||
<div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('HTTPParameters', () => {
|
||||
|
||||
render(<HTTPParametersHarness />)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</FormSectionContent>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -2,29 +2,83 @@ import type { KeyValueFieldArrayAction } from 'ui-patterns/form/KeyValueFieldArr
|
||||
|
||||
interface BuildEdgeFunctionHeaderAddActionsParams<TRow> {
|
||||
apiKey: string
|
||||
includeApiKeyHeader?: boolean
|
||||
createRow: (name: string, value: string) => TRow
|
||||
}
|
||||
|
||||
interface HTTPHeader {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface EnsureEdgeFunctionAuthorizationHeaderParams<TRow extends HTTPHeader> {
|
||||
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 = <TRow extends HTTPHeader>({
|
||||
headers,
|
||||
serviceRoleKey,
|
||||
verifyJwt,
|
||||
createRow,
|
||||
}: EnsureEdgeFunctionAuthorizationHeaderParams<TRow>): 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 = <TRow>({
|
||||
apiKey,
|
||||
includeApiKeyHeader = false,
|
||||
createRow,
|
||||
}: BuildEdgeFunctionHeaderAddActionsParams<TRow>): KeyValueFieldArrayAction<TRow>[] => [
|
||||
{
|
||||
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<TRow>): KeyValueFieldArrayAction<TRow>[] => {
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user