From c0ffd825e2f4ee2256a157fd085fb624dcede625 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Thu, 21 May 2026 17:31:47 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Google=20Sheets=20OAuth=20?= =?UTF-8?q?callback=20authorization=20(#2501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Secure Google Sheets OAuth state with a signed payload, expiry, user binding, and HttpOnly nonce cookie. - Enforce workspace and typebot write authorization before generating consent URLs and before callback side effects. - Scope Google Sheets credential creation and typebot updates in a transaction, and clear the OAuth state cookie after callback. - Add OAuth state verification to the Forge popup flow and centralize OAuth block definition lookup. - Add tests for signed Google Sheets OAuth state parsing and redirect sanitization. --- ...getAuthorizedGoogleSheetsOAuthResources.ts | 64 ++++++++ .../api/googleSheetsOAuthState.test.ts | 110 +++++++++++++ .../api/googleSheetsOAuthState.ts | 144 ++++++++++++++++++ .../googleSheets/api/handleGetConsentUrl.ts | 30 +++- .../googleSheets/api/handleHandleCallback.ts | 134 ++++++++-------- .../integrations/googleSheets/api/router.ts | 4 +- .../api/getOAuthBlockDefinition.ts | 25 +++ .../credentials/api/handleAuthorizeOAuth.ts | 19 +-- .../api/handleCreateOAuthCredentials.ts | 8 +- .../api/handleUpdateOAuthCredentials.ts | 8 +- .../components/credentials/useOAuthPopup.ts | 15 +- apps/builder/src/pages/oauth/redirect.tsx | 11 +- 12 files changed, 473 insertions(+), 99 deletions(-) create mode 100644 apps/builder/src/features/blocks/integrations/googleSheets/api/getAuthorizedGoogleSheetsOAuthResources.ts create mode 100644 apps/builder/src/features/blocks/integrations/googleSheets/api/googleSheetsOAuthState.test.ts create mode 100644 apps/builder/src/features/blocks/integrations/googleSheets/api/googleSheetsOAuthState.ts create mode 100644 apps/builder/src/features/credentials/api/getOAuthBlockDefinition.ts diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/api/getAuthorizedGoogleSheetsOAuthResources.ts b/apps/builder/src/features/blocks/integrations/googleSheets/api/getAuthorizedGoogleSheetsOAuthResources.ts new file mode 100644 index 000000000..529ef8a52 --- /dev/null +++ b/apps/builder/src/features/blocks/integrations/googleSheets/api/getAuthorizedGoogleSheetsOAuthResources.ts @@ -0,0 +1,64 @@ +import { ORPCError } from "@orpc/server"; +import prisma from "@typebot.io/prisma"; +import type { User } from "@typebot.io/user/schemas"; +import { isWriteTypebotForbidden } from "@/features/typebot/helpers/isWriteTypebotForbidden"; +import { isWriteWorkspaceForbidden } from "@/features/workspace/helpers/isWriteWorkspaceForbidden"; + +export const getAuthorizedGoogleSheetsOAuthResources = async ({ + workspaceId, + typebotId, + user, +}: { + workspaceId: string; + typebotId?: string; + user: Pick; +}) => { + const workspace = await prisma.workspace.findFirst({ + where: { + id: workspaceId, + }, + select: { + id: true, + members: { select: { userId: true, role: true } }, + }, + }); + + if (!workspace || isWriteWorkspaceForbidden(workspace, user)) + throw new ORPCError("NOT_FOUND", { message: "Workspace not found" }); + + if (!typebotId) return { workspace, typebot: null }; + + const typebot = await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspaceId, + }, + select: { + version: true, + groups: true, + workspace: { + select: { + isSuspended: true, + isPastDue: true, + members: { + select: { + userId: true, + role: true, + }, + }, + }, + }, + collaborators: { + select: { + userId: true, + type: true, + }, + }, + }, + }); + + if (!typebot || (await isWriteTypebotForbidden(typebot, user))) + throw new ORPCError("NOT_FOUND", { message: "Typebot not found" }); + + return { workspace, typebot }; +}; diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/api/googleSheetsOAuthState.test.ts b/apps/builder/src/features/blocks/integrations/googleSheets/api/googleSheetsOAuthState.test.ts new file mode 100644 index 000000000..e3fbc2f29 --- /dev/null +++ b/apps/builder/src/features/blocks/integrations/googleSheets/api/googleSheetsOAuthState.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "bun:test"; + +process.env.SKIP_ENV_CHECK = "true"; +process.env.ENCRYPTION_SECRET = "12345678901234567890123456789012"; +process.env.NEXTAUTH_URL = "https://app.typebot.io"; + +const { + clearGoogleSheetsOAuthStateCookie, + createGoogleSheetsOAuthState, + googleSheetsOAuthStateCookieName, + googleSheetsOAuthContextSchema, + parseGoogleSheetsOAuthState, +} = await import("./googleSheetsOAuthState"); + +describe("googleSheetsOAuthState", () => { + it("round trips a signed OAuth state", () => { + const input = googleSheetsOAuthContextSchema.parse({ + redirectUrl: "https://app.typebot.io/typebots/typebot-id/edit?foo=bar", + workspaceId: "workspace-id", + typebotId: "typebot-id", + blockId: "block-id", + }); + + const { state, cookie } = createGoogleSheetsOAuthState({ + input, + userId: "user-id", + }); + + const parsedState = parseGoogleSheetsOAuthState(state); + + expect(parsedState).toMatchObject({ + userId: "user-id", + workspaceId: "workspace-id", + redirectPath: "/typebots/typebot-id/edit", + typebotId: "typebot-id", + blockId: "block-id", + }); + expect(cookie).toContain( + `${googleSheetsOAuthStateCookieName}=${parsedState.nonce}`, + ); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + }); + + it("falls back to the dashboard for off-origin redirects", () => { + const input = googleSheetsOAuthContextSchema.parse({ + redirectUrl: "https://evil.example/typebots/typebot-id/edit", + workspaceId: "workspace-id", + }); + + const { state } = createGoogleSheetsOAuthState({ + input, + userId: "user-id", + }); + + expect(parseGoogleSheetsOAuthState(state).redirectPath).toBe("/typebots"); + }); + + it("falls back to the dashboard for scheme-relative redirect paths", () => { + const input = googleSheetsOAuthContextSchema.parse({ + redirectUrl: "https://app.typebot.io//evil.example/path", + workspaceId: "workspace-id", + }); + + const { state } = createGoogleSheetsOAuthState({ + input, + userId: "user-id", + }); + + expect(parseGoogleSheetsOAuthState(state).redirectPath).toBe("/typebots"); + }); + + it("rejects tampered state payloads", () => { + const input = googleSheetsOAuthContextSchema.parse({ + workspaceId: "workspace-id", + }); + const { state } = createGoogleSheetsOAuthState({ + input, + userId: "user-id", + }); + const [encodedPayload, signature] = state.split("."); + if (!encodedPayload || !signature) throw new Error("Invalid test state"); + + const payload = JSON.parse( + Buffer.from(encodedPayload, "base64url").toString(), + ); + const tamperedPayload = Buffer.from( + JSON.stringify({ ...payload, workspaceId: "other-workspace-id" }), + ).toString("base64url"); + + expect(() => + parseGoogleSheetsOAuthState(`${tamperedPayload}.${signature}`), + ).toThrow(); + }); + + it("requires typebotId and blockId to be provided together", () => { + expect(() => + googleSheetsOAuthContextSchema.parse({ + workspaceId: "workspace-id", + typebotId: "typebot-id", + }), + ).toThrow(); + }); + + it("serializes a clearing cookie", () => { + expect(clearGoogleSheetsOAuthStateCookie()).toContain( + `${googleSheetsOAuthStateCookieName}=; Max-Age=0`, + ); + }); +}); diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/api/googleSheetsOAuthState.ts b/apps/builder/src/features/blocks/integrations/googleSheets/api/googleSheetsOAuthState.ts new file mode 100644 index 000000000..0314e00d1 --- /dev/null +++ b/apps/builder/src/features/blocks/integrations/googleSheets/api/googleSheetsOAuthState.ts @@ -0,0 +1,144 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { ORPCError } from "@orpc/server"; +import { env } from "@typebot.io/env"; +import { z } from "zod"; + +const stateDurationMs = 10 * 60 * 1000; +const stateDurationSeconds = stateDurationMs / 1000; +export const googleSheetsOAuthStateCookieName = + "typebot-google-sheets-oauth-state"; + +export const googleSheetsOAuthContextSchema = z + .object({ + redirectUrl: z.string().optional(), + workspaceId: z.string(), + typebotId: z.string().optional(), + blockId: z.string().optional(), + }) + .refine(({ typebotId, blockId }) => Boolean(typebotId) === Boolean(blockId), { + message: "typebotId and blockId should be provided together", + }); + +const googleSheetsOAuthStatePayloadSchema = z + .object({ + userId: z.string(), + nonce: z.string(), + workspaceId: z.string(), + redirectPath: z + .string() + .startsWith("/") + .refine((path) => !path.startsWith("//")), + typebotId: z.string().optional(), + blockId: z.string().optional(), + expiresAt: z.number().int().positive(), + }) + .refine(({ typebotId, blockId }) => Boolean(typebotId) === Boolean(blockId), { + message: "typebotId and blockId should be provided together", + }); + +type GoogleSheetsOAuthStatePayload = z.infer< + typeof googleSheetsOAuthStatePayloadSchema +>; + +export const createGoogleSheetsOAuthState = ({ + input, + userId, +}: { + input: z.infer; + userId: string; +}) => { + const nonce = randomBytes(32).toString("base64url"); + const encodedPayload = Buffer.from( + JSON.stringify({ + userId, + nonce, + workspaceId: input.workspaceId, + redirectPath: getSafeRedirectPath(input.redirectUrl), + typebotId: input.typebotId, + blockId: input.blockId, + expiresAt: Date.now() + stateDurationMs, + }), + ).toString("base64url"); + + return { + state: `${encodedPayload}.${createStateSignature(encodedPayload)}`, + cookie: createGoogleSheetsOAuthStateCookie(nonce), + }; +}; + +export const parseGoogleSheetsOAuthState = ( + state: string, +): GoogleSheetsOAuthStatePayload => { + try { + const stateParts = state.split("."); + const encodedPayload = stateParts[0]; + const signature = stateParts[1]; + + if (stateParts.length !== 2 || !encodedPayload || !signature) + throwInvalidOAuthState(); + + if (!hasValidSignature(encodedPayload, signature)) throwInvalidOAuthState(); + + const payload = googleSheetsOAuthStatePayloadSchema.parse( + JSON.parse(Buffer.from(encodedPayload, "base64url").toString()), + ); + + if (payload.expiresAt < Date.now()) throwInvalidOAuthState(); + + return payload; + } catch { + return throwInvalidOAuthState(); + } +}; + +export const clearGoogleSheetsOAuthStateCookie = () => + createGoogleSheetsOAuthStateCookie("", 0); + +const getSafeRedirectPath = (redirectUrl: string | undefined) => { + if (!redirectUrl) return "/typebots"; + + try { + const appUrl = new URL(env.NEXTAUTH_URL); + const url = new URL(redirectUrl, appUrl); + if (url.origin !== appUrl.origin) return "/typebots"; + if (url.pathname.startsWith("//")) return "/typebots"; + return url.pathname; + } catch { + return "/typebots"; + } +}; + +const createStateSignature = (encodedPayload: string) => + createHmac("sha256", env.ENCRYPTION_SECRET) + .update(encodedPayload) + .digest("base64url"); + +const hasValidSignature = (encodedPayload: string, signature: string) => { + const expectedSignature = Buffer.from(createStateSignature(encodedPayload)); + const candidateSignature = Buffer.from(signature); + if (expectedSignature.byteLength !== candidateSignature.byteLength) + return false; + return timingSafeEqual(expectedSignature, candidateSignature); +}; + +const createGoogleSheetsOAuthStateCookie = ( + value: string, + maxAge = stateDurationSeconds, +) => + [ + `${googleSheetsOAuthStateCookieName}=${value}`, + `Max-Age=${maxAge}`, + "Path=/api/credentials/google-sheets/callback", + "HttpOnly", + "SameSite=Lax", + getSecureCookieAttribute(), + ] + .filter(Boolean) + .join("; "); + +const getSecureCookieAttribute = () => + new URL(env.NEXTAUTH_URL).protocol === "https:" ? "Secure" : ""; + +const throwInvalidOAuthState = (): never => { + throw new ORPCError("BAD_REQUEST", { message: "Invalid OAuth state" }); +}; diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/api/handleGetConsentUrl.ts b/apps/builder/src/features/blocks/integrations/googleSheets/api/handleGetConsentUrl.ts index 32ab5b61c..d27ebb2cc 100644 --- a/apps/builder/src/features/blocks/integrations/googleSheets/api/handleGetConsentUrl.ts +++ b/apps/builder/src/features/blocks/integrations/googleSheets/api/handleGetConsentUrl.ts @@ -1,6 +1,12 @@ import { env } from "@typebot.io/env"; +import type { User } from "@typebot.io/user/schemas"; import { OAuth2Client } from "google-auth-library"; -import { z } from "zod"; +import type { z } from "zod"; +import { getAuthorizedGoogleSheetsOAuthResources } from "./getAuthorizedGoogleSheetsOAuthResources"; +import { + createGoogleSheetsOAuthState, + googleSheetsOAuthContextSchema, +} from "./googleSheetsOAuthState"; export const googleSheetsScopes = [ "https://www.googleapis.com/auth/userinfo.email", @@ -8,32 +14,40 @@ export const googleSheetsScopes = [ "https://www.googleapis.com/auth/drive.file", ]; -export const getConsentUrlInputSchema = z.object({ - redirectUrl: z.string().optional(), - workspaceId: z.string().optional(), - typebotId: z.string().optional(), - blockId: z.string().optional(), -}); +export const getConsentUrlInputSchema = googleSheetsOAuthContextSchema; export const handleGetConsentUrl = async ({ input, + context: { user }, }: { input: z.infer; + context: { user: Pick }; }) => { + await getAuthorizedGoogleSheetsOAuthResources({ + workspaceId: input.workspaceId, + typebotId: input.typebotId, + user, + }); + const oauth2Client = new OAuth2Client( env.GOOGLE_SHEETS_CLIENT_ID, env.GOOGLE_SHEETS_CLIENT_SECRET, `${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`, ); + const { state, cookie } = createGoogleSheetsOAuthState({ + input, + userId: user.id, + }); const url = oauth2Client.generateAuthUrl({ access_type: "offline", scope: googleSheetsScopes, prompt: "consent", - state: Buffer.from(JSON.stringify(input)).toString("base64"), + state, }); return { headers: { location: url, + "set-cookie": cookie, }, }; }; diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/api/handleHandleCallback.ts b/apps/builder/src/features/blocks/integrations/googleSheets/api/handleHandleCallback.ts index a271fd990..ed0880e5d 100644 --- a/apps/builder/src/features/blocks/integrations/googleSheets/api/handleHandleCallback.ts +++ b/apps/builder/src/features/blocks/integrations/googleSheets/api/handleHandleCallback.ts @@ -4,8 +4,16 @@ import { env } from "@typebot.io/env"; import { parseGroups } from "@typebot.io/groups/helpers/parseGroups"; import prisma from "@typebot.io/prisma"; import type { Prisma } from "@typebot.io/prisma/types"; +import type { User } from "@typebot.io/user/schemas"; import { OAuth2Client } from "google-auth-library"; +import { cookies } from "next/headers"; import { z } from "zod"; +import { getAuthorizedGoogleSheetsOAuthResources } from "./getAuthorizedGoogleSheetsOAuthResources"; +import { + clearGoogleSheetsOAuthStateCookie, + googleSheetsOAuthStateCookieName, + parseGoogleSheetsOAuthState, +} from "./googleSheetsOAuthState"; import { googleSheetsScopes } from "./handleGetConsentUrl"; export const handleCallbackInputSchema = z.object({ @@ -15,23 +23,25 @@ export const handleCallbackInputSchema = z.object({ export const handleHandleCallback = async ({ input: { code, state }, + context: { user }, }: { input: z.infer; + context: { user: Pick }; }) => { - const { typebotId, redirectUrl, blockId, workspaceId } = JSON.parse( - Buffer.from(state, "base64").toString(), - ) as { - redirectUrl: string; - workspaceId: string; - typebotId?: string; - blockId?: string; - }; + const oauthState = parseGoogleSheetsOAuthState(state); + const stateCookie = (await cookies()).get(googleSheetsOAuthStateCookieName); - if (!workspaceId) + if (oauthState.userId !== user.id || stateCookie?.value !== oauthState.nonce) throw new ORPCError("BAD_REQUEST", { - message: "workspaceId is required", + message: "Invalid OAuth state", }); + const { typebot } = await getAuthorizedGoogleSheetsOAuthResources({ + workspaceId: oauthState.workspaceId, + typebotId: oauthState.typebotId, + user, + }); + const oauth2Client = new OAuth2Client( env.GOOGLE_SHEETS_CLIENT_ID, env.GOOGLE_SHEETS_CLIENT_SECRET, @@ -64,70 +74,74 @@ export const handleHandleCallback = async ({ const credentials = { name: email, type: "google sheets", - workspaceId, + workspaceId: oauthState.workspaceId, data: encryptedData, iv, } satisfies Prisma.Prisma.CredentialsUncheckedCreateInput; - const { id: credentialsId } = await prisma.credentials.create({ - data: credentials, + await prisma.$transaction(async (tx) => { + const createdCredentials = await tx.credentials.create({ + data: credentials, + select: { + id: true, + }, + }); + + if (!typebot || !oauthState.typebotId || !oauthState.blockId) + return createdCredentials; + + const groups = parseGroups(typebot.groups, { + typebotVersion: typebot.version, + }).map((group) => { + const block = group.blocks.find( + (block) => block.id === oauthState.blockId, + ); + if (!block) return group; + return { + ...group, + blocks: group.blocks.map((block) => { + if (block.id !== oauthState.blockId) return block; + return { + ...block, + options: + "options" in block + ? { ...block.options, credentialsId: createdCredentials.id } + : { + credentialsId: createdCredentials.id, + }, + }; + }), + }; + }); + + await tx.typebot.updateMany({ + where: { + id: oauthState.typebotId, + workspaceId: oauthState.workspaceId, + }, + data: { + groups, + }, + }); + + return createdCredentials; }); - if (!typebotId) { + if (!oauthState.typebotId || !oauthState.blockId) { return { headers: { - location: `${redirectUrl.split("?")[0]}`, + location: oauthState.redirectPath, + "set-cookie": clearGoogleSheetsOAuthStateCookie(), }, }; } - const typebot = await prisma.typebot.findFirst({ - where: { - id: typebotId, - }, - select: { - version: true, - groups: true, - }, - }); - - if (!typebot) - throw new ORPCError("NOT_FOUND", { message: "Typebot not found" }); - - const groups = parseGroups(typebot.groups, { - typebotVersion: typebot.version, - }).map((group) => { - const block = group.blocks.find((block) => block.id === blockId); - if (!block) return group; - return { - ...group, - blocks: group.blocks.map((block) => { - if (block.id !== blockId) return block; - return { - ...block, - options: - "options" in block - ? { ...block.options, credentialsId } - : { - credentialsId, - }, - }; - }), - }; - }); - - await prisma.typebot.updateMany({ - where: { - id: typebotId, - }, - data: { - groups, - }, - }); - return { headers: { - location: `${redirectUrl.split("?")[0]}?blockId=${blockId}`, + location: `${oauthState.redirectPath}?${new URLSearchParams({ + blockId: oauthState.blockId, + })}`, + "set-cookie": clearGoogleSheetsOAuthStateCookie(), }, }; }; diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/api/router.ts b/apps/builder/src/features/blocks/integrations/googleSheets/api/router.ts index b4ae85361..4e4a36292 100644 --- a/apps/builder/src/features/blocks/integrations/googleSheets/api/router.ts +++ b/apps/builder/src/features/blocks/integrations/googleSheets/api/router.ts @@ -31,7 +31,7 @@ export const googleSheetsRouter = { .route({ method: "GET", path: "/credentials/google-sheets/consent-url", - successStatus: 301, + successStatus: 302, outputStructure: "detailed", }) .input(getConsentUrlInputSchema) @@ -39,6 +39,7 @@ export const googleSheetsRouter = { z.object({ headers: z.object({ location: z.string(), + "set-cookie": z.string(), }), }), ) @@ -56,6 +57,7 @@ export const googleSheetsRouter = { z.object({ headers: z.object({ location: z.string(), + "set-cookie": z.string(), }), }), ) diff --git a/apps/builder/src/features/credentials/api/getOAuthBlockDefinition.ts b/apps/builder/src/features/credentials/api/getOAuthBlockDefinition.ts new file mode 100644 index 000000000..71dda17a9 --- /dev/null +++ b/apps/builder/src/features/credentials/api/getOAuthBlockDefinition.ts @@ -0,0 +1,25 @@ +import { ORPCError } from "@orpc/server"; +import type { OAuthDefinition } from "@typebot.io/forge/types"; +import { + type ForgedBlockDefinition, + forgedBlocks, +} from "@typebot.io/forge-repository/definitions"; + +export const getOAuthBlockDefinition = (blockType: string) => { + const blockDefinition = Object.values(forgedBlocks).find( + (blockDefinition) => blockDefinition.id === blockType, + ); + + if (!blockDefinition || !isOAuthDefinition(blockDefinition.auth)) + throw new ORPCError("BAD_REQUEST", { + message: "Block is not an OAuth block", + }); + + return { ...blockDefinition, auth: blockDefinition.auth }; +}; + +const isOAuthDefinition = ( + authConfig: ForgedBlockDefinition["auth"] | undefined, +): authConfig is OAuthDefinition => { + return !!authConfig && authConfig.type === "oauth"; +}; diff --git a/apps/builder/src/features/credentials/api/handleAuthorizeOAuth.ts b/apps/builder/src/features/credentials/api/handleAuthorizeOAuth.ts index d008d3173..d6fe3048e 100644 --- a/apps/builder/src/features/credentials/api/handleAuthorizeOAuth.ts +++ b/apps/builder/src/features/credentials/api/handleAuthorizeOAuth.ts @@ -1,24 +1,21 @@ import { ORPCError } from "@orpc/server"; import { env } from "@typebot.io/env"; import { getRuntimeVariable } from "@typebot.io/env/getRuntimeVariable"; -import type { AuthDefinition, OAuthDefinition } from "@typebot.io/forge/types"; -import { forgedBlocks } from "@typebot.io/forge-repository/definitions"; import { z } from "zod"; +import { getOAuthBlockDefinition } from "./getOAuthBlockDefinition"; export const authorizeOAuthInputSchema = z.object({ blockType: z.string(), clientId: z.string().optional(), + state: z.string().optional(), }); export const handleAuthorizeOAuth = async ({ - input: { blockType, clientId }, + input: { blockType, clientId, state }, }: { input: z.infer; }) => { - const authConfig = forgedBlocks[blockType as keyof typeof forgedBlocks]?.auth; - - if (!isOAuthDefinition(authConfig)) - throw new ORPCError("BAD_REQUEST", { message: "Invalid block type" }); + const authConfig = getOAuthBlockDefinition(blockType).auth; const resolvedClientId = clientId || @@ -36,9 +33,11 @@ export const handleAuthorizeOAuth = async ({ redirect_uri: `${env.NEXTAUTH_URL}/oauth/redirect`, scope: authConfig.scopes.join(" "), ...authConfig.extraAuthParams, + ...(state ? { state } : {}), }; Object.entries(urlParams).forEach(([k, v]) => { + if (!v) return; url.searchParams.append(k, v); }); @@ -48,9 +47,3 @@ export const handleAuthorizeOAuth = async ({ }, }; }; - -const isOAuthDefinition = ( - authConfig: AuthDefinition | undefined, -): authConfig is OAuthDefinition => { - return !!authConfig && authConfig.type === "oauth"; -}; diff --git a/apps/builder/src/features/credentials/api/handleCreateOAuthCredentials.ts b/apps/builder/src/features/credentials/api/handleCreateOAuthCredentials.ts index 41ab67bf9..f7e92781d 100644 --- a/apps/builder/src/features/credentials/api/handleCreateOAuthCredentials.ts +++ b/apps/builder/src/features/credentials/api/handleCreateOAuthCredentials.ts @@ -3,13 +3,13 @@ import { encrypt } from "@typebot.io/credentials/encrypt"; import { env } from "@typebot.io/env"; import { getRuntimeVariable } from "@typebot.io/env/getRuntimeVariable"; import type { OAuthDefinition } from "@typebot.io/forge/types"; -import { forgedBlocks } from "@typebot.io/forge-repository/definitions"; import { ky } from "@typebot.io/lib/ky"; import { parseUnknownError } from "@typebot.io/lib/parseUnknownError"; import prisma from "@typebot.io/prisma"; import type { User } from "@typebot.io/user/schemas"; import { z } from "zod"; import { isWriteWorkspaceForbidden } from "@/features/workspace/helpers/isWriteWorkspaceForbidden"; +import { getOAuthBlockDefinition } from "./getOAuthBlockDefinition"; const commonInput = z.object({ name: z.string(), @@ -44,11 +44,7 @@ export const handleCreateOAuthCredentials = async ({ input: z.infer; context: { user: Pick }; }) => { - const blockDef = forgedBlocks[input.blockType as keyof typeof forgedBlocks]; - if (!blockDef || blockDef.auth?.type !== "oauth") - throw new ORPCError("BAD_REQUEST", { - message: "Block is not an OAuth block", - }); + const blockDef = getOAuthBlockDefinition(input.blockType); const client = getClient(input.customClient, blockDef.auth); diff --git a/apps/builder/src/features/credentials/api/handleUpdateOAuthCredentials.ts b/apps/builder/src/features/credentials/api/handleUpdateOAuthCredentials.ts index 73c41a542..72bb805a4 100644 --- a/apps/builder/src/features/credentials/api/handleUpdateOAuthCredentials.ts +++ b/apps/builder/src/features/credentials/api/handleUpdateOAuthCredentials.ts @@ -3,13 +3,13 @@ import { encrypt } from "@typebot.io/credentials/encrypt"; import { env } from "@typebot.io/env"; import { getRuntimeVariable } from "@typebot.io/env/getRuntimeVariable"; import type { OAuthDefinition } from "@typebot.io/forge/types"; -import { forgedBlocks } from "@typebot.io/forge-repository/definitions"; import { ky } from "@typebot.io/lib/ky"; import { parseUnknownError } from "@typebot.io/lib/parseUnknownError"; import prisma from "@typebot.io/prisma"; import type { User } from "@typebot.io/user/schemas"; import { z } from "zod"; import { isWriteWorkspaceForbidden } from "@/features/workspace/helpers/isWriteWorkspaceForbidden"; +import { getOAuthBlockDefinition } from "./getOAuthBlockDefinition"; export const updateOAuthCredentialsInputSchema = z.object({ name: z.string(), @@ -41,11 +41,7 @@ export const handleUpdateOAuthCredentials = async ({ if (!workspace || isWriteWorkspaceForbidden(workspace, user)) throw new ORPCError("NOT_FOUND", { message: "Workspace not found" }); - const blockDef = forgedBlocks[input.blockType as keyof typeof forgedBlocks]; - if (!blockDef || blockDef.auth?.type !== "oauth") - throw new ORPCError("BAD_REQUEST", { - message: "Block is not an OAuth block", - }); + const blockDef = getOAuthBlockDefinition(input.blockType); const client = getClient(input.customClient, blockDef.auth); diff --git a/apps/builder/src/features/forge/components/credentials/useOAuthPopup.ts b/apps/builder/src/features/forge/components/credentials/useOAuthPopup.ts index 8c4167845..3596808d2 100644 --- a/apps/builder/src/features/forge/components/credentials/useOAuthPopup.ts +++ b/apps/builder/src/features/forge/components/credentials/useOAuthPopup.ts @@ -56,6 +56,7 @@ export const useOAuthPopup = ({ null, ); const popupCheckIntervalRef = useRef(null); + const oauthStateRef = useRef(null); const isAuthorizingRef = useRef(false); const setIsAuthorizing = (value: boolean) => { @@ -87,6 +88,7 @@ export const useOAuthPopup = ({ oauthWindowRef.current.close(); } oauthWindowRef.current = null; + oauthStateRef.current = null; setIsAuthorizing(false); }; @@ -114,8 +116,11 @@ export const useOAuthPopup = ({ setIsAuthorizing(true); try { + const state = crypto.randomUUID(); + oauthStateRef.current = state; const popupUrl = `/api/${blockId}/oauth/authorize?${stringify({ clientId: clientId, + state, })}`; const popup = window.open(popupUrl, "oauthPopup", popupFeatures); @@ -153,22 +158,26 @@ export const useOAuthPopup = ({ } try { - cleanup(); - - const { code, error } = event.data; + const { code, error, state } = event.data; if (error) { throw new Error(`OAuth failed: ${error}`); } + if (state !== oauthStateRef.current) { + throw new Error("Invalid OAuth state"); + } + if (!code) { throw new Error( "No authorization code received from OAuth provider", ); } + cleanup(); onSuccess(code); } catch (err) { + cleanup(); const errorMessage = err instanceof Error ? err.message : "OAuth authentication failed"; toast({ diff --git a/apps/builder/src/pages/oauth/redirect.tsx b/apps/builder/src/pages/oauth/redirect.tsx index b61c6b644..f68c370d0 100644 --- a/apps/builder/src/pages/oauth/redirect.tsx +++ b/apps/builder/src/pages/oauth/redirect.tsx @@ -5,8 +5,15 @@ export default function Page() { const { query } = useRouter(); useEffect(() => { - if (!query.code) return; - window.opener.postMessage({ type: "oauth", code: query.code }, "*"); + const code = typeof query.code === "string" ? query.code : undefined; + const error = typeof query.error === "string" ? query.error : undefined; + const state = typeof query.state === "string" ? query.state : undefined; + + if (!code && !error) return; + window.opener?.postMessage( + { type: "oauth", code, error, state }, + window.location.origin, + ); window.close(); }, [query]);