Skip to content

Commit c6708ac

Browse files
imorYostra
andauthored
fix: use correct supabase auth in stripe-setup edge-function (#385)
* fix: use secret key auth for stripe-setup * move to a separate function * refactor: rename a function to better reflect its purpose --------- Co-authored-by: Yostra <straya.mark@gmail.com>
1 parent ffc12db commit c6708ac

3 files changed

Lines changed: 271 additions & 19 deletions

File tree

packages/sync-engine/src/supabase/edge-functions/stripe-setup.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { embeddedMigrations } from '../../database/migrations-embedded.ts'
55
import { parseSchemaComment } from '../schemaComment.ts'
66
import postgres from 'postgres'
77

8+
// Name of the vault secret used to authenticate callers of this function.
9+
// Generated per-install and stored in vault, mirroring the stripe-worker secret.
10+
const SETUP_SECRET_NAME = 'stripe_setup_secret'
11+
812
// Get management API base URL from environment variable (for testing against localhost/staging)
913
// Caller should provide full URL with protocol (e.g., http://localhost:54323 or https://api.supabase.com)
1014
const MGMT_API_BASE_RAW = Deno.env.get('MANAGEMENT_API_URL') || 'https://api.supabase.com'
@@ -55,6 +59,45 @@ async function deleteSecret(
5559
}
5660
}
5761

62+
// Thrown when setup-secret validation fails; carries the HTTP status for the response.
63+
class SetupAuthError extends Error {
64+
constructor(
65+
readonly status: number,
66+
message: string
67+
) {
68+
super(message)
69+
}
70+
}
71+
72+
// Validates the caller's bearer secret against the per-install setup secret in vault.
73+
// Returns only on a strict match with a non-empty stored secret; throws on every other case.
74+
async function authenticateCaller(callerSecret: string): Promise<void> {
75+
const dbUrl = Deno.env.get('SUPABASE_DB_URL')
76+
if (!dbUrl) {
77+
throw new SetupAuthError(500, 'SUPABASE_DB_URL not set')
78+
}
79+
80+
let authSql: ReturnType<typeof postgres> | undefined
81+
try {
82+
authSql = postgres(dbUrl, { max: 1, prepare: false })
83+
const secretResult = await authSql`
84+
SELECT decrypted_secret
85+
FROM vault.decrypted_secrets
86+
WHERE name = ${SETUP_SECRET_NAME}
87+
`
88+
const storedSecret: unknown = secretResult[0]?.decrypted_secret
89+
if (typeof storedSecret !== 'string' || storedSecret.length === 0) {
90+
throw new SetupAuthError(500, 'Setup secret not configured in vault')
91+
}
92+
if (callerSecret === storedSecret) {
93+
return
94+
}
95+
throw new SetupAuthError(403, 'Forbidden: Invalid setup secret')
96+
} finally {
97+
if (authSql) await authSql.end()
98+
}
99+
}
100+
58101
Deno.serve(async (req) => {
59102
// Extract project ref from SUPABASE_URL (format: https://{projectRef}.{base})
60103
const supabaseUrl = Deno.env.get('SUPABASE_URL')
@@ -66,13 +109,36 @@ Deno.serve(async (req) => {
66109
}
67110
const projectRef = new URL(supabaseUrl).hostname.split('.')[0]
68111

69-
// Validate access token for all requests
112+
// Authenticate the caller against the per-install secret stored in vault.
113+
// The Authorization bearer token must match `stripe_setup_secret`. An attacker
114+
// cannot read the vault to learn this value, and because the destructive
115+
// branches below only run after this check passes, a forged token is rejected
116+
// before any cleanup occurs. This mirrors how the stripe-worker function
117+
// authenticates against its own vault secret.
70118
const authHeader = req.headers.get('Authorization')
71119
if (!authHeader?.startsWith('Bearer ')) {
72120
return new Response('Unauthorized', { status: 401 })
73121
}
122+
const callerSecret = authHeader.substring(7) // Remove 'Bearer '
123+
124+
// The Management API access token used for the function's own Management API
125+
// operations (deleting secrets and edge functions during uninstall). It is
126+
// supplied out-of-band from the authenticating secret so that this function
127+
// never has to trust the bearer token for privileged Management operations.
128+
const accessToken = req.headers.get('x-management-api-token') ?? ''
74129

75-
const accessToken = authHeader.substring(7) // Remove 'Bearer '
130+
try {
131+
await authenticateCaller(callerSecret)
132+
} catch (error: unknown) {
133+
if (error instanceof SetupAuthError) {
134+
return new Response(error.message, { status: error.status })
135+
}
136+
console.error('Setup secret validation error:', error)
137+
return new Response(JSON.stringify({ error: (error as Error).message }), {
138+
status: 500,
139+
headers: { 'Content-Type': 'application/json' },
140+
})
141+
}
76142

77143
// Handle GET requests for status
78144
if (req.method === 'GET') {
@@ -216,7 +282,7 @@ Deno.serve(async (req) => {
216282
try {
217283
await stripeSync.postgresClient.query(`
218284
DELETE FROM vault.secrets
219-
WHERE name IN ('stripe_sync_worker_secret', 'stripe_sigma_worker_secret')
285+
WHERE name IN ('stripe_sync_worker_secret', 'stripe_sigma_worker_secret', 'stripe_setup_secret')
220286
`)
221287
} catch (err) {
222288
console.warn('Could not delete vault secret:', err)

packages/sync-engine/src/supabase/supabase.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export class SupabaseSetupClient {
2828
private supabaseManagementUrl?: string
2929
private accessToken: string
3030
private workerSecret: string
31+
private setupSecret: string
3132

3233
constructor(options: DeployClientOptions) {
3334
this.api = new SupabaseManagementAPI({
@@ -39,6 +40,35 @@ export class SupabaseSetupClient {
3940
this.supabaseManagementUrl = options.supabaseManagementUrl
4041
this.accessToken = options.accessToken
4142
this.workerSecret = crypto.randomUUID()
43+
this.setupSecret = crypto.randomUUID()
44+
}
45+
46+
/**
47+
* Store the per-install setup secret in vault. The stripe-setup edge function
48+
* authenticates callers against this value, so it must exist before the
49+
* function is invoked.
50+
*/
51+
private async storeSetupSecret(): Promise<void> {
52+
const escapedSetupSecret = this.setupSecret.replace(/'/g, "''")
53+
await this.runSQL(`
54+
DELETE FROM vault.secrets WHERE name = 'stripe_setup_secret';
55+
SELECT vault.create_secret('${escapedSetupSecret}', 'stripe_setup_secret');
56+
`)
57+
}
58+
59+
/**
60+
* Read the per-install setup secret from vault. Used during uninstall to
61+
* authenticate the call to the stripe-setup edge function.
62+
*/
63+
private async getSetupSecret(): Promise<string | null> {
64+
try {
65+
const result = (await this.runSQL(
66+
`SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'stripe_setup_secret'`
67+
)) as { decrypted_secret: string }[]
68+
return result[0]?.decrypted_secret ?? null
69+
} catch {
70+
return null
71+
}
4272
}
4373

4474
/**
@@ -271,15 +301,23 @@ export class SupabaseSetupClient {
271301
async invokeFunction(
272302
slug: string,
273303
method: string,
274-
bearerToken: string
304+
bearerToken: string,
305+
managementApiToken?: string
275306
): Promise<{ success: boolean; error?: string }> {
276307
const url = `https://${this.projectRef}.${this.projectBaseUrl}/functions/v1/${slug}`
308+
const headers: Record<string, string> = {
309+
Authorization: `Bearer ${bearerToken}`,
310+
'Content-Type': 'application/json',
311+
}
312+
// The stripe-setup function authenticates the caller via the bearer secret,
313+
// but performs its own Management API operations using a separate token
314+
// passed out-of-band in this header.
315+
if (managementApiToken) {
316+
headers['x-management-api-token'] = managementApiToken
317+
}
277318
const response = await fetch(url, {
278319
method,
279-
headers: {
280-
Authorization: `Bearer ${bearerToken}`,
281-
'Content-Type': 'application/json',
282-
},
320+
headers,
283321
})
284322

285323
if (!response.ok) {
@@ -450,9 +488,22 @@ export class SupabaseSetupClient {
450488
await this.updateComment({ status: 'uninstalling', startTime })
451489
}
452490

491+
// Read the setup secret stored in vault at install time to authenticate
492+
// the uninstall call. The Management API token (this.accessToken) authorizes
493+
// reading the secret here, and is also forwarded for the function's own
494+
// Management API cleanup operations.
495+
const setupSecret = await this.getSetupSecret()
496+
if (!setupSecret) {
497+
throw new Error('Setup secret not found in vault; cannot authenticate uninstall')
498+
}
499+
453500
// Invoke the DELETE endpoint on stripe-setup function
454-
// Use accessToken in Authorization header for Management API validation
455-
const setupResult = await this.invokeFunction('stripe-setup', 'DELETE', this.accessToken)
501+
const setupResult = await this.invokeFunction(
502+
'stripe-setup',
503+
'DELETE',
504+
setupSecret,
505+
this.accessToken
506+
)
456507

457508
if (!setupResult.success) {
458509
throw new Error(`Uninstall failed: ${setupResult.error}`)
@@ -523,9 +574,19 @@ export class SupabaseSetupClient {
523574
const versionedSetup = this.injectPackageVersion(setupFunctionCode, version)
524575
await this.deployFunction('stripe-setup', versionedSetup, false)
525576

577+
// Store the setup secret in vault before invoking the function, since the
578+
// function authenticates the caller against this value.
579+
await this.storeSetupSecret()
580+
526581
// Run setup (migrations + webhook creation)
527-
// Use accessToken for Management API validation
528-
const setupResult = await this.invokeFunction('stripe-setup', 'POST', this.accessToken)
582+
// Authenticate with the setup secret; pass the Management token separately
583+
// for the function's own Management API operations.
584+
const setupResult = await this.invokeFunction(
585+
'stripe-setup',
586+
'POST',
587+
this.setupSecret,
588+
this.accessToken
589+
)
529590

530591
if (!setupResult.success) {
531592
throw new Error(`Setup failed: ${setupResult.error}`)

packages/sync-engine/src/tests/unit/supabase.test.ts

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
23
import { SupabaseSetupClient } from '../../supabase/supabase'
34

45
describe('SupabaseDeployClient', () => {
@@ -655,9 +656,6 @@ describe('SupabaseDeployClient', () => {
655656
})
656657

657658
// Mock only what we need to test
658-
client.validateProject = vi
659-
.fn()
660-
.mockResolvedValue({ id: mockProjectRef, name: 'test', region: 'us-east-1' })
661659
client.runSQL = vi.fn().mockResolvedValue(null)
662660
client.deployFunction = vi.fn().mockResolvedValue(null)
663661
client.setSecrets = mockSetSecrets
@@ -683,9 +681,6 @@ describe('SupabaseDeployClient', () => {
683681
})
684682

685683
// Mock only what we need to test
686-
client.validateProject = vi
687-
.fn()
688-
.mockResolvedValue({ id: mockProjectRef, name: 'test', region: 'us-east-1' })
689684
client.runSQL = vi.fn().mockResolvedValue(null)
690685
client.deployFunction = vi.fn().mockResolvedValue(null)
691686
client.setSecrets = mockSetSecrets
@@ -701,4 +696,134 @@ describe('SupabaseDeployClient', () => {
701696
])
702697
})
703698
})
699+
700+
describe('Setup secret authentication', () => {
701+
it('passes the setup secret as bearer and the management token as x-management-api-token', async () => {
702+
const client = new SupabaseSetupClient({
703+
accessToken: mockAccessToken,
704+
projectRef: mockProjectRef,
705+
projectBaseUrl: 'test-domain.com',
706+
})
707+
708+
const mockFetch = vi.fn().mockResolvedValue({
709+
ok: true,
710+
json: async () => ({ success: true }),
711+
})
712+
global.fetch = mockFetch
713+
714+
await client.invokeFunction('stripe-setup', 'DELETE', 'the-setup-secret', mockAccessToken)
715+
716+
expect(mockFetch).toHaveBeenCalledWith(
717+
`https://${mockProjectRef}.test-domain.com/functions/v1/stripe-setup`,
718+
expect.objectContaining({
719+
method: 'DELETE',
720+
headers: expect.objectContaining({
721+
Authorization: 'Bearer the-setup-secret',
722+
'x-management-api-token': mockAccessToken,
723+
}),
724+
})
725+
)
726+
727+
vi.restoreAllMocks()
728+
})
729+
730+
it('does not set x-management-api-token when no management token is provided', async () => {
731+
const client = new SupabaseSetupClient({
732+
accessToken: mockAccessToken,
733+
projectRef: mockProjectRef,
734+
})
735+
736+
const mockFetch = vi.fn().mockResolvedValue({
737+
ok: true,
738+
json: async () => ({ success: true }),
739+
})
740+
global.fetch = mockFetch
741+
742+
await client.invokeFunction('stripe-worker', 'POST', 'worker-secret')
743+
744+
const headers = mockFetch.mock.calls[0][1].headers
745+
expect(headers).not.toHaveProperty('x-management-api-token')
746+
747+
vi.restoreAllMocks()
748+
})
749+
750+
it('stores the setup secret in vault before invoking stripe-setup during install', async () => {
751+
const runSqlCalls: string[] = []
752+
const client = new SupabaseSetupClient({
753+
accessToken: mockAccessToken,
754+
projectRef: mockProjectRef,
755+
})
756+
757+
client.runSQL = vi.fn().mockImplementation(async (sql: string) => {
758+
runSqlCalls.push(sql)
759+
return null
760+
})
761+
client.deployFunction = vi.fn().mockResolvedValue(null)
762+
client.setSecrets = vi.fn().mockResolvedValue(null)
763+
client.setupPgCronJob = vi.fn().mockResolvedValue(null)
764+
client.setupSigmaPgCronJob = vi.fn().mockResolvedValue(null)
765+
766+
let setupSecretStoredBeforeInvoke = false
767+
client.invokeFunction = vi.fn().mockImplementation(async (slug: string) => {
768+
if (slug === 'stripe-setup') {
769+
setupSecretStoredBeforeInvoke = runSqlCalls.some((q) =>
770+
q.includes("'stripe_setup_secret'")
771+
)
772+
}
773+
return { success: true }
774+
})
775+
776+
await client.install('sk_test_key')
777+
778+
expect(setupSecretStoredBeforeInvoke).toBe(true)
779+
})
780+
781+
it('authenticates stripe-setup with the secret read from vault, not the access token', async () => {
782+
const storedSecret = 'stored-setup-secret'
783+
const client = new SupabaseSetupClient({
784+
accessToken: mockAccessToken,
785+
projectRef: mockProjectRef,
786+
})
787+
788+
client.runSQL = vi.fn().mockImplementation(async (sql: string) => {
789+
if (sql.includes('stripe_setup_secret')) {
790+
return [{ decrypted_secret: storedSecret }]
791+
}
792+
if (sql.includes('schema_exists')) {
793+
return [{ schema_exists: true }]
794+
}
795+
return [{ comment: null }]
796+
})
797+
798+
const invokeSpy = vi.fn().mockResolvedValue({ success: true })
799+
client.invokeFunction = invokeSpy
800+
client.updateComment = vi.fn().mockResolvedValue(undefined)
801+
802+
await client.uninstall()
803+
804+
expect(invokeSpy).toHaveBeenCalledWith(
805+
'stripe-setup',
806+
'DELETE',
807+
storedSecret,
808+
mockAccessToken
809+
)
810+
})
811+
812+
it('fails uninstall without invoking stripe-setup when the setup secret cannot be read', async () => {
813+
const client = new SupabaseSetupClient({
814+
accessToken: 'attacker-controlled-token',
815+
projectRef: mockProjectRef,
816+
})
817+
818+
// Simulate the Management API rejecting an unauthorized token: every
819+
// runSQL (including the vault read) throws.
820+
client.runSQL = vi.fn().mockRejectedValue(new Error('401 Unauthorized'))
821+
822+
const invokeSpy = vi.fn().mockResolvedValue({ success: true })
823+
client.invokeFunction = invokeSpy
824+
825+
await expect(client.uninstall()).rejects.toThrow(/Setup secret not found in vault/)
826+
expect(invokeSpy).not.toHaveBeenCalled()
827+
})
828+
})
704829
})

0 commit comments

Comments
 (0)