🐛 Fix Google Sheets OAuth callback authorization (#2501)

- 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.
This commit is contained in:
Baptiste Arnaud 2026-05-21 17:31:47 +02:00 committed by GitHub
parent c549cec651
commit c0ffd825e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 473 additions and 99 deletions

View File

@ -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<User, "id">;
}) => {
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 };
};

View File

@ -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`,
);
});
});

View File

@ -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<typeof googleSheetsOAuthContextSchema>;
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" });
};

View File

@ -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<typeof getConsentUrlInputSchema>;
context: { user: Pick<User, "id"> };
}) => {
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,
},
};
};

View File

@ -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<typeof handleCallbackInputSchema>;
context: { user: Pick<User, "id"> };
}) => {
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(),
},
};
};

View File

@ -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(),
}),
}),
)

View File

@ -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";
};

View File

@ -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<typeof authorizeOAuthInputSchema>;
}) => {
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<any> | undefined,
): authConfig is OAuthDefinition => {
return !!authConfig && authConfig.type === "oauth";
};

View File

@ -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<typeof createOAuthCredentialsInputSchema>;
context: { user: Pick<User, "id"> };
}) => {
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);

View File

@ -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);

View File

@ -56,6 +56,7 @@ export const useOAuthPopup = ({
null,
);
const popupCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
const oauthStateRef = useRef<string | null>(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({

View File

@ -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]);