From 2ff4683ebf6df23edba14528451c7dc59327986f Mon Sep 17 00:00:00 2001 From: Adrian Cooney Date: Thu, 17 Oct 2024 13:06:31 +0100 Subject: [PATCH 1/5] Add import route --- .../resources/import/route.ts | 130 ++++++++++++++++++ lib/partner/index.ts | 6 +- lib/vercel/auth.ts | 20 ++- lib/vercel/schemas.ts | 11 ++ 4 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 app/v1/installations/[installationId]/resources/import/route.ts 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..dba4032 --- /dev/null +++ b/app/v1/installations/[installationId]/resources/import/route.ts @@ -0,0 +1,130 @@ +import { provisionResource } from "@/lib/partner"; +import { readRequestBodyWithSchema } from "@/lib/utils"; +import { getBearerAuthorizationToken, 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: "imported-resource", + }, + resource.id + ); + + importedResources.push({ + id: importedResource.id, + secrets: buildSecrets(result.data.productId), + }); + } + + return Response.json({ + resources: importedResources, + }); + }, + { + getAuthorizationToken(request) { + const bearerToken = getBearerAuthorizationToken(request); + + // TODO: decrypt token using CLIENT_SECRET + return bearerToken; + }, + } +); + +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..b3caf46 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, diff --git a/lib/vercel/auth.ts b/lib/vercel/auth.ts index b86fdfa..57fbfa7 100644 --- a/lib/vercel/auth.ts +++ b/lib/vercel/auth.ts @@ -22,11 +22,23 @@ export function withAuth( claims: OidcClaims, req: NextRequest, ...rest: any[] - ) => Promise + ) => Promise, + { + getAuthorizationToken = getBearerAuthorizationToken, + }: { + getAuthorizationToken?: ( + request: NextRequest + ) => string | null | Promise; + } = {} ): (req: NextRequest, ...rest: any[]) => Promise { return async (req: NextRequest, ...rest: any[]): Promise => { try { - const token = getAuthorizationToken(req); + const token = await getAuthorizationToken(req); + + if (!token) { + throw new AuthError("Invalid Authorization header, no token found"); + } + const claims = await verifyToken(token); return callback(claims, req, ...rest); @@ -71,12 +83,12 @@ export async function verifyToken(token: string): Promise { } } -function getAuthorizationToken(req: Request): string { +export 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]; diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts index f1fb834..d5e6098 100644 --- a/lib/vercel/schemas.ts +++ b/lib/vercel/schemas.ts @@ -532,3 +532,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(), + }) + ), +}); From a035f31911dbeebf1b43c2663fcb3bb290253a10 Mon Sep 17 00:00:00 2001 From: Adrian Cooney Date: Thu, 17 Oct 2024 13:33:25 +0100 Subject: [PATCH 2/5] Use default billing plan --- app/v1/installations/[installationId]/resources/import/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/v1/installations/[installationId]/resources/import/route.ts b/app/v1/installations/[installationId]/resources/import/route.ts index dba4032..d2e05c0 100644 --- a/app/v1/installations/[installationId]/resources/import/route.ts +++ b/app/v1/installations/[installationId]/resources/import/route.ts @@ -28,7 +28,7 @@ export const POST = withAuth( productId: result.data.productId, name: resource.name, metadata: {}, - billingPlanId: "imported-resource", + billingPlanId: "default", }, resource.id ); From c7892e9ca63ddc0070fd2ce4e96e4e5086f58975 Mon Sep 17 00:00:00 2001 From: Adrian Cooney Date: Thu, 17 Oct 2024 14:24:37 +0100 Subject: [PATCH 3/5] Add status --- app/v1/installations/[installationId]/resources/import/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/v1/installations/[installationId]/resources/import/route.ts b/app/v1/installations/[installationId]/resources/import/route.ts index d2e05c0..1053a7e 100644 --- a/app/v1/installations/[installationId]/resources/import/route.ts +++ b/app/v1/installations/[installationId]/resources/import/route.ts @@ -36,6 +36,7 @@ export const POST = withAuth( importedResources.push({ id: importedResource.id, secrets: buildSecrets(result.data.productId), + status: "ready", }); } From bd963651baf306829742ca87134da4e63a3195ef Mon Sep 17 00:00:00 2001 From: Adrian Cooney Date: Fri, 25 Oct 2024 15:24:16 +0100 Subject: [PATCH 4/5] Add shared-secret auth --- .../resources/import/route.ts | 80 ++++++++----------- lib/vercel/auth.ts | 46 ++++++++--- 2 files changed, 71 insertions(+), 55 deletions(-) diff --git a/app/v1/installations/[installationId]/resources/import/route.ts b/app/v1/installations/[installationId]/resources/import/route.ts index 1053a7e..4d4fffa 100644 --- a/app/v1/installations/[installationId]/resources/import/route.ts +++ b/app/v1/installations/[installationId]/resources/import/route.ts @@ -1,58 +1,48 @@ import { provisionResource } from "@/lib/partner"; import { readRequestBodyWithSchema } from "@/lib/utils"; -import { getBearerAuthorizationToken, withAuth } from "@/lib/vercel/auth"; +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", - }, - }); - } +export const POST = withAuth(async (claims, request) => { + const result = await readRequestBodyWithSchema( + request, + resourceImportRequestSchema + ); - const importedResources = []; + if (!result.success) { + return Response.json({ + error: { + type: "validation_error", + message: "Invalid request body", + }, + }); + } - 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 - ); + const importedResources = []; - importedResources.push({ - id: importedResource.id, - secrets: buildSecrets(result.data.productId), - status: "ready", - }); - } + 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 + ); - return Response.json({ - resources: importedResources, + importedResources.push({ + id: importedResource.id, + secrets: buildSecrets(result.data.productId), + status: "ready", }); - }, - { - getAuthorizationToken(request) { - const bearerToken = getBearerAuthorizationToken(request); - - // TODO: decrypt token using CLIENT_SECRET - return bearerToken; - }, } -); + + return Response.json({ + resources: importedResources, + }); +}); function buildSecrets( productId: string diff --git a/lib/vercel/auth.ts b/lib/vercel/auth.ts index 57fbfa7..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; @@ -22,18 +23,11 @@ export function withAuth( claims: OidcClaims, req: NextRequest, ...rest: any[] - ) => Promise, - { - getAuthorizationToken = getBearerAuthorizationToken, - }: { - getAuthorizationToken?: ( - request: NextRequest - ) => string | null | Promise; - } = {} + ) => Promise ): (req: NextRequest, ...rest: any[]) => Promise { return async (req: NextRequest, ...rest: any[]): Promise => { try { - const token = await getAuthorizationToken(req); + const token = await getRequestAuthJWT(req); if (!token) { throw new AuthError("Invalid Authorization header, no token found"); @@ -83,7 +77,27 @@ export async function verifyToken(token: string): Promise { } } -export function getBearerAuthorizationToken(req: Request): string | null { +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); @@ -94,4 +108,16 @@ export function getBearerAuthorizationToken(req: Request): string | 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 {} From abde33d7061226f94e951c40383a24d76d18a3f0 Mon Sep 17 00:00:00 2001 From: Adrian Cooney Date: Tue, 26 Nov 2024 11:36:35 +0000 Subject: [PATCH 5/5] Add secret prefix --- lib/partner/index.ts | 1 + lib/vercel/schemas.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/partner/index.ts b/lib/partner/index.ts index b3caf46..5ce20ce 100644 --- a/lib/partner/index.ts +++ b/lib/partner/index.ts @@ -143,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/schemas.ts b/lib/vercel/schemas.ts index d5e6098..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<