diff --git a/app/v1/installations/[installationId]/resources/import/route.ts b/app/v1/installations/[installationId]/resources/import/route.ts new file mode 100644 index 0000000..4d4fffa --- /dev/null +++ b/app/v1/installations/[installationId]/resources/import/route.ts @@ -0,0 +1,121 @@ +import { provisionResource } from "@/lib/partner"; +import { readRequestBodyWithSchema } from "@/lib/utils"; +import { withAuth } from "@/lib/vercel/auth"; +import { resourceImportRequestSchema } from "@/lib/vercel/schemas"; + +export const POST = withAuth(async (claims, request) => { + const result = await readRequestBodyWithSchema( + request, + resourceImportRequestSchema + ); + + if (!result.success) { + return Response.json({ + error: { + type: "validation_error", + message: "Invalid request body", + }, + }); + } + + const importedResources = []; + + for (const resource of result.data.resources) { + const importedResource = await provisionResource( + claims.installation_id, + { + productId: result.data.productId, + name: resource.name, + metadata: {}, + billingPlanId: "default", + }, + resource.id + ); + + importedResources.push({ + id: importedResource.id, + secrets: buildSecrets(result.data.productId), + status: "ready", + }); + } + + return Response.json({ + resources: importedResources, + }); +}); + +function buildSecrets( + productId: string +): { name: string; value: string; prefix?: string }[] { + switch (productId) { + case "postgres": + return [ + { + name: "URL", + prefix: "POSTGRES", + value: "postgres://neon.tech", + }, + { + name: "URL_NON_POOLING", + prefix: "POSTGRES", + value: "postgres://neon.tech?non-pooling=true", + }, + { + name: "URL_NO_SSL", + prefix: "POSTGRES", + value: "postgres://neon.tech?no-ssl=true", + }, + { + name: "PRISMA_URL", + prefix: "POSTGRES", + value: "postgres://neon.tech?prisma=true", + }, + { + name: "USER", + prefix: "POSTGRES", + value: "foobar", + }, + { + name: "USER", + prefix: "POSTGRES", + value: "foobar", + }, + { + name: "PASSWORD", + prefix: "POSTGRES", + value: "password", + }, + { + name: "HOST", + prefix: "POSTGRES", + value: "neon.tech", + }, + { + name: "DATABASE", + prefix: "POSTGRES", + value: "verceldb", + }, + { + name: "CUSTOM_ENV_VAR", + value: "my-secret", + }, + ]; + case "redis": + return [ + { name: "URL", prefix: "KV", value: "redis://upstash.com" }, + { name: "REST_API_URL", prefix: "KV", value: "https://upstash.com" }, + { name: "REST_API_TOKEN", prefix: "KV", value: "foobar-token" }, + { + name: "REST_API_READ_ONLY_TOKEN", + prefix: "KV", + value: "https://upstash.com/read-only", + }, + { + name: "CUSTOM_ENV_VAR", + value: "my-secret", + }, + ]; + default: + throw new Error(`Unsupported product id '${productId}'`); + } +} diff --git a/lib/partner/index.ts b/lib/partner/index.ts index 1e8a963..5ce20ce 100644 --- a/lib/partner/index.ts +++ b/lib/partner/index.ts @@ -16,6 +16,7 @@ import { } from "@/lib/vercel/schemas"; import { kv } from "@vercel/kv"; import { compact } from "lodash"; +import { z } from "zod"; const billingPlans: BillingPlan[] = [ { @@ -112,14 +113,15 @@ export async function listInstallations(): Promise { export async function provisionResource( installationId: string, - request: ProvisionResourceRequest + request: ProvisionResourceRequest, + id: string = nanoid() ): Promise { const billingPlan = billingPlanMap.get(request.billingPlanId); if (!billingPlan) { throw new Error(`Unknown billing plan ${request.billingPlanId}`); } const resource = { - id: nanoid(), + id, status: "ready", name: request.name, billingPlan, @@ -141,6 +143,7 @@ export async function provisionResource( { name: "TOP_SECRET", value: `birds aren't real (${new Date().toISOString()})`, + prefix: "SUPER", }, ], }; diff --git a/lib/vercel/auth.ts b/lib/vercel/auth.ts index b86fdfa..3679596 100644 --- a/lib/vercel/auth.ts +++ b/lib/vercel/auth.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { env } from "../env"; import { JWTExpired, JWTInvalid } from "jose/errors"; +import { createDecipheriv } from "crypto"; export interface OidcClaims { sub: string; @@ -26,7 +27,12 @@ export function withAuth( ): (req: NextRequest, ...rest: any[]) => Promise { return async (req: NextRequest, ...rest: any[]): Promise => { try { - const token = getAuthorizationToken(req); + const token = await getRequestAuthJWT(req); + + if (!token) { + throw new AuthError("Invalid Authorization header, no token found"); + } + const claims = await verifyToken(token); return callback(claims, req, ...rest); @@ -71,15 +77,47 @@ export async function verifyToken(token: string): Promise { } } -function getAuthorizationToken(req: Request): string { +function getRequestAuthJWT(req: Request): string | null { + switch (req.headers.get("x-vercel-auth")) { + case "shared-secret": + return getSharedSecretAuthorizationToken(req); + + default: + return getBearerAuthorizationToken(req); + } +} + +function getSharedSecretAuthorizationToken(req: Request): string | null { + const token = getBearerAuthorizationToken(req); + + if (!token) { + return null; + } + + return decryptAuthToken(env.INTEGRATION_CLIENT_SECRET, token); +} + +function getBearerAuthorizationToken(req: Request): string | null { const authHeader = req.headers.get("Authorization"); const match = authHeader?.match(/^bearer (.+)$/i); if (!match) { - throw new AuthError("Invalid Authorization header"); + return null; } return match[1]; } +function decryptAuthToken(clientSecret: string, token: string): string { + const [hexIv, hexCipherText] = token.split("."); + const iv = Buffer.from(hexIv, "hex"); + const decipher = createDecipheriv( + "aes-192-cbc", + Buffer.from(clientSecret), + iv + ); + + return decipher.update(hexCipherText, "hex", "utf8") + decipher.final("utf8"); +} + class AuthError extends Error {} diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts index f1fb834..dc1d99b 100644 --- a/lib/vercel/schemas.ts +++ b/lib/vercel/schemas.ts @@ -181,7 +181,13 @@ export type ProvisionResourceRequest = z.infer< >; export const provisionResourceResponseSchema = resourceSchema.extend({ - secrets: z.array(z.object({ name: z.string(), value: z.string() })), + secrets: z.array( + z.object({ + name: z.string(), + value: z.string(), + prefix: z.string().optional(), + }) + ), }); export type ProvisionResourceResponse = z.infer< @@ -532,3 +538,14 @@ export const unknownWebhookEventSchema = webhookEventBaseSchema.extend({ payload: z.unknown(), unknown: z.boolean().optional().default(true), }); + +export type ResourceImportRequest = z.infer; +export const resourceImportRequestSchema = z.object({ + productId: z.string(), + resources: z.array( + z.object({ + id: z.string(), + name: z.string(), + }) + ), +});