diff --git a/apps/webapp/app/routes/api.oauth.clients.tsx b/apps/webapp/app/routes/api.oauth.clients.tsx index 3d2cbfb..0192fa9 100644 --- a/apps/webapp/app/routes/api.oauth.clients.tsx +++ b/apps/webapp/app/routes/api.oauth.clients.tsx @@ -94,6 +94,14 @@ export const action = async ({ request }: ActionFunctionArgs) => { "openid", // Integration scope "integration", + "integration:read", + "integration:credentials", + "integration:manage", + "integration:webhook", + // MCP scope + "mcp", + "mcp:read", + "mcp:write", ]; const requestedScopes = Array.isArray(allowedScopes) diff --git a/apps/webapp/app/routes/api.v1.integration_account.$id.credentials.tsx b/apps/webapp/app/routes/api.v1.integration_account.$id.credentials.tsx new file mode 100644 index 0000000..e4359b3 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.integration_account.$id.credentials.tsx @@ -0,0 +1,114 @@ +import { type LoaderFunctionArgs, json } from "@remix-run/node"; +import { z } from "zod"; +import { authenticateOAuthRequest } from "~/services/apiAuth.server"; +import { prisma } from "~/db.server"; + +// Schema for the integration account ID parameter +const ParamsSchema = z.object({ + id: z.string().min(1, "Integration account ID is required"), +}); + +/** + * API endpoint for OAuth apps to get integration account credentials + * GET /api/v1/integration_account/:id/credentials + * Authorization: Bearer + * Required scope: integration:credentials + */ +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + try { + // Authenticate OAuth request and verify integration:credentials scope + const authResult = await authenticateOAuthRequest(request, ["integration:credentials", "integration"]); + + if (!authResult.success) { + return json( + { + error: "unauthorized", + error_description: authResult.error + }, + { status: 401 } + ); + } + + // Validate parameters + const parseResult = ParamsSchema.safeParse(params); + if (!parseResult.success) { + return json( + { + error: "invalid_request", + error_description: "Invalid integration account ID" + }, + { status: 400 } + ); + } + + const { id } = parseResult.data; + + // Get the integration account with proper access control + const integrationAccount = await prisma.integrationAccount.findFirst({ + where: { + id, + integratedById: authResult.user!.id, // Ensure user owns this integration account + isActive: true, + deleted: null, + }, + include: { + integrationDefinition: { + select: { + id: true, + name: true, + slug: true, + description: true, + icon: true, + }, + }, + }, + }); + + if (!integrationAccount) { + return json( + { + error: "not_found", + error_description: "Integration account not found or access denied" + }, + { status: 404 } + ); + } + + // Extract credentials from integrationConfiguration + const credentials = integrationAccount.integrationConfiguration as Record; + + // Return the credentials and metadata + return json({ + id: integrationAccount.id, + accountId: integrationAccount.accountId, + provider: integrationAccount.integrationDefinition.slug, + name: integrationAccount.integrationDefinition.name, + icon: integrationAccount.integrationDefinition.icon, + credentials, + settings: integrationAccount.settings, + connectedAt: integrationAccount.createdAt, + isActive: integrationAccount.isActive, + }); + + } catch (error) { + console.error("Error fetching integration account credentials:", 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/services/oauth2.server.ts b/apps/webapp/app/services/oauth2.server.ts index 494749c..a79c474 100644 --- a/apps/webapp/app/services/oauth2.server.ts +++ b/apps/webapp/app/services/oauth2.server.ts @@ -289,8 +289,17 @@ export class OAuth2Service { // Google-style auth scopes const authScopes = ["profile", "email", "openid"]; - // Single integration scope - const integrationScopes = ["integration"]; + // Integration-related scopes + const integrationScopes = [ + "integration", + "integration:read", + "integration:credentials", + "integration:manage", + "integration:webhook", + ]; + + // MCP-related scopes + const mcpScopes = ["mcp", "mcp:read", "mcp:write"]; const hasAuthScopes = scopes.some((s) => authScopes.includes(s)); const hasIntegrationScopes = scopes.some((s) => @@ -324,6 +333,34 @@ export class OAuth2Service { description: "Access your workspace integrations", icon: "database", }, + "integration:read": { + description: "Read integration metadata and status", + icon: "eye", + }, + "integration:credentials": { + description: "Access integration account credentials", + icon: "key", + }, + "integration:manage": { + description: "Create, update, and delete integrations", + icon: "settings", + }, + "integration:webhook": { + description: "Manage integration webhooks", + icon: "webhook", + }, + mcp: { + description: "Access MCP endpoints", + icon: "mcp", + }, + "mcp:read": { + description: "Read MCP endpoints", + icon: "eye", + }, + "mcp:write": { + description: "Write to MCP endpoints", + icon: "pencil", + }, }; return scopes.map((scope) => ({ @@ -560,7 +597,6 @@ export class OAuth2Service { expiresAt: { gt: new Date() }, userId: tokenPayload.user_id, workspaceId: tokenPayload.workspace_id, - ...(scopes ? { scope: { contains: scopes.join(",") } } : {}), }, include: { client: true, @@ -568,6 +604,20 @@ export class OAuth2Service { }, }); + // Validate scopes separately if requested + if (scopes && accessToken) { + const tokenScopes = + accessToken.scope?.split(",").map((s) => s.trim()) || []; + + const hasAllScopes = scopes.some((requiredScope) => + tokenScopes.some((tokenScope) => tokenScope === requiredScope), + ); + + if (!hasAllScopes) { + throw new Error("Insufficient scope"); + } + } + if (!accessToken) { throw new Error("Invalid or expired token"); }