Skip to content

Commit 814ab75

Browse files
committed
fix: use correct supabase auth
1 parent 9e037ac commit 814ab75

3 files changed

Lines changed: 135 additions & 90 deletions

File tree

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

Lines changed: 2 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { runMigrationsFromContent } from '../../database/migrate.ts'
33
import { VERSION } from '../../version.ts'
44
import { embeddedMigrations } from '../../database/migrations-embedded.ts'
55
import { parseSchemaComment } from '../schemaComment.ts'
6+
import { authenticateWithSupabase } from '../managementAuth.ts'
67
import postgres from 'postgres'
78

89
// Get management API base URL from environment variable (for testing against localhost/staging)
@@ -12,95 +13,6 @@ const MGMT_API_BASE = MGMT_API_BASE_RAW.match(/^https?:\/\//)
1213
? MGMT_API_BASE_RAW
1314
: `https://${MGMT_API_BASE_RAW}`
1415

15-
// Returns true if the projectRef passed is a non-branch project, false otherwise
16-
async function canAccessProject(projectRef: string, accessToken: string): Promise<boolean> {
17-
const url = `${MGMT_API_BASE}/v1/projects/${projectRef}`
18-
const response = await fetch(url, {
19-
method: 'GET',
20-
headers: {
21-
Authorization: `Bearer ${accessToken}`,
22-
'Content-Type': 'application/json',
23-
},
24-
})
25-
return response.ok
26-
}
27-
28-
// Returns all projects refs
29-
async function getAllProjects(accessToken: string): Promise<string[]> {
30-
const url = `${MGMT_API_BASE}/v1/projects`
31-
const response = await fetch(url, {
32-
method: 'GET',
33-
headers: {
34-
Authorization: `Bearer ${accessToken}`,
35-
'Content-Type': 'application/json',
36-
},
37-
})
38-
if (response.ok) {
39-
const projects = (await response.json()) as Array<{ ref?: string }>
40-
return Array.isArray(projects)
41-
? projects
42-
.map((project) => project.ref)
43-
.filter((projectRef): projectRef is string => Boolean(projectRef))
44-
: []
45-
} else {
46-
return []
47-
}
48-
}
49-
50-
// Returns all branch project refs of a give projectRef
51-
async function getAllBranches(projectRef: string, accessToken: string): Promise<string[]> {
52-
const url = `${MGMT_API_BASE}/v1/projects/${projectRef}/branches`
53-
const response = await fetch(url, {
54-
method: 'GET',
55-
headers: {
56-
Authorization: `Bearer ${accessToken}`,
57-
'Content-Type': 'application/json',
58-
},
59-
})
60-
if (response.ok) {
61-
const branches = (await response.json()) as Array<{ project_ref?: string }>
62-
return Array.isArray(branches)
63-
? branches
64-
.map((branch) => branch.project_ref)
65-
.filter((projectRef): projectRef is string => Boolean(projectRef))
66-
: []
67-
} else {
68-
return []
69-
}
70-
}
71-
72-
// Authenticates with Supabase management API. Returns null if the accessToken has
73-
// access to the projectRef project, other returns an error string
74-
async function authenticateWithSupabase(
75-
projectRef: string,
76-
accessToken: string | null
77-
): Promise<string | null> {
78-
if (!accessToken) {
79-
return 'Unauthorized: Invalid credentials'
80-
}
81-
82-
// First we check if the token can return the project directly
83-
// If it does then the token is valid for this project and
84-
// we authenticate the request
85-
const tokenCanAccesProject = await canAccessProject(projectRef, accessToken)
86-
if (tokenCanAccesProject) {
87-
return null
88-
}
89-
90-
// If the token does not return a project then projectRef could be a branch
91-
// project, in which case we enumerate branch projects of all projects
92-
const allProjectRefs = await getAllProjects(accessToken)
93-
for (const ref of allProjectRefs) {
94-
const branches = await getAllBranches(ref, accessToken)
95-
if (branches.includes(projectRef)) {
96-
return null
97-
}
98-
}
99-
100-
// It's not even a branch project
101-
return 'Unauthorized: Invalid credentials'
102-
}
103-
10416
// Helper to delete edge function via Management API
10517
async function deleteEdgeFunction(
10618
projectRef: string,
@@ -163,7 +75,7 @@ Deno.serve(async (req) => {
16375

16476
const accessToken = authHeader.substring(7) // Remove 'Bearer '
16577

166-
const supabaseAuthError = await authenticateWithSupabase(projectRef, accessToken)
78+
const supabaseAuthError = await authenticateWithSupabase(MGMT_API_BASE, projectRef, accessToken)
16779
if (supabaseAuthError) {
16880
return new Response('Unauthorized', { status: 401 })
16981
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Verifies a management access token can operate on a specific project, mirroring
2+
// how the install script targets the project ref directly via the Management API.
3+
4+
export async function canAccessProject(
5+
mgmtApiBase: string,
6+
projectRef: string,
7+
accessToken: string
8+
): Promise<boolean> {
9+
const response = await fetch(`${mgmtApiBase}/v1/projects/${projectRef}`, {
10+
method: 'GET',
11+
headers: {
12+
Authorization: `Bearer ${accessToken}`,
13+
'Content-Type': 'application/json',
14+
},
15+
})
16+
return response.ok
17+
}
18+
19+
// Fallback for branch deployments, whose ref is exposed via the branches endpoint
20+
// rather than /v1/projects/{ref}.
21+
export async function canAccessBranch(
22+
mgmtApiBase: string,
23+
projectRef: string,
24+
accessToken: string
25+
): Promise<boolean> {
26+
const response = await fetch(`${mgmtApiBase}/v1/branches/${projectRef}`, {
27+
method: 'GET',
28+
headers: {
29+
Authorization: `Bearer ${accessToken}`,
30+
'Content-Type': 'application/json',
31+
},
32+
})
33+
return response.ok
34+
}
35+
36+
// Returns null when the token may operate on projectRef, otherwise an error string.
37+
export async function authenticateWithSupabase(
38+
mgmtApiBase: string,
39+
projectRef: string,
40+
accessToken: string | null
41+
): Promise<string | null> {
42+
if (!accessToken) {
43+
return 'Unauthorized: Invalid credentials'
44+
}
45+
46+
if (await canAccessProject(mgmtApiBase, projectRef, accessToken)) {
47+
return null
48+
}
49+
50+
if (await canAccessBranch(mgmtApiBase, projectRef, accessToken)) {
51+
return null
52+
}
53+
54+
return 'Unauthorized: Invalid credentials'
55+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect, afterEach, vi } from 'vitest'
2+
import { authenticateWithSupabase } from '../../supabase/managementAuth'
3+
4+
const MGMT_API_BASE = 'https://api.supabase.com'
5+
const PROJECT_REF = 'lgiudgtkdioaxbczoqhq'
6+
const VALID_TOKEN = 'sbp_valid_token'
7+
8+
// Stubs global fetch so response.ok is driven by the requested URL.
9+
function mockFetchByUrl(isOk: (url: string) => boolean) {
10+
const mockFetch = vi.fn().mockImplementation((url: string) => Promise.resolve({ ok: isOk(url) }))
11+
global.fetch = mockFetch
12+
return mockFetch
13+
}
14+
15+
describe('authenticateWithSupabase', () => {
16+
afterEach(() => {
17+
vi.restoreAllMocks()
18+
})
19+
20+
it('rejects a missing token without calling the management API', async () => {
21+
const mockFetch = mockFetchByUrl(() => true)
22+
23+
const error = await authenticateWithSupabase(MGMT_API_BASE, PROJECT_REF, null)
24+
25+
expect(error).toBe('Unauthorized: Invalid credentials')
26+
expect(mockFetch).not.toHaveBeenCalled()
27+
})
28+
29+
it('rejects a fabricated token (the reported attack)', async () => {
30+
// A bogus token gets a 401 from the management API, so response.ok is false.
31+
const mockFetch = mockFetchByUrl(() => false)
32+
33+
const error = await authenticateWithSupabase(MGMT_API_BASE, PROJECT_REF, 'fake_attacker_token')
34+
35+
expect(error).toBe('Unauthorized: Invalid credentials')
36+
// Both the project and branch checks are attempted before rejecting.
37+
expect(mockFetch).toHaveBeenCalledTimes(2)
38+
})
39+
40+
it('accepts a token that can access the project', async () => {
41+
mockFetchByUrl((url) => url.endsWith(`/v1/projects/${PROJECT_REF}`))
42+
43+
const error = await authenticateWithSupabase(MGMT_API_BASE, PROJECT_REF, VALID_TOKEN)
44+
45+
expect(error).toBeNull()
46+
})
47+
48+
it('accepts a branch ref reachable via /v1/branches as a fallback', async () => {
49+
const mockFetch = mockFetchByUrl((url) => url.includes('/v1/branches/'))
50+
51+
const error = await authenticateWithSupabase(MGMT_API_BASE, PROJECT_REF, VALID_TOKEN)
52+
53+
expect(error).toBeNull()
54+
// Direct project check ran first and failed, then the branch check passed.
55+
expect(mockFetch).toHaveBeenCalledTimes(2)
56+
expect(mockFetch).toHaveBeenLastCalledWith(
57+
`${MGMT_API_BASE}/v1/branches/${PROJECT_REF}`,
58+
expect.objectContaining({
59+
method: 'GET',
60+
headers: expect.objectContaining({ Authorization: `Bearer ${VALID_TOKEN}` }),
61+
})
62+
)
63+
})
64+
65+
it('queries the exact project ref with the bearer token', async () => {
66+
const mockFetch = mockFetchByUrl(() => true)
67+
68+
await authenticateWithSupabase(MGMT_API_BASE, PROJECT_REF, VALID_TOKEN)
69+
70+
expect(mockFetch).toHaveBeenCalledWith(
71+
`${MGMT_API_BASE}/v1/projects/${PROJECT_REF}`,
72+
expect.objectContaining({
73+
method: 'GET',
74+
headers: expect.objectContaining({ Authorization: `Bearer ${VALID_TOKEN}` }),
75+
})
76+
)
77+
})
78+
})

0 commit comments

Comments
 (0)