From 8ec974f942ab57fe34b8cecf4f4f4d3bc3a2eaf5 Mon Sep 17 00:00:00 2001 From: Manoj K Date: Wed, 23 Jul 2025 22:28:00 +0530 Subject: [PATCH] Feat: add mcp oauth2.1 support --- apps/webapp/app/routes/api.v1.mcp.memory.tsx | 14 +- apps/webapp/app/routes/oauth.authorize.tsx | 2 +- apps/webapp/app/routes/oauth.register.tsx | 59 +++++ apps/webapp/app/services/oauth2.server.ts | 44 ++++ .../routeBuilders/apiBuilder.server.ts | 19 +- apps/webapp/server.mjs | 19 +- ...auth-integration-webhook-implementation.md | 207 ++++++++++++++++++ docs/webhook-delivery-architecture.md | 159 ++++++++++++++ .../migration.sql | 10 + packages/database/prisma/schema.prisma | 10 +- 10 files changed, 524 insertions(+), 19 deletions(-) create mode 100644 apps/webapp/app/routes/oauth.register.tsx create mode 100644 docs/oauth-integration-webhook-implementation.md create mode 100644 docs/webhook-delivery-architecture.md create mode 100644 packages/database/prisma/migrations/20250723162344_modify_client_for_dymanic_creation/migration.sql diff --git a/apps/webapp/app/routes/api.v1.mcp.memory.tsx b/apps/webapp/app/routes/api.v1.mcp.memory.tsx index 83ebb70..30bbc4f 100644 --- a/apps/webapp/app/routes/api.v1.mcp.memory.tsx +++ b/apps/webapp/app/routes/api.v1.mcp.memory.tsx @@ -36,6 +36,10 @@ setInterval( // MCP request body schema const MCPRequestSchema = z.object({}).passthrough(); +const SourceParams = z.object({ + source: z.string().optional(), +}); + // Search parameters schema for MCP tool const SearchParamsSchema = z.object({ query: z.string().describe("The search query in third person perspective"), @@ -55,10 +59,11 @@ const handleMCPRequest = async ( request: Request, body: any, authentication: any, + params: z.infer, ) => { const sessionId = request.headers.get("mcp-session-id") as string | undefined; - const source = request.headers.get("source") as string | undefined; - + const source = + request.headers.get("source") || (params.source as string | undefined); if (!source) { return json( { @@ -241,17 +246,18 @@ const handleDelete = async (request: Request, authentication: any) => { const { action, loader } = createHybridActionApiRoute( { body: MCPRequestSchema, + searchParams: SourceParams, allowJWT: true, authorization: { action: "mcp", }, corsStrategy: "all", }, - async ({ body, authentication, request }) => { + async ({ body, authentication, request, searchParams }) => { const method = request.method; if (method === "POST") { - return await handleMCPRequest(request, body, authentication); + return await handleMCPRequest(request, body, authentication, searchParams); } else if (method === "DELETE") { return await handleDelete(request, authentication); } else { diff --git a/apps/webapp/app/routes/oauth.authorize.tsx b/apps/webapp/app/routes/oauth.authorize.tsx index 531e98d..33933a6 100644 --- a/apps/webapp/app/routes/oauth.authorize.tsx +++ b/apps/webapp/app/routes/oauth.authorize.tsx @@ -30,7 +30,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } const url = new URL(request.url); - let scopeParam = url.searchParams.get("scope") || undefined; + let scopeParam = url.searchParams.get("scope") || "mcp"; // If scope is present, normalize it to comma-separated format // Handle both space-separated (from URL encoding) and comma-separated scopes diff --git a/apps/webapp/app/routes/oauth.register.tsx b/apps/webapp/app/routes/oauth.register.tsx new file mode 100644 index 0000000..d9e2b43 --- /dev/null +++ b/apps/webapp/app/routes/oauth.register.tsx @@ -0,0 +1,59 @@ +import { json } from "@remix-run/node"; +import { type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { oauth2Service } from "~/services/oauth2.server"; + +// Dynamic Client Registration for MCP clients (Claude, etc.) +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } + + try { + const body = await request.json(); + const { client_name, redirect_uris, grant_types, response_types } = body; + + // Validate required fields + if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) { + return json( + { error: "invalid_request", error_description: "redirect_uris is required" }, + { status: 400 } + ); + } + + // Create MCP client with special handling + const client = await oauth2Service.createDynamicClient({ + name: client_name || "MCP Client", + redirectUris: redirect_uris, + grantTypes: grant_types || ["authorization_code"], + responseTypes: response_types || ["code"], + clientType: "mcp", // Special flag for MCP clients + requirePkce: true, + allowedScopes: "mcp", + }); + + return json ({ + client_id: client.clientId, + client_secret: client.clientSecret, // Include if confidential client + client_id_issued_at: Math.floor(Date.now() / 1000), + grant_types: client.grantTypes.split(","), + response_types: ["code"], + redirect_uris: client.redirectUris.split(","), + scope: client.allowedScopes, + token_endpoint_auth_method: "client_secret_post", + }); + } catch (error) { + console.error("Dynamic client registration error:", error); + return json( + { + error: "invalid_request", + error_description: "Failed to register client" + }, + { status: 400 } + ); + } +} + +// Prevent GET requests +export async function loader() { + throw new Response("Method Not 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 365ad8b..494749c 100644 --- a/apps/webapp/app/services/oauth2.server.ts +++ b/apps/webapp/app/services/oauth2.server.ts @@ -691,6 +691,50 @@ export class OAuth2Service { scope: storedRefreshToken.scope || undefined, }; } + + async createDynamicClient(params: { + name: string; + redirectUris: string[]; + grantTypes?: string[]; + clientType?: string; + responseTypes?: string[]; + requirePkce?: boolean; + allowedScopes?: string; + description?: string; + workspaceId?: string; + createdById?: string; + }) { + // Generate secure client credentials + const clientId = crypto.randomBytes(16).toString("hex"); + const clientSecret = crypto.randomBytes(32).toString("hex"); + + // Default values for MCP clients + const grantTypes = params.grantTypes || [ + "authorization_code", + "refresh_token", + ]; + const allowedScopes = params.allowedScopes || "mcp"; + const requirePkce = params.requirePkce ?? true; // Default to true for security + + const client = await prisma.oAuthClient.create({ + data: { + clientId, + clientSecret, + name: params.name, + description: + params.description || + `Dynamically registered ${params.clientType || "client"}`, + redirectUris: params.redirectUris.join(","), + grantTypes: grantTypes.join(","), + allowedScopes, + requirePkce, + clientType: "mcp", + isActive: true, + }, + }); + + return client; + } } export const oauth2Service = new OAuth2Service(); diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index c686cf0..bce2d42 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -635,11 +635,13 @@ async function wrapResponse( } // New hybrid authentication types and functions -export type HybridAuthenticationResult = ApiAuthenticationResultSuccess | { - ok: true; - type: "COOKIE"; - userId: string; -}; +export type HybridAuthenticationResult = + | ApiAuthenticationResultSuccess + | { + ok: true; + type: "COOKIE"; + userId: string; + }; async function authenticateHybridRequest( request: Request, @@ -766,10 +768,9 @@ export function createHybridActionApiRoute< } try { - const authenticationResult = await authenticateHybridRequest( - request, - { allowJWT }, - ); + const authenticationResult = await authenticateHybridRequest(request, { + allowJWT, + }); if (!authenticationResult) { return await wrapResponse( diff --git a/apps/webapp/server.mjs b/apps/webapp/server.mjs index 2a62d17..ca490a4 100644 --- a/apps/webapp/server.mjs +++ b/apps/webapp/server.mjs @@ -44,10 +44,27 @@ async function init() { app.use(morgan("tiny")); + app.get("/.well-known/oauth-authorization-server", (req, res) => { + res.json({ + issuer: process.env.APP_ORIGIN, + authorization_endpoint: `${process.env.APP_ORIGIN}/oauth/authorize`, + token_endpoint: `${process.env.APP_ORIGIN}/oauth/token`, + registration_endpoint: `${process.env.APP_ORIGIN}/oauth/register`, + scopes_supported: ["mcp"], + response_types_supported: ["code"], + grant_types_supported: [ + "authorization_code", + "refresh_token", + "client_credentials", + ], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "none"], + }); + }); + // handle SSR requests app.all("*", remixHandler); - const port = process.env.REMIX_APP_PORT || 3000; app.listen(port, () => console.log(`Express server listening at http://localhost:${port}`), diff --git a/docs/oauth-integration-webhook-implementation.md b/docs/oauth-integration-webhook-implementation.md new file mode 100644 index 0000000..39e6c88 --- /dev/null +++ b/docs/oauth-integration-webhook-implementation.md @@ -0,0 +1,207 @@ +# OAuth Integration Webhook Implementation + +This document describes the implementation of webhook notifications for OAuth applications when users connect new integrations, following the existing trigger-based architecture. + +## Architecture + +The implementation follows the established pattern used in the Echo system: + +- **Integration Creation**: Happens in `integration-run` trigger +- **Webhook Delivery**: Uses dedicated trigger task for asynchronous processing +- **Error Handling**: Non-blocking - webhook failures don't affect integration creation + +## Implementation Components + +### 1. OAuth Integration Webhook Delivery Task + +**File**: `apps/webapp/app/trigger/webhooks/oauth-integration-webhook-delivery.ts` + +This is a dedicated trigger task that handles webhook delivery to OAuth applications: + +```typescript +export const oauthIntegrationWebhookTask = task({ + id: "oauth-integration-webhook-delivery", + queue: oauthIntegrationWebhookQueue, + run: async (payload: OAuthIntegrationWebhookPayload) => { + // Implementation + }, +}); +``` + +**Key Features**: + +- Finds OAuth clients with `integration` scope for the user +- Sends webhook notifications with integration details +- Includes HMAC signature verification +- Provides detailed delivery status tracking +- Non-blocking error handling + +### 2. Integration into Integration-Run Trigger + +**File**: `apps/webapp/app/trigger/integrations/integration-run.ts` + +Modified the `handleAccountMessage` function to trigger webhook notifications: + +```typescript +async function handleAccountMessage(...) { + // Create integration account + const integrationAccount = await createIntegrationAccount({...}); + + // Trigger OAuth integration webhook notifications + try { + await triggerOAuthIntegrationWebhook(integrationAccount.id, userId); + } catch (error) { + // Log error but don't fail integration creation + } + + return integrationAccount; +} +``` + +**Integration Points**: + +- Triggered after successful integration account creation +- Works for all integration types (OAuth, API key, MCP) +- Maintains existing integration creation flow + +## Webhook Flow + +### 1. Integration Connection + +When a user connects a new integration: + +1. Integration runs through `IntegrationEventType.SETUP` +2. CLI returns "account" message +3. `handleAccountMessage` creates integration account +4. `triggerOAuthIntegrationWebhook` is called +5. Webhook delivery task is queued + +### 2. Webhook Delivery + +The webhook delivery task: + +1. Queries OAuth clients with: + - `integration` scope in `allowedScopes` + - Active `OAuthIntegrationGrant` for the user + - Configured `webhookUrl` +2. Sends HTTP POST to each webhook URL +3. Logs delivery results + +### 3. Webhook Payload + +```json +{ + "event": "integration.connected", + "user_id": "user_uuid", + "integration": { + "id": "integration_account_uuid", + "provider": "linear", + "account_id": "external_account_id", + "mcp_endpoint": "mcp://core.ai/linear/external_account_id", + "name": "Linear", + "icon": "https://example.com/linear-icon.png" + }, + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +## Security Features + +### HMAC Signature + +If OAuth client has `webhookSecret` configured: + +```typescript +const signature = crypto + .createHmac("sha256", client.webhookSecret) + .update(payloadString) + .digest("hex"); +headers["X-Webhook-Secret"] = signature; +``` + +### Headers + +- `Content-Type: application/json` +- `User-Agent: Echo-OAuth-Webhooks/1.0` +- `X-Webhook-Delivery: ${deliveryId}` +- `X-Webhook-Event: integration.connected` +- `X-Webhook-Secret: ${signature}` (if secret configured) + +## Error Handling + +### Non-Blocking Design + +- Webhook delivery failures do NOT affect integration creation +- Errors are logged but don't throw exceptions +- Integration process continues normally + +### Retry Strategy + +Currently, the system uses Trigger.dev's built-in retry mechanism: + +- Failed webhook deliveries will be retried automatically +- Exponential backoff for temporary failures +- Dead letter queue for permanent failures + +### Logging + +Comprehensive logging includes: + +- Integration account details +- OAuth client information +- HTTP response status and body +- Error messages and stack traces +- Delivery success/failure counts + +## Database Requirements + +The implementation requires these existing database relationships: + +### OAuthClient + +- `webhookUrl`: Target URL for notifications +- `webhookSecret`: Optional HMAC secret +- `allowedScopes`: Must include "integration" + +### OAuthIntegrationGrant + +- Links OAuth clients to users +- `isActive`: Must be true for notifications +- `userId`: Target user for the integration + +### IntegrationAccount + +- Created during integration setup +- Includes `integrationDefinition` relationship +- Contains provider-specific configuration + +## Testing + +To test the webhook delivery: + +1. **Create OAuth Client** with integration scope: + +```sql +UPDATE "OAuthClient" +SET "allowedScopes" = 'profile,email,openid,integration', + "webhookUrl" = 'https://your-webhook-endpoint.com/webhooks' +WHERE "clientId" = 'your-client-id'; +``` + +2. **Grant Integration Access** through OAuth flow with `integration` scope + +3. **Connect Integration** (Linear, Slack, etc.) - webhooks will be triggered automatically + +4. **Monitor Logs** for delivery status and any errors + +## Advantages of This Approach + +1. **Follows Existing Patterns**: Uses the same trigger-based architecture as other webhook systems +2. **Scalable**: Leverages Trigger.dev's queue system for handling high volumes +3. **Reliable**: Built-in retry and error handling +4. **Non-Blocking**: Integration creation is never blocked by webhook issues +5. **Comprehensive**: Works with all integration types and OAuth flows +6. **Secure**: Includes HMAC signature verification and proper headers +7. **Observable**: Detailed logging for monitoring and debugging + +This implementation ensures that OAuth applications are immediately notified when users connect new integrations, while maintaining the reliability and scalability of the existing system architecture. diff --git a/docs/webhook-delivery-architecture.md b/docs/webhook-delivery-architecture.md new file mode 100644 index 0000000..d22e8cb --- /dev/null +++ b/docs/webhook-delivery-architecture.md @@ -0,0 +1,159 @@ +# Webhook Delivery Architecture + +This document describes the refactored webhook delivery system that eliminates code duplication by using common utilities. + +## Architecture Overview + +The webhook delivery system now follows a clean separation of concerns: + +1. **Common Utilities** (`webhook-delivery-utils.ts`) - Shared HTTP delivery logic +2. **Activity Webhooks** (`webhook-delivery.ts`) - Workspace-based activity notifications +3. **OAuth Integration Webhooks** (`oauth-integration-webhook-delivery.ts`) - OAuth app integration notifications + +## Common Utilities (`webhook-delivery-utils.ts`) + +### Core Function: `deliverWebhook()` + +Handles the common HTTP delivery logic for both webhook types: + +```typescript +export async function deliverWebhook(params: WebhookDeliveryParams): Promise<{ + success: boolean; + deliveryResults: DeliveryResult[]; + summary: { total: number; successful: number; failed: number }; +}>; +``` + +**Features:** + +- Generic payload support (works with any webhook structure) +- Configurable User-Agent strings +- HMAC signature verification with different header formats +- 30-second timeout +- Comprehensive error handling and logging +- Detailed delivery results + +### Helper Function: `prepareWebhookTargets()` + +Converts simple webhook configurations to the standardized target format: + +```typescript +export function prepareWebhookTargets( + webhooks: Array<{ url: string; secret?: string | null }> +): WebhookTarget[]; +``` + +## Activity Webhooks (`webhook-delivery.ts`) + +**Purpose:** Send notifications to workspace webhook configurations when activities are created. + +**Payload Structure:** + +```json +{ + "event": "activity.created", + "timestamp": "2024-01-15T10:30:00.000Z", + "data": { + "id": "activity_id", + "text": "Activity content", + "sourceURL": "https://source.url", + "integrationAccount": { ... }, + "workspace": { ... } + } +} +``` + +**Key Features:** + +- Uses `X-Hub-Signature-256` header for HMAC verification +- Logs delivery results to `WebhookDeliveryLog` table +- Targets all active workspace webhook configurations + +## OAuth Integration Webhooks (`oauth-integration-webhook-delivery.ts`) + +**Purpose:** Notify OAuth applications when users connect new integrations. + +**Payload Structure:** + +```json +{ + "event": "integration.connected", + "user_id": "user_uuid", + "integration": { + "id": "integration_account_id", + "provider": "linear", + "account_id": "external_account_id", + "mcp_endpoint": "mcp://core.ai/linear/external_account_id", + "name": "Linear", + "icon": "https://example.com/icon.png" + }, + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +**Key Features:** + +- Uses `X-Webhook-Secret` header for HMAC verification +- Custom User-Agent: `Echo-OAuth-Webhooks/1.0` +- Targets OAuth clients with `integration` scope and webhook URLs + +## Shared Features + +Both webhook types benefit from the common utilities: + +### Security + +- HMAC-SHA256 signature verification +- Configurable secrets per webhook target +- Proper HTTP headers for identification + +### Reliability + +- 30-second request timeout +- Comprehensive error handling +- Non-blocking webhook failures + +### Observability + +- Detailed logging at each step +- Delivery success/failure tracking +- Response status and body capture (limited) + +### Performance + +- Parallel webhook delivery +- Efficient target preparation +- Minimal memory footprint + +## Integration Points + +### Activity Webhooks + +- Triggered from: `apps/webapp/app/routes/api.v1.activity.tsx` +- Function: `triggerWebhookDelivery(activityId, workspaceId)` + +### OAuth Integration Webhooks + +- Triggered from: `apps/webapp/app/trigger/integrations/integration-run.ts` +- Function: `triggerOAuthIntegrationWebhook(integrationAccountId, userId)` + +## Benefits of This Architecture + +1. **Code Reuse**: Common HTTP delivery logic eliminates duplication +2. **Maintainability**: Single place to update delivery logic +3. **Consistency**: Same headers, timeouts, and error handling across webhook types +4. **Flexibility**: Easy to add new webhook types by reusing common utilities +5. **Testing**: Easier to test common logic independently +6. **Security**: Consistent HMAC implementation across all webhook types + +## Adding New Webhook Types + +To add a new webhook type: + +1. Create a new trigger task file (e.g., `new-webhook-delivery.ts`) +2. Define your payload structure +3. Use `deliverWebhook()` with your payload and targets +4. Add your event type to `WebhookEventType` in utils +5. Update HMAC header logic in `deliverWebhook()` if needed + +This architecture provides a solid foundation for webhook delivery that can easily scale to support additional webhook types while maintaining code quality and consistency. diff --git a/packages/database/prisma/migrations/20250723162344_modify_client_for_dymanic_creation/migration.sql b/packages/database/prisma/migrations/20250723162344_modify_client_for_dymanic_creation/migration.sql new file mode 100644 index 0000000..251c030 --- /dev/null +++ b/packages/database/prisma/migrations/20250723162344_modify_client_for_dymanic_creation/migration.sql @@ -0,0 +1,10 @@ +-- DropForeignKey +ALTER TABLE "OAuthClient" DROP CONSTRAINT "OAuthClient_createdById_fkey"; + +-- AlterTable +ALTER TABLE "OAuthClient" ADD COLUMN "clientType" TEXT NOT NULL DEFAULT 'regular', +ALTER COLUMN "workspaceId" DROP NOT NULL, +ALTER COLUMN "createdById" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 9d6187f..476b6cb 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -287,6 +287,8 @@ model OAuthClient { // PKCE support requirePkce Boolean @default(false) + clientType String @default("regular") + // Client metadata logoUrl String? homepageUrl String? @@ -299,12 +301,12 @@ model OAuthClient { isActive Boolean @default(true) // Workspace relationship (like GitHub orgs) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - workspaceId String + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspaceId String? // Created by user (for audit trail) - createdBy User @relation(fields: [createdById], references: [id]) - createdById String + createdBy User? @relation(fields: [createdById], references: [id]) + createdById String? // Relations oauthAuthorizationCodes OAuthAuthorizationCode[]