diff --git a/apps/supabase/src/edge-functions/stripe-setup.ts b/apps/supabase/src/edge-functions/stripe-setup.ts index 15e3c1966..6c75238fc 100644 --- a/apps/supabase/src/edge-functions/stripe-setup.ts +++ b/apps/supabase/src/edge-functions/stripe-setup.ts @@ -68,6 +68,112 @@ const MGMT_API_BASE = MGMT_API_BASE_RAW.match(/^https?:\/\//) ? MGMT_API_BASE_RAW : `https://${MGMT_API_BASE_RAW}` +// Returns true if the projectRef passed is a non-branch project, false otherwise +async function canAccessProject(projectRef: string, accessToken: string): Promise { + const url = `${MGMT_API_BASE}/v1/projects/${projectRef}` + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + return response.ok +} + +// Returns all projects refs +async function getAllProjects(accessToken: string): Promise { + const url = `${MGMT_API_BASE}/v1/projects` + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + if (response.ok) { + const projects = (await response.json()) as Array<{ ref?: string }> + return Array.isArray(projects) + ? projects + .map((project) => project.ref) + .filter((projectRef): projectRef is string => Boolean(projectRef)) + : [] + } else { + return [] + } +} + +// Returns all branch project refs of a give projectRef +async function getAllBranches(projectRef: string, accessToken: string): Promise { + const url = `${MGMT_API_BASE}/v1/projects/${projectRef}/branches` + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + if (response.ok) { + const branches = (await response.json()) as Array<{ project_ref?: string }> + return Array.isArray(branches) + ? branches + .map((branch) => branch.project_ref) + .filter((projectRef): projectRef is string => Boolean(projectRef)) + : [] + } else { + return [] + } +} + +// Authenticates with Supabase management API. Returns null if the accessToken has +// access to the projectRef project, other returns an error string +async function authenticateWithSupabase( + projectRef: string, + accessToken: string | null +): Promise { + if (!accessToken) { + return 'Unauthorized: Invalid credentials' + } + + // First we check if the token can return the project directly + // If it does then the token is valid for this project and + // we authenticate the request + const tokenCanAccesProject = await canAccessProject(projectRef, accessToken) + if (tokenCanAccesProject) { + return null + } + + // If the token does not return a project then projectRef could be a branch + // project, in which case we enumerate branch projects of all projects + const allProjectRefs = await getAllProjects(accessToken) + for (const ref of allProjectRefs) { + const branches = await getAllBranches(ref, accessToken) + if (branches.includes(projectRef)) { + return null + } + } + + // It's not even a branch project + return 'Unauthorized: Invalid credentials' +} + +// Helper to validate accessToken against Management API +async function validateAccessToken(projectRef: string, accessToken: string): Promise { + // Try to fetch project details using the access token + // This validates that the token is valid for the management API + const url = `${MGMT_API_BASE}/v1/projects/${projectRef}` + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + // If we can successfully get the project, the token is valid + return response.ok +} + const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, @@ -162,6 +268,11 @@ async function handleSetupPost(req: Request): Promise { if (ctx instanceof Response) return ctx const { supabaseUrl, projectRef, accessToken } = ctx + const supabaseAuthError = await authenticateWithSupabase(projectRef, accessToken) + if (supabaseAuthError) { + return new Response(supabaseAuthError, { status: 401 }) + } + let pool: pg.Pool | null = null try { const dbUrl = Deno.env.get('SUPABASE_DB_URL') @@ -275,6 +386,15 @@ async function handleSetupPost(req: Request): Promise { // --------------------------------------------------------------------------- async function handleSetupGet(_req: Request): Promise { + const ctx = extractAuthContext(req) + if (ctx instanceof Response) return ctx + const { projectRef, accessToken } = ctx + + const supabaseAuthError = await authenticateWithSupabase(projectRef, accessToken) + if (supabaseAuthError) { + return new Response(supabaseAuthError, { status: 401 }) + } + const dbUrl = Deno.env.get('SUPABASE_DB_URL') if (!dbUrl) { return jsonResponse({ error: 'SUPABASE_DB_URL not set' }, 500) @@ -353,6 +473,11 @@ async function handleSetupDelete(req: Request): Promise { if (ctx instanceof Response) return ctx const { projectRef, accessToken } = ctx + const isValid = await validateAccessToken(projectRef, accessToken) + if (!isValid) { + return new Response('Forbidden: Invalid access token for this project', { status: 403 }) + } + let pool: pg.Pool | null = null try { const dbUrl = Deno.env.get('SUPABASE_DB_URL')