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:
Félix Malfait
2026-03-16 09:42:28 +01:00
committed by GitHub
parent 87c519b72f
commit 95a35f8a1d
38 changed files with 984 additions and 165 deletions
@@ -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 {}
@@ -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);
}