mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-06-05 21:04:43 +08:00
🐛 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:
parent
c549cec651
commit
c0ffd825e2
@ -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 };
|
||||
};
|
||||
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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" });
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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";
|
||||
};
|
||||
@ -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";
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user