Implement OAuth 2.0 Dynamic Client Registration (RFC 7591) (#18608)
## Summary This PR implements OAuth 2.0 Dynamic Client Registration (RFC 7591) and OAuth 2.0 Protected Resource Metadata (RFC 9728) support, enabling third-party applications to dynamically register as OAuth clients without manual configuration. ## Key Changes ### OAuth Dynamic Client Registration - **New Controller**: `OAuthRegistrationController` at `POST /oauth/register` endpoint - Validates client metadata according to RFC 7591 specifications - Enforces PKCE-only public client model (no client secrets) - Supports only `authorization_code` grant type and `code` response type - Rate limits registrations to 10 per hour per IP address - Returns `client_id` and registration metadata in response - **Input Validation**: `OAuthRegisterInput` DTO with constraints on: - Client name (max 256 chars) - Redirect URIs (max 20, validated for security) - Grant types, response types, scopes, and auth methods - Logo and client URIs (max 2048 chars) - **Discovery Endpoint Update**: Added `registration_endpoint` to OAuth discovery metadata ### Stale Registration Cleanup - **Cleanup Service**: Automatically removes OAuth-only registrations older than 30 days that have no active installations - **Cron Job**: Runs daily at 02:30 AM UTC with batch processing (100 records per batch) - **CLI Command**: `cron:stale-registration-cleanup` to manually trigger cleanup ### MCP (Model Context Protocol) Authentication - **New Guard**: `McpAuthGuard` implements RFC 9728 compliance - Wraps JWT authentication with proper error responses - Returns `WWW-Authenticate` header with protected resource metadata URL on 401 - Enables OAuth-protected MCP endpoints ### Protected Resource Metadata - **New Endpoint**: `GET /.well-known/oauth-protected-resource` (RFC 9728) - Advertises MCP resource as OAuth-protected - Lists supported scopes and bearer token methods - Enables OAuth clients to discover authorization requirements ### Application Registration Updates - **New Source Type**: `OAUTH_ONLY` enum value for OAuth-only registrations - **Install Service**: Skips artifact installation for OAuth-only apps (no code artifacts) ### Frontend Updates - **Authorization Page**: Support both snake_case (standard OAuth) and camelCase (legacy) query parameters - `client_id` / `clientId` - `code_challenge` / `codeChallenge` - `redirect_uri` / `redirectUrl` ## Implementation Details - **Rate Limiting**: Uses token bucket algorithm with 10 registrations per 3,600,000ms window per IP - **Scope Validation**: Requested scopes are capped to allowed OAuth scopes; defaults to all scopes if not specified - **Redirect URI Validation**: Uses existing `validateRedirectUri` utility for security - **Cache Headers**: Registration responses include `Cache-Control: no-store` and `Pragma: no-cache` - **Batch Processing**: Cleanup operations process 100 records at a time to avoid memory issues - **Grace Period**: 30-day grace period before cleanup to allow time for client activation https://claude.ai/code/session_01PxcuWFFRuXMASMaMGTLYk2 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
+12
@@ -5,12 +5,15 @@ import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
|
||||
import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller';
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
describe('McpCoreController', () => {
|
||||
@@ -43,6 +46,15 @@ describe('McpCoreController', () => {
|
||||
handleError: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: JwtAuthGuard,
|
||||
useValue: { canActivate: jest.fn().mockReturnValue(true) },
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: { get: jest.fn().mockReturnValue('http://localhost:3000') },
|
||||
},
|
||||
McpAuthGuard,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
@@ -18,12 +19,11 @@ import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@Controller('mcp')
|
||||
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@UseGuards(McpAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@UseFilters(RestApiExceptionFilter)
|
||||
export class McpCoreController {
|
||||
constructor(private readonly mcpProtocolService: McpProtocolService) {}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
|
||||
describe('McpAuthGuard', () => {
|
||||
let guard: McpAuthGuard;
|
||||
let jwtAuthGuard: jest.Mocked<JwtAuthGuard>;
|
||||
let twentyConfigService: jest.Mocked<TwentyConfigService>;
|
||||
|
||||
const mockSetHeader = jest.fn();
|
||||
const mockContext = {
|
||||
switchToHttp: () => ({
|
||||
getResponse: () => ({ setHeader: mockSetHeader }),
|
||||
getRequest: () => ({}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jwtAuthGuard = {
|
||||
canActivate: jest.fn(),
|
||||
} as unknown as jest.Mocked<JwtAuthGuard>;
|
||||
twentyConfigService = {
|
||||
get: jest.fn().mockReturnValue('https://crm.example.com'),
|
||||
} as unknown as jest.Mocked<TwentyConfigService>;
|
||||
|
||||
guard = new McpAuthGuard(jwtAuthGuard, twentyConfigService);
|
||||
mockSetHeader.mockClear();
|
||||
});
|
||||
|
||||
it('should return true when JwtAuthGuard passes', async () => {
|
||||
jwtAuthGuard.canActivate.mockResolvedValue(true);
|
||||
|
||||
const result = await guard.canActivate(mockContext);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSetHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set WWW-Authenticate header and throw when auth fails', async () => {
|
||||
jwtAuthGuard.canActivate.mockResolvedValue(false);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
|
||||
expect(mockSetHeader).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer resource_metadata="https://crm.example.com/.well-known/oauth-protected-resource"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type Response } from 'express';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
|
||||
// RFC 9728: When the MCP endpoint returns 401, include a WWW-Authenticate
|
||||
// header pointing to the Protected Resource Metadata URL.
|
||||
@Injectable()
|
||||
export class McpAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtAuthGuard: JwtAuthGuard,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isAuthenticated = await this.jwtAuthGuard.canActivate(context);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
const resourceMetadataUrl = `${serverUrl}/.well-known/oauth-protected-resource`;
|
||||
|
||||
// Set the header on the response before throwing, because exception
|
||||
// filters may not preserve custom headers from the exception payload.
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
|
||||
response.setHeader(
|
||||
'WWW-Authenticate',
|
||||
`Bearer resource_metadata="${resourceMetadataUrl}"`,
|
||||
);
|
||||
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller';
|
||||
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
@@ -16,13 +19,19 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
ApiKeyModule,
|
||||
TokenModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
FeatureFlagModule,
|
||||
UserRoleModule,
|
||||
ToolProviderModule,
|
||||
SkillModule,
|
||||
TwentyConfigModule,
|
||||
],
|
||||
controllers: [McpCoreController],
|
||||
exports: [McpProtocolService],
|
||||
providers: [McpProtocolService, McpToolExecutorService],
|
||||
providers: [
|
||||
JwtAuthGuard,
|
||||
McpAuthGuard,
|
||||
WorkspaceAuthGuard,
|
||||
McpProtocolService,
|
||||
McpToolExecutorService,
|
||||
],
|
||||
})
|
||||
export class McpModule {}
|
||||
|
||||
-67
@@ -1,15 +1,12 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { EXECUTE_TOOL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
|
||||
import { GET_TOOL_CATALOG_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool';
|
||||
import { LEARN_TOOLS_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
|
||||
@@ -21,7 +18,6 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
|
||||
|
||||
describe('McpProtocolService', () => {
|
||||
let service: McpProtocolService;
|
||||
let featureFlagService: jest.Mocked<FeatureFlagService>;
|
||||
let _toolRegistryService: jest.Mocked<ToolRegistryService>;
|
||||
let userRoleService: jest.Mocked<UserRoleService>;
|
||||
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
|
||||
@@ -54,10 +50,6 @@ describe('McpProtocolService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
McpProtocolService,
|
||||
{
|
||||
provide: FeatureFlagService,
|
||||
useValue: { isFeatureEnabled: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: ToolRegistryService,
|
||||
useValue: {
|
||||
@@ -94,7 +86,6 @@ describe('McpProtocolService', () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<McpProtocolService>(McpProtocolService);
|
||||
featureFlagService = module.get(FeatureFlagService);
|
||||
_toolRegistryService = module.get(ToolRegistryService);
|
||||
userRoleService = module.get(UserRoleService);
|
||||
mcpToolExecutorService = module.get(McpToolExecutorService);
|
||||
@@ -105,31 +96,6 @@ describe('McpProtocolService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('checkAiEnabled', () => {
|
||||
it('should not throw when AI is enabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.checkAiEnabled('workspace-1'),
|
||||
).resolves.not.toThrow();
|
||||
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalledWith(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
'workspace-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when AI is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
await expect(service.checkAiEnabled('workspace-1')).rejects.toThrow(
|
||||
new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleInitialize', () => {
|
||||
it('should return correct initialization response', () => {
|
||||
const requestId = '123';
|
||||
@@ -198,8 +164,6 @@ describe('McpProtocolService', () => {
|
||||
|
||||
describe('handleMCPCoreQuery', () => {
|
||||
it('should handle initialize method', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
@@ -227,7 +191,6 @@ describe('McpProtocolService', () => {
|
||||
});
|
||||
|
||||
it('should build a ToolSet with exactly 5 tools and pass it to executor for tools/call', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const mockToolCallResponse = {
|
||||
@@ -278,7 +241,6 @@ describe('McpProtocolService', () => {
|
||||
});
|
||||
|
||||
it('should build a ToolSet with exactly 5 tools and pass it to executor for tools/list', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
mcpToolExecutorService.handleToolsListing.mockReturnValue({
|
||||
@@ -315,8 +277,6 @@ describe('McpProtocolService', () => {
|
||||
});
|
||||
|
||||
it('should handle tools/call with apiKey authentication', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
const mockToolCallResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
@@ -346,34 +306,7 @@ describe('McpProtocolService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error when AI is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.handleMCPCoreQuery(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
...MCP_SERVER_METADATA,
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
message: 'AI feature is not enabled for this workspace',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error when tool execution fails', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
mcpToolExecutorService.handleToolCall.mockRejectedValue(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet, zodSchema } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
|
||||
@@ -11,7 +10,6 @@ import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entit
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build-api-key-auth-context.util';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import {
|
||||
@@ -43,7 +41,6 @@ const MCP_EXCLUDED_TOOLS = new Set(['code_interpreter', 'http_request']);
|
||||
@Injectable()
|
||||
export class McpProtocolService {
|
||||
constructor(
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly mcpToolExecutorService: McpToolExecutorService,
|
||||
@@ -51,20 +48,6 @@ export class McpProtocolService {
|
||||
private readonly skillService: SkillService,
|
||||
) {}
|
||||
|
||||
async checkAiEnabled(workspaceId: string): Promise<void> {
|
||||
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isAiEnabled) {
|
||||
throw new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleInitialize(requestId: string | number) {
|
||||
return wrapJsonRpcResponse(requestId, {
|
||||
result: {
|
||||
@@ -184,8 +167,6 @@ export class McpProtocolService {
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
await this.checkAiEnabled(workspace.id);
|
||||
|
||||
if (method === 'initialize') {
|
||||
return this.handleInitialize(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user