Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 84 additions & 79 deletions apps/web/app/api/oauth/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,96 +10,101 @@ import { SHOPIFY_INTEGRATION_ID, STRIPE_INTEGRATION_ID } from "@dub/utils";
import { NextResponse } from "next/server";

// POST /api/oauth/authorize - approve OAuth authorization request
export const POST = withWorkspace(async ({ session, req, workspace }) => {
const {
state,
scope,
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
} = authorizeRequestSchema.parse(await parseRequestBody(req));
export const POST = withWorkspace(
async ({ session, req, workspace }) => {
const {
state,
scope,
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
} = authorizeRequestSchema.parse(await parseRequestBody(req));

// Check if the user has the required scopes for the workspace selected
const userRole = workspace.users[0].role;
const scopesForRole = getScopesForRole(userRole);
const scopesMissing = consolidateScopes(scope).filter(
(scope) => !scopesForRole.includes(scope) && scope !== "user.read",
);
// Check if the user has the required scopes for the workspace selected
const userRole = workspace.users[0].role;
const scopesForRole = getScopesForRole(userRole);
const scopesMissing = consolidateScopes(scope).filter(
(scope) => !scopesForRole.includes(scope) && scope !== "user.read",
);

if (scopesMissing.length > 0) {
throw new DubApiError({
code: "bad_request",
message: "You don't have the permission to install this integration.",
if (scopesMissing.length > 0) {
throw new DubApiError({
code: "bad_request",
message: "You don't have the permission to install this integration.",
});
}

const app = await prisma.oAuthApp.findUniqueOrThrow({
where: {
clientId,
},
select: {
redirectUris: true,
pkce: true,
integrationId: true,
},
});
}

const app = await prisma.oAuthApp.findUniqueOrThrow({
where: {
clientId,
},
select: {
redirectUris: true,
pkce: true,
integrationId: true,
},
});
if (
[STRIPE_INTEGRATION_ID, SHOPIFY_INTEGRATION_ID].includes(
app.integrationId,
) &&
(workspace.plan === "free" || workspace.plan === "pro")
) {
throw new DubApiError({
code: "bad_request",
message:
"This integration is only available for workspaces with a Business plan or higher. Please upgrade your plan to continue.",
});
}

if (
[STRIPE_INTEGRATION_ID, SHOPIFY_INTEGRATION_ID].includes(
app.integrationId,
) &&
(workspace.plan === "free" || workspace.plan === "pro")
) {
throw new DubApiError({
code: "bad_request",
message:
"This integration is only available for workspaces with a Business plan or higher. Please upgrade your plan to continue.",
});
}
const redirectUris = (app.redirectUris || []) as string[];

const redirectUris = (app.redirectUris || []) as string[];
if (!redirectUris.includes(redirectUri)) {
throw new DubApiError({
code: "bad_request",
message: "Invalid redirect_uri parameter for the application.",
});
}

if (!redirectUris.includes(redirectUri)) {
throw new DubApiError({
code: "bad_request",
message: "Invalid redirect_uri parameter for the application.",
});
}
// If PKCE is required, ensure that the code_challenge and code_challenge_method are present
if (app.pkce && (!codeChallenge || !codeChallengeMethod)) {
throw new DubApiError({
code: "bad_request",
message: "Missing code_challenge or code_challenge_method parameters.",
});
}

// If PKCE is required, ensure that the code_challenge and code_challenge_method are present
if (app.pkce && (!codeChallenge || !codeChallengeMethod)) {
throw new DubApiError({
code: "bad_request",
message: "Missing code_challenge or code_challenge_method parameters.",
const { code } = await prisma.oAuthCode.create({
data: {
clientId,
redirectUri,
projectId: workspace.id,
userId: session.user.id,
scopes: scope.join(" "),
code: createToken({ length: OAUTH_CONFIG.CODE_LENGTH }),
expiresAt: new Date(Date.now() + OAUTH_CONFIG.CODE_LIFETIME * 1000),
...(app.pkce && { codeChallenge, codeChallengeMethod }),
},
});
}

const { code } = await prisma.oAuthCode.create({
data: {
clientId,
redirectUri,
projectId: workspace.id,
userId: session.user.id,
scopes: scope.join(" "),
code: createToken({ length: OAUTH_CONFIG.CODE_LENGTH }),
expiresAt: new Date(Date.now() + OAUTH_CONFIG.CODE_LIFETIME * 1000),
...(app.pkce && { codeChallenge, codeChallengeMethod }),
},
});

// Generate the callback URL
const callbackUrl = new URL(redirectUri);
// Generate the callback URL
const callbackUrl = new URL(redirectUri);

callbackUrl.searchParams.set("code", code);
callbackUrl.searchParams.set("code", code);

if (state) {
callbackUrl.searchParams.set("state", state);
}
if (state) {
callbackUrl.searchParams.set("state", state);
}

const response = {
callbackUrl: callbackUrl.toString(),
};
const response = {
callbackUrl: callbackUrl.toString(),
};

return NextResponse.json(response);
});
return NextResponse.json(response);
},
{
requiredPermissions: ["integrations.write"],
},
);
103 changes: 70 additions & 33 deletions apps/web/app/app.dub.co/(auth)/oauth/authorize/authorize-form.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

import { consolidateScopes, getScopesForRole } from "@/lib/api/tokens/scopes";
import { clientAccessCheck } from "@/lib/client-access-check";
import useWorkspaces from "@/lib/swr/use-workspaces";
import { authorizeRequestSchema } from "@/lib/zod/schemas/oauth";
import { WorkspaceSelector } from "@/ui/workspaces/workspace-selector";
import { Button } from "@dub/ui";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import * as z from "zod/v4";

Expand All @@ -20,41 +21,65 @@ export const AuthorizeForm = ({
code_challenge_method,
}: z.infer<typeof authorizeRequestSchema>) => {
const { data: session } = useSession();
const { workspaces } = useWorkspaces();
const { workspaces, loading: workspacesLoading } = useWorkspaces();
const [submitting, setSubmitting] = useState(false);
const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(
null,
);

// missing scopes for the user's role on the workspace selected
const [missingScopes, setMissingScopes] = useState<string[]>([]);

useEffect(() => {
setSelectedWorkspace(session?.user?.["defaultWorkspace"] || null);
}, [session]);

// Check if the user has the required scopes for the workspace selected
useEffect(() => {
if (!selectedWorkspace) {
return;
}

const workspace = workspaces?.find(
(workspace) => workspace.slug === selectedWorkspace,
);

if (!workspace) {
return;
}

const userRole = workspace.users[0].role;
const scopesForRole = getScopesForRole(userRole);
const scopesMissing = consolidateScopes(scope).filter(
(scope) => !scopesForRole.includes(scope) && scope !== "user.read",
);

setMissingScopes(scopesMissing);
}, [selectedWorkspace]);
const { permissionsError, missingScopes, workspaceUnresolvedMessage } =
useMemo(() => {
if (!selectedWorkspace) {
return {
permissionsError: undefined,
missingScopes: [] as string[],
workspaceUnresolvedMessage: undefined,
};
}

if (workspacesLoading || workspaces === undefined) {
return {
permissionsError: undefined,
missingScopes: [] as string[],
workspaceUnresolvedMessage: "Loading workspaces...",
};
}

const workspace = workspaces.find(
(workspace) => workspace.slug === selectedWorkspace,
);

if (!workspace) {
return {
permissionsError: undefined,
missingScopes: [] as string[],
workspaceUnresolvedMessage: "Please select a valid workspace",
};
}

const userRole = workspace.users[0].role;

const permissionsError = clientAccessCheck({
action: "integrations.write",
role: userRole,
customPermissionDescription: "install this integration",
}).error;

const scopesForRole = getScopesForRole(userRole);
const missingScopes = consolidateScopes(scope).filter(
(scope) => !scopesForRole.includes(scope) && scope !== "user.read",
);

return {
permissionsError,
missingScopes,
workspaceUnresolvedMessage: undefined,
};
}, [workspaces, workspacesLoading, selectedWorkspace, scope]);

// Decline the request
const onDecline = () => {
Expand All @@ -75,12 +100,17 @@ export const AuthorizeForm = ({
return;
}

setSubmitting(true);

const workspaceId = workspaces?.find(
(workspace) => workspace.slug === selectedWorkspace,
)?.id;

if (!workspaceId) {
toast.error("Please select a workspace to continue");
return;
}

setSubmitting(true);

const response = await fetch(
`/api/oauth/authorize?workspaceId=${workspaceId}`,
{
Expand Down Expand Up @@ -138,13 +168,20 @@ export const AuthorizeForm = ({
text="Authorize"
type="submit"
loading={submitting}
disabled={!selectedWorkspace}
disabled={
!selectedWorkspace ||
!!workspaceUnresolvedMessage ||
!!permissionsError ||
missingScopes.length > 0
}
disabledTooltip={
!selectedWorkspace
? "Please select a workspace to continue"
: missingScopes.length > 0
? "You don't have the permission to install this integration"
: undefined
: workspaceUnresolvedMessage ||
permissionsError ||
(missingScopes.length > 0
? "You don't have the permission to install this integration"
: undefined)
}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,9 @@ export default function IntegrationPageClient({
cta="Upgrade to Advanced"
href={`/${slug}/settings/billing/upgrade?plan=advanced`}
/>
) : undefined
) : (
permissionsError || undefined
)
}
/>
)}
Expand Down
Loading