From 3bf2c612481747e330930109e1c549ecb04898f1 Mon Sep 17 00:00:00 2001 From: Kalil Smith-Nuevelle Date: Fri, 8 Sep 2023 09:48:35 -0500 Subject: [PATCH 1/2] Add basic auth to integrations api --- core/lib/contracts/resources/integrations.ts | 239 ++++++++++--------- core/pages/api/v0/[...ts-rest].ts | 52 +++- 2 files changed, 167 insertions(+), 124 deletions(-) diff --git a/core/lib/contracts/resources/integrations.ts b/core/lib/contracts/resources/integrations.ts index 74f56eaec0..a7e4990060 100644 --- a/core/lib/contracts/resources/integrations.ts +++ b/core/lib/contracts/resources/integrations.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; import { initContract } from "@ts-rest/core"; +import { z } from "zod"; const contract = initContract(); @@ -28,127 +28,132 @@ export type PubFieldsResponse = z.infer; export type PubPostBody = z.infer; export type SuggestedMember = z.infer; -export const integrationsApi = contract.router({ - auth: { - headers: z.object({ - authorization: z.string(), - }), - method: "GET", - path: "/integrations/:instanceId/auth", - summary: "Authenticate a user and receive basic information about them", - description: - "Integrations can use this endpoint to exchange a PubPub community member's auth token for information about them.", - pathParams: z.object({ - instanceId: z.string(), - }), - responses: { - 200: UserSchema, +export const integrationsApi = contract.router( + { + auth: { + method: "GET", + path: "/:instanceId/auth", + summary: "Authenticate a user and receive basic information about them", + description: + "Integrations can use this endpoint to exchange a PubPub community member's auth token for information about them.", + pathParams: z.object({ + instanceId: z.string(), + }), + responses: { + 200: UserSchema, + }, }, - }, - createPub: { - method: "POST", - path: "/integrations/:instanceId/pubs", - summary: "Creates a new pub", - description: "A way to create a new pub", - body: PubPostSchema, - pathParams: z.object({ - instanceId: z.string(), - }), - responses: { - 200: PubFieldsSchema, - 404: z.object({ message: z.string() }), + createPub: { + method: "POST", + path: "/:instanceId/pubs", + summary: "Creates a new pub", + description: "A way to create a new pub", + body: PubPostSchema, + pathParams: z.object({ + instanceId: z.string(), + }), + responses: { + 200: PubFieldsSchema, + 404: z.object({ message: z.string() }), + }, }, - }, - getPub: { - method: "GET", - path: "/integrations/:instanceId/pubs/:pubId", - summary: "Gets a pub", - description: "A way to get pubs fields for an integration instance", - pathParams: z.object({ - pubId: z.string(), - instanceId: z.string(), - }), - responses: { - 200: z.array(PubFieldsSchema), + getPub: { + method: "GET", + path: "/:instanceId/pubs/:pubId", + summary: "Gets a pub", + description: "A way to get pubs fields for an integration instance", + pathParams: z.object({ + pubId: z.string(), + instanceId: z.string(), + }), + responses: { + 200: z.array(PubFieldsSchema), + }, }, - }, - getAllPubs: { - method: "GET", - path: "/integrations/:instanceId/pubs", - summary: "Gets all pubs for this instance", - description: "A way to get all pubs for an integration instance", - pathParams: z.object({ - instanceId: z.string(), - }), - responses: { - 200: z.array(PubFieldsSchema), + getAllPubs: { + method: "GET", + path: "/:instanceId/pubs", + summary: "Gets all pubs for this instance", + description: "A way to get all pubs for an integration instance", + pathParams: z.object({ + instanceId: z.string(), + }), + responses: { + 200: z.array(PubFieldsSchema), + }, }, - }, - updatePub: { - method: "PATCH", - path: "/integrations/:instanceId/pubs/:pubId", - summary: "Adds field(s) to a pub", - description: "A way to update a field for an existing pub", - body: PubFieldsSchema, - pathParams: z.object({ - pubId: z.string(), - instanceId: z.string(), - }), - responses: { - 200: PubFieldsSchema, + updatePub: { + method: "PATCH", + path: "/:instanceId/pubs/:pubId", + summary: "Adds field(s) to a pub", + description: "A way to update a field for an existing pub", + body: PubFieldsSchema, + pathParams: z.object({ + pubId: z.string(), + instanceId: z.string(), + }), + responses: { + 200: PubFieldsSchema, + }, }, - }, - getSuggestedMembers: { - method: "GET", - path: "/integrations/:instanceId/autosuggest/members/:memberCandidateString", - summary: "autosuggest member", - description: - "A way to autosuggest members so that integrations users can find users or verify they exist. Will return a name for ", - pathParams: z.object({ - memberCandidateString: z.string(), - instanceId: z.string(), - }), - responses: { - 200: z.array(SuggestedMembersSchema), + getSuggestedMembers: { + method: "GET", + path: "/:instanceId/autosuggest/members/:memberCandidateString", + summary: "autosuggest member", + description: + "A way to autosuggest members so that integrations users can find users or verify they exist. Will return a name for ", + pathParams: z.object({ + memberCandidateString: z.string(), + instanceId: z.string(), + }), + responses: { + 200: z.array(SuggestedMembersSchema), + }, }, - }, - sendEmail: { - method: "POST", - path: "/integrations/:instanceId/email", - summary: "Send an email from PubPub to a new or existing PubPub user", - description: - "Recipient can be an existing pubpub user identified by ID, or a new user who must be identified by email and name.", - body: z.object({ - to: z.union([ - z.object({ - userId: z.string(), - }), - z.object({ - email: z.string(), - name: z.string(), - }), - ]), - subject: z.string(), - message: z.string(), - }), - pathParams: z.object({ - instanceId: z.string(), - }), - responses: { - 200: z.undefined(), + sendEmail: { + method: "POST", + path: "/:instanceId/email", + summary: "Send an email from PubPub to a new or existing PubPub user", + description: + "Recipient can be an existing pubpub user identified by ID, or a new user who must be identified by email and name.", + body: z.object({ + to: z.union([ + z.object({ + userId: z.string(), + }), + z.object({ + email: z.string(), + name: z.string(), + }), + ]), + subject: z.string(), + message: z.string(), + }), + pathParams: z.object({ + instanceId: z.string(), + }), + responses: { + 200: z.undefined(), + }, }, + // TODO implement these endpoints + // getAllMembers: { + // method: "GET", + // path: "integrations/:instanceId/members", + // summary: "Gets all members for this instance", + // description: "A way to get all members for an integration instance", + // pathParams: z.object({ + // instanceId: z.string(), + // }), + // responses: { + // 200: z.array(SuggestedMembersSchema), + // }, + // }, }, - // TODO implement these endpoints - // getAllMembers: { - // method: "GET", - // path: "integrations/:instanceId/members", - // summary: "Gets all members for this instance", - // description: "A way to get all members for an integration instance", - // pathParams: z.object({ - // instanceId: z.string(), - // }), - // responses: { - // 200: z.array(SuggestedMembersSchema), - // }, - // }, -}); + { + pathPrefix: "/integrations", + baseHeaders: z.object({ + authorization: z.string(), + }), + } +); diff --git a/core/pages/api/v0/[...ts-rest].ts b/core/pages/api/v0/[...ts-rest].ts index a7aa22b668..17fef2c28b 100644 --- a/core/pages/api/v0/[...ts-rest].ts +++ b/core/pages/api/v0/[...ts-rest].ts @@ -1,7 +1,16 @@ import { createNextRoute, createNextRouter } from "@ts-rest/next"; import { type NextApiRequest, type NextApiResponse } from "next/types"; +import crypto from "node:crypto"; import { api } from "~/lib/contracts"; -import { getPub, getMembers, updatePub, createPub, HTTPStatusError } from "~/lib/server"; +import { + BadRequestError, + HTTPStatusError, + UnauthorizedError, + createPub, + getMembers, + getPub, + updatePub, +} from "~/lib/server"; import { emailUser } from "~/lib/server/email"; import { validateToken } from "~/lib/server/token"; @@ -15,34 +24,63 @@ const handleErrors = (error: unknown, req: NextApiRequest, res: NextApiResponse) return res.status(500).json({ message: "Internal Server Error" }); }; +const getBearerToken = (authHeader: string) => { + const parts = authHeader.split("Bearer "); + if (parts.length !== 2) { + throw new BadRequestError("Unable to parse authorization header"); + } + return parts[1]; +}; + +const checkApiKey = (apiKey: string) => { + const serverKey = process.env.API_KEY; + if (!serverKey) { + return; + } + if ( + serverKey && + serverKey.length === apiKey.length && + crypto.timingSafeEqual(Buffer.from(serverKey), Buffer.from(apiKey)) + ) { + return; + } + + throw new UnauthorizedError("Invalid API key"); +}; + // TODO: verify pub belongs to integrationInstance probably in some middleware // TODO: verify token in header const integrationsRouter = createNextRoute(api.integrations, { - createPub: async ({ params, body }) => { + createPub: async ({ headers, params, body }) => { + checkApiKey(getBearerToken(headers.authorization)); const pub = await createPub(params.instanceId, body); return { status: 200, body: pub }; }, - getPub: async ({ params }) => { + getPub: async ({ headers, params }) => { + checkApiKey(getBearerToken(headers.authorization)); const pubFieldValuePairs = await getPub(params.pubId); return { status: 200, body: pubFieldValuePairs, }; }, - getAllPubs: async ({ params }) => { + getAllPubs: async ({ headers }) => { + checkApiKey(getBearerToken(headers.authorization)); return { status: 200, body: [{ message: "This is not implemented" }], }; }, - updatePub: async ({ params, body }) => { + updatePub: async ({ headers, params, body }) => { + checkApiKey(getBearerToken(headers.authorization)); const updatedPub = await updatePub(params.pubId, body); return { status: 200, body: updatedPub, }; }, - getSuggestedMembers: async ({ params }) => { + getSuggestedMembers: async ({ headers, params }) => { + checkApiKey(getBearerToken(headers.authorization)); const member = await getMembers(params.memberCandidateString); return { status: 200, @@ -50,7 +88,7 @@ const integrationsRouter = createNextRoute(api.integrations, { }; }, auth: async ({ headers }) => { - const token = headers.authorization.split("Bearer ")[1]; + const token = getBearerToken(headers.authorization); const user = await validateToken(token); return { status: 200, From f48c138d9ed1449d538bf46bbbe06d3ca6675d8f Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 8 Sep 2023 16:19:06 -0400 Subject: [PATCH 2/2] Use api key in sdk; add env examples to integrations --- integrations/evaluations/.env.template | 3 +++ integrations/submissions/.env.template | 3 +++ .../submissions/app/actions/submit/actions.ts | 4 ++-- .../submissions/app/actions/submit/page.tsx | 2 +- .../submissions/app/actions/submit/submit.tsx | 3 +-- integrations/submissions/lib/pubpub.ts | 3 ++- packages/sdk/src/lib/client.ts | 17 +++++++---------- 7 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 integrations/evaluations/.env.template create mode 100644 integrations/submissions/.env.template diff --git a/integrations/evaluations/.env.template b/integrations/evaluations/.env.template new file mode 100644 index 0000000000..836da02a4d --- /dev/null +++ b/integrations/evaluations/.env.template @@ -0,0 +1,3 @@ +API_TOKEN= +PUBPUB_URL="http://localhost:3000" +REDIS_CONNECTION_STRING="redis://localhost:6379" diff --git a/integrations/submissions/.env.template b/integrations/submissions/.env.template new file mode 100644 index 0000000000..836da02a4d --- /dev/null +++ b/integrations/submissions/.env.template @@ -0,0 +1,3 @@ +API_TOKEN= +PUBPUB_URL="http://localhost:3000" +REDIS_CONNECTION_STRING="redis://localhost:6379" diff --git a/integrations/submissions/app/actions/submit/actions.ts b/integrations/submissions/app/actions/submit/actions.ts index 3be3c14987..3f8fbe5d9f 100644 --- a/integrations/submissions/app/actions/submit/actions.ts +++ b/integrations/submissions/app/actions/submit/actions.ts @@ -6,12 +6,12 @@ import { assert, expect } from "utils"; import { client } from "~/lib/pubpub"; import { findInstance } from "~/lib/instance"; -export async function submit(form: FormData, token: string) { +export async function submit(form: FormData) { try { const { "instance-id": instanceId, ...pub } = Object.fromEntries(form); assert(typeof instanceId === "string"); const instance = expect(await findInstance(instanceId)); - return client.create(instanceId, token, pub as Pub, instance.pubTypeId); + return client.create(instanceId, pub as Pub, instance.pubTypeId); } catch (error) { return { error: error.message }; } diff --git a/integrations/submissions/app/actions/submit/page.tsx b/integrations/submissions/app/actions/submit/page.tsx index eaff0ac058..205e11a108 100644 --- a/integrations/submissions/app/actions/submit/page.tsx +++ b/integrations/submissions/app/actions/submit/page.tsx @@ -16,7 +16,7 @@ export default async function Page(props: Props) {

Hello {user.name}

- +
); } diff --git a/integrations/submissions/app/actions/submit/submit.tsx b/integrations/submissions/app/actions/submit/submit.tsx index 43d8429f60..c4642dfada 100644 --- a/integrations/submissions/app/actions/submit/submit.tsx +++ b/integrations/submissions/app/actions/submit/submit.tsx @@ -5,7 +5,6 @@ import { submit } from "./actions"; type Props = { instanceId: string; - token: string; }; export function Submit(props: Props) { @@ -13,7 +12,7 @@ export function Submit(props: Props) { const [isPending, startTransition] = useTransition(); async function onSubmit(form: FormData) { - const response = await submit(form, props.token); + const response = await submit(form); setMessage("error" in response ? response.error : "Pub submitted!"); } diff --git a/integrations/submissions/lib/pubpub.ts b/integrations/submissions/lib/pubpub.ts index 5adeef5760..9f2482af5c 100644 --- a/integrations/submissions/lib/pubpub.ts +++ b/integrations/submissions/lib/pubpub.ts @@ -1,4 +1,5 @@ import { makeClient } from "@pubpub/sdk"; import manifest from "pubpub-integration.json"; +import { expect } from "utils"; -export const client = makeClient(manifest); +export const client = makeClient(manifest, expect(process.env.API_KEY)); diff --git a/packages/sdk/src/lib/client.ts b/packages/sdk/src/lib/client.ts index 9e8f737c7c..26e7f9e88c 100644 --- a/packages/sdk/src/lib/client.ts +++ b/packages/sdk/src/lib/client.ts @@ -44,19 +44,16 @@ export type Client = { auth(instanceId: string, token: string): Promise; create( instanceId: string, - token: string, pub: Pub, pubTypeId: string ): Promise>; read>( instanceId: string, - token: string, pubId: string, ...fields: U ): Promise>; update( instanceId: string, - token: string, pubId: string, patch: Patch ): Promise>; @@ -113,7 +110,7 @@ const makeRequest = async ( throw new ResponseError(response, "Failed to connect to PubPub"); }; -export const makeClient = (manifest: T): Client => { +export const makeClient = (manifest: T, apiKey: string): Client => { const write = new Set(manifest.write ? Object.keys(manifest.write) : null); const read = new Set(manifest.read ? [write.values(), ...Object.keys(manifest.read)] : write); return { @@ -130,14 +127,14 @@ export const makeClient = (manifest: T): Client => { throw new PubPubError("Failed to authenticate user or integration", { cause }); } }, - async create(instanceId, token, pub, pubTypeId) { + async create(instanceId, pub, pubTypeId) { for (let field in pub) { if (!write.has(field)) { throw new ValidationError(`Field ${field} is not writeable`); } } try { - return makeRequest(instanceId, token, "POST", "pubs", { + return makeRequest(instanceId, apiKey, "POST", "pubs", { pubTypeId, pubFields: pub, }); @@ -145,26 +142,26 @@ export const makeClient = (manifest: T): Client => { throw new PubPubError("Failed to create Pub", { cause }); } }, - async read(instanceId, token, pubId, ...fields) { + async read(instanceId, pubId, ...fields) { try { for (let i = 0; i < fields.length; i++) { if (!read.has(fields[i])) { throw new ValidationError(`Field ${fields[i]} is not readable`); } } - return makeRequest(instanceId, token, "GET", "pubs", pubId); + return makeRequest(instanceId, apiKey, "GET", "pubs", pubId); } catch (cause) { throw new PubPubError("Failed to get Pub", { cause }); } }, - async update(instanceId, token, pubId, patch) { + async update(instanceId, pubId, patch) { try { for (const field in patch) { if (!write.has(field)) { throw new ValidationError(`Field ${field} is not writeable`); } } - return makeRequest(instanceId, token, "PATCH", `pubs/${pubId}`, patch); + return makeRequest(instanceId, apiKey, "PATCH", `pubs/${pubId}`, patch); } catch (cause) { throw new PubPubError("Failed to update Pub", { cause }); }