diff --git a/apps/webapp/app/routes/api.oauth.clients.tsx b/apps/webapp/app/routes/api.oauth.clients.tsx index d38151a..3d2cbfb 100644 --- a/apps/webapp/app/routes/api.oauth.clients.tsx +++ b/apps/webapp/app/routes/api.oauth.clients.tsx @@ -86,6 +86,32 @@ 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 }, @@ -96,6 +122,10 @@ export const action = async ({ request }: ActionFunctionArgs) => { return json({ error: "No workspace found" }, { status: 404 }); } + if (!userRecord?.admin) { + return json({ error: "No access to create OAuth app" }, { status: 404 }); + } + // Generate client credentials const clientId = crypto.randomUUID(); const clientSecret = crypto.randomBytes(32).toString("hex"); @@ -110,9 +140,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 +166,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..531e98d 100644 --- a/apps/webapp/app/routes/oauth.authorize.tsx +++ b/apps/webapp/app/routes/oauth.authorize.tsx @@ -4,7 +4,7 @@ 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, @@ -14,7 +14,8 @@ 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"; + export const loader = async ({ request }: LoaderFunctionArgs) => { // Check if user is authenticated @@ -31,12 +32,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 +84,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 +104,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 +150,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 +173,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 +238,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 +254,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 +294,7 @@ export default function OAuthAuthorize() { name="code_challenge_method" value={params.code_challenge_method} /> - )} + )}