From 1916a58c1c9aa3d9335d19584b1441251d9c252e Mon Sep 17 00:00:00 2001 From: Manoj K Date: Wed, 23 Jul 2025 01:03:42 +0530 Subject: [PATCH] Feat: add integrations access to OAuth apps --- apps/webapp/app/routes/api.oauth.clients.tsx | 25 +- .../api.v1.integration_account.disconnect.tsx | 7 + ....v1.integration_account.disconnect_mcp.tsx | 7 + .../webapp/app/routes/api.v1.integrations.tsx | 57 ++++ apps/webapp/app/routes/oauth.authorize.tsx | 98 ++++-- apps/webapp/app/services/apiAuth.server.ts | 47 ++- .../services/integrationDefinition.server.ts | 8 +- apps/webapp/app/services/oauth2.server.ts | 82 ++++- .../app/services/oauthIntegration.server.ts | 171 +++++++++++ .../trigger/integrations/integration-run.ts | 29 +- .../webhooks/integration-webhook-delivery.ts | 169 +++++++++++ .../webhooks/webhook-delivery-utils.ts | 163 ++++++++++ .../app/trigger/webhooks/webhook-delivery.ts | 135 +++------ apps/webapp/prisma/schema.prisma | 284 +++++++++++------- .../migration.sql | 30 ++ .../migration.sql | 54 ++++ packages/database/prisma/schema.prisma | 284 +++++++++++------- 17 files changed, 1313 insertions(+), 337 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.integrations.tsx create mode 100644 apps/webapp/app/services/oauthIntegration.server.ts create mode 100644 apps/webapp/app/trigger/webhooks/integration-webhook-delivery.ts create mode 100644 apps/webapp/app/trigger/webhooks/webhook-delivery-utils.ts create mode 100644 packages/database/prisma/migrations/20250722045404_add_integration_grant_model/migration.sql create mode 100644 packages/database/prisma/migrations/20250722185955_add_oauth_installation_model/migration.sql diff --git a/apps/webapp/app/routes/api.oauth.clients.tsx b/apps/webapp/app/routes/api.oauth.clients.tsx index d38151a..1da90eb 100644 --- a/apps/webapp/app/routes/api.oauth.clients.tsx +++ b/apps/webapp/app/routes/api.oauth.clients.tsx @@ -86,6 +86,24 @@ export const action = async ({ request }: ActionFunctionArgs) => { ); } + // Validate scopes + const validScopes = [ + // Authentication scopes (Google-style) + 'profile', 'email', 'openid', + // Integration scope + 'integration' + ]; + + const requestedScopes = Array.isArray(allowedScopes) ? allowedScopes : [allowedScopes || 'read']; + const invalidScopes = requestedScopes.filter(scope => !validScopes.includes(scope)); + + if (invalidScopes.length > 0) { + return json( + { error: `Invalid scopes: ${invalidScopes.join(', ')}. Valid scopes are: ${validScopes.join(', ')}` }, + { status: 400 }, + ); + } + // Get user's workspace const userRecord = await prisma.user.findUnique({ where: { id: user.id }, @@ -110,9 +128,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { redirectUris: Array.isArray(redirectUris) ? redirectUris.join(",") : redirectUris, - allowedScopes: Array.isArray(allowedScopes) - ? allowedScopes.join(",") - : allowedScopes || "read", + allowedScopes: requestedScopes.join(","), requirePkce: requirePkce || false, logoUrl: logoUrl || null, homepageUrl: homepageUrl || null, @@ -138,8 +154,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { return json({ success: true, client, - message: - "OAuth client created successfully. Save the client_secret securely - it won't be shown again.", + message: "OAuth client created successfully", }); } catch (error) { console.error("Error creating OAuth client:", error); diff --git a/apps/webapp/app/routes/api.v1.integration_account.disconnect.tsx b/apps/webapp/app/routes/api.v1.integration_account.disconnect.tsx index 59cbcce..2c976bc 100644 --- a/apps/webapp/app/routes/api.v1.integration_account.disconnect.tsx +++ b/apps/webapp/app/routes/api.v1.integration_account.disconnect.tsx @@ -3,6 +3,7 @@ import { requireUserId } from "~/services/session.server"; import { logger } from "~/services/logger.service"; import { prisma } from "~/db.server"; +import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery"; export async function action({ request }: ActionFunctionArgs) { if (request.method !== "POST") { @@ -29,6 +30,12 @@ export async function action({ request }: ActionFunctionArgs) { }, }); + await triggerIntegrationWebhook( + integrationAccountId, + userId, + "integration.disconnected", + ); + logger.info("Integration account disconnected (soft deleted)", { integrationAccountId, userId, diff --git a/apps/webapp/app/routes/api.v1.integration_account.disconnect_mcp.tsx b/apps/webapp/app/routes/api.v1.integration_account.disconnect_mcp.tsx index 90c16c3..fdc5fbb 100644 --- a/apps/webapp/app/routes/api.v1.integration_account.disconnect_mcp.tsx +++ b/apps/webapp/app/routes/api.v1.integration_account.disconnect_mcp.tsx @@ -3,6 +3,7 @@ import { requireUserId } from "~/services/session.server"; import { logger } from "~/services/logger.service"; import { prisma } from "~/db.server"; +import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery"; export async function action({ request }: ActionFunctionArgs) { if (request.method !== "POST") { @@ -52,6 +53,12 @@ export async function action({ request }: ActionFunctionArgs) { }, }); + await triggerIntegrationWebhook( + integrationAccountId, + userId, + "mcp.disconnected", + ); + logger.info("MCP configuration disconnected", { integrationAccountId, userId, diff --git a/apps/webapp/app/routes/api.v1.integrations.tsx b/apps/webapp/app/routes/api.v1.integrations.tsx new file mode 100644 index 0000000..3deaabc --- /dev/null +++ b/apps/webapp/app/routes/api.v1.integrations.tsx @@ -0,0 +1,57 @@ +import { type LoaderFunctionArgs, json } from "@remix-run/node"; +import { oauthIntegrationService } from "~/services/oauthIntegration.server"; +import { authenticateOAuthRequest } from "~/services/apiAuth.server"; + +/** + * API endpoint for OAuth apps to get their connected integrations + * GET /api/oauth/integrations + * Authorization: Bearer + */ +export const loader = async ({ request }: LoaderFunctionArgs) => { + try { + // Authenticate OAuth request and verify integration scope + const authResult = await authenticateOAuthRequest(request, ["integration"]); + + if (!authResult.success) { + return json( + { + error: "unauthorized", + error_description: authResult.error + }, + { status: 401 } + ); + } + + // Get connected integrations for this client and user + const integrations = await oauthIntegrationService.getConnectedIntegrations({ + clientId: authResult.clientId!, + userId: authResult.user!.id, + }); + + return json({ + integrations, + count: integrations.length, + }); + + } catch (error) { + console.error("Error fetching OAuth integrations:", error); + return json( + { + error: "server_error", + error_description: "Internal server error" + }, + { status: 500 } + ); + } +}; + +// Method not allowed for non-GET requests +export const action = async () => { + return json( + { + error: "method_not_allowed", + error_description: "Only GET requests are allowed" + }, + { status: 405 } + ); +}; \ No newline at end of file diff --git a/apps/webapp/app/routes/oauth.authorize.tsx b/apps/webapp/app/routes/oauth.authorize.tsx index cb03a99..76e4bed 100644 --- a/apps/webapp/app/routes/oauth.authorize.tsx +++ b/apps/webapp/app/routes/oauth.authorize.tsx @@ -4,17 +4,32 @@ import { redirect, } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; -import { getUser } from "~/services/session.server"; +import { getUser, requireWorkpace } from "~/services/session.server"; import { oauth2Service, OAuth2Errors, type OAuth2AuthorizeRequest, } from "~/services/oauth2.server"; +import { getIntegrationAccounts } from "~/services/integrationAccount.server"; import { Button } from "~/components/ui/button"; import { Card, CardContent } from "~/components/ui/card"; import { Arrows } from "~/components/icons"; import Logo from "~/components/logo/logo"; -import { AlignLeft, LayoutGrid, Pen } from "lucide-react"; +import { AlignLeft, LayoutGrid, Pen, User, Mail, Shield, Database } from "lucide-react"; + +// Helper function to convert integration definition IDs to account IDs +async function convertDefIdsToAccountIds(defIds: string[], userId: string): Promise { + const integrationAccounts = await getIntegrationAccounts(userId); + const defToAccountMap = new Map( + integrationAccounts + .filter(acc => acc.isActive) + .map(acc => [acc.integrationDefinitionId, acc.id]) + ); + + return defIds + .map(defId => defToAccountMap.get(defId)) + .filter(Boolean) as string[]; +} export const loader = async ({ request }: LoaderFunctionArgs) => { // Check if user is authenticated @@ -31,12 +46,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); let scopeParam = url.searchParams.get("scope") || undefined; - // If scope is present, remove spaces after commas (e.g., "read, write" -> "read,write") + // If scope is present, normalize it to comma-separated format + // Handle both space-separated (from URL encoding) and comma-separated scopes if (scopeParam) { - scopeParam = scopeParam - .split(",") - .map((s) => s.trim()) - .join(","); + // First, try splitting by spaces (common in OAuth2 URLs) + let scopes = scopeParam.split(/\s+/).filter(s => s.length > 0); + + // If no spaces found, try splitting by commas + if (scopes.length === 1) { + scopes = scopeParam.split(",").map(s => s.trim()).filter(s => s.length > 0); + } + + scopeParam = scopes.join(","); } else { throw new Error("Scope is not found"); } @@ -77,6 +98,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ); } + // Validate scopes + if (!oauth2Service.validateScopes(client, params.scope || '')) { + return redirect( + `${params.redirect_uri}?error=${OAuth2Errors.INVALID_SCOPE}&error_description=Invalid scope${params.state ? `&state=${params.state}` : ""}`, + ); + } return { user, client, @@ -91,6 +118,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => { const user = await getUser(request); + const workspace = await requireWorkpace(request); if (!user) { return redirect("/login"); @@ -136,7 +164,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { state: params.state, codeChallenge: params.code_challenge, codeChallengeMethod: params.code_challenge_method, - }); + workspaceId: workspace.id, + }); // Redirect back to client with authorization code const redirectUrl = new URL(params.redirect_uri); redirectUrl.searchParams.set("code", authCode); @@ -158,14 +187,45 @@ export const action = async ({ request }: ActionFunctionArgs) => { }; export default function OAuthAuthorize() { - const { user, client, params } = useLoaderData(); + const { user, client, params } = useLoaderData(); - const getIcon = (scope: string) => { - if (scope === "read") { - return ; + + const getScopeIcon = (scope: string) => { + switch (scope) { + case "profile": + return ; + case "email": + return ; + case "openid": + return ; + case "integration": + return ; + case "read": + return ; + case "write": + return ; + default: + return ; } + }; - return ; + const getScopeDescription = (scope: string) => { + switch (scope) { + case "profile": + return "View your basic profile information"; + case "email": + return "View your email address"; + case "openid": + return "Verify your identity using OpenID Connect"; + case "integration": + return "Access and manage your workspace integrations"; + case "read": + return "Read access to your account"; + case "write": + return "Write access to your account"; + default: + return `Access to ${scope}`; + } }; return ( @@ -192,14 +252,15 @@ export default function OAuthAuthorize() { {client.name} is requesting access

- Authenticating with your {user.name} workspace + Authenticating with your {user.name} account

Permissions

    - {params.scope?.split(" ").map((scope, index, arr) => { + {params.scope?.split(",").map((scope, index, arr) => { + const trimmedScope = scope.trim(); const isFirst = index === 0; const isLast = index === arr.length - 1; return ( @@ -207,10 +268,9 @@ export default function OAuthAuthorize() { key={index} className={`flex items-center gap-2 border-x border-t border-gray-300 p-2 ${isLast ? "border-b" : ""} ${isFirst ? "rounded-tl-md rounded-tr-md" : ""} ${isLast ? "rounded-br-md rounded-bl-md" : ""} `} > -
    {getIcon(scope)}
    +
    {getScopeIcon(trimmedScope)}
    - {scope.charAt(0).toUpperCase() + scope.slice(1)} access to - your workspace + {getScopeDescription(trimmedScope)}
    ); @@ -248,7 +308,7 @@ export default function OAuthAuthorize() { name="code_challenge_method" value={params.code_challenge_method} /> - )} + )}