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}
/>
- )}
+ )}