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
+4
View File
@@ -6302,6 +6302,10 @@
"source": "/developers/extend/capabilities/apps",
"destination": "/developers/extend/apps/getting-started"
},
{
"source": "/developers/extend/mcp",
"destination": "/user-guide/ai/capabilities/mcp"
},
{
"source": "/developers/local-setup",
"destination": "/developers/contribute/capabilities/local-setup"
@@ -0,0 +1,139 @@
---
title: MCP Server
description: Connect AI assistants to your Twenty workspace using the Model Context Protocol.
---
<Warning>
MCP is currently in **alpha** and is only available on some workspaces. It may not be enabled for your workspace yet.
</Warning>
Twenty exposes an [MCP](https://modelcontextprotocol.io/) server so that AI assistants — Claude Desktop, Claude Code, Cursor, ChatGPT, and others — can read and write your CRM data through natural language.
Use your **workspace URL** (the URL you use to access Twenty) as the MCP endpoint. On Twenty Cloud, your workspace URL might be `https://{mycompany}.twenty.com` or a custom domain. The server is available at:
| Environment | MCP Endpoint |
|-------------|-------------|
| **Cloud** | `https://{your-workspace-url}/mcp` (e.g. `https://mycompany.twenty.com/mcp`) |
| **Self-Hosted** | `https://{your-domain}/mcp` |
## Authentication Methods
You have two ways to authenticate your MCP client: **OAuth** (recommended) or **API Key**.
### Option A — OAuth (Recommended)
With OAuth, your MCP client opens a browser window for you to log in. No secrets are stored in config files, and tokens refresh automatically.
<Note>
OAuth requires an MCP client that supports the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). Claude Desktop, Claude Code, Cursor, and ChatGPT support it.
</Note>
Add this to your MCP client configuration, replacing `{your-workspace-url}` with your workspace host (e.g. `mycompany.twenty.com`):
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp"
}
}
}
```
That's it — no API key needed. When the client connects for the first time it will:
1. Discover Twenty's OAuth metadata via `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`
2. Register itself as an OAuth client via dynamic client registration (RFC 7591)
3. Open your browser to authorize access
4. Receive tokens and connect to the MCP server
Subsequent connections reuse the stored tokens and refresh them automatically.
### Option B — API Key
If your MCP client does not support OAuth, or you prefer static credentials, pass an API key in the `Authorization` header:
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
<Warning>
Your API key grants access to workspace data. Keep it out of version control and shared dotfiles.
</Warning>
To create an API key, go to **Settings > APIs & Webhooks > + Create key**. See [APIs](/developers/extend/api#create-an-api-key) for details.
## Quick Start
### 1. Copy the config
Go to **Settings > AI > More > MCP Server** in Twenty. Choose your authentication method (OAuth or API Key), copy the JSON snippet (it will already use your workspace URL), and paste it into your MCP client's config file.
| Client | Config file location |
|--------|---------------------|
| **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows) |
| **Claude Code** | `~/.claude.json` (user) or `.mcp.json` (project) |
| **Cursor** | `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` globally |
| **ChatGPT** | Turn on Developer Mode in **Settings > Apps & Connectors > Advanced settings**, then use **Create** in **Settings > Apps & Connectors** to add the MCP server |
### 2. Connect
Restart your MCP client (or reload the config). If using OAuth you will be redirected to Twenty to authorize access. If using an API key the connection is immediate.
### 3. Start using it
Ask your AI assistant to interact with your CRM:
- *"Show me the 5 most recently created companies"*
- *"Create a new person named Jane Doe at Acme Corp"*
- *"Find all open opportunities worth more than $10k"*
## Available Tools
Once connected, the MCP server exposes tools that mirror the Twenty API. The recommended workflow is:
1. **`get_tool_catalog`** — discover all available tools
2. **`learn_tools`** — get the input schema for specific tools
3. **`execute_tool`** — run a tool
You don't need to remember tool names. Ask your AI assistant what it can do and it will call `get_tool_catalog` automatically.
## Permissions
MCP connections inherit the permissions of the authenticated user (OAuth) or the role assigned to the API key. To restrict what the MCP server can do:
- **OAuth**: The user's workspace role applies.
- **API Key**: Assign a role to the API key under **Settings > Roles**. See [Permissions](/user-guide/permissions-access/capabilities/permissions).
## Self-Hosted Configuration
For self-hosted instances, replace `{your-workspace-url}` with your server URL. Make sure `SERVER_URL` in your environment matches the public URL of your Twenty instance — this is used to generate the OAuth discovery metadata.
```bash
SERVER_URL=https://twenty.yourcompany.com
```
The MCP endpoint, OAuth endpoints, and discovery metadata all derive from this value.
## Troubleshooting
**"Unauthorized" or 401 errors**
- OAuth: re-authorize by clearing the stored tokens in your MCP client and reconnecting.
- API Key: verify the key is valid and hasn't expired. Regenerate it if needed.
**OAuth flow doesn't open a browser**
- Ensure your MCP client supports MCP Authorization. Fall back to the API Key method if it doesn't.
**Connection timeout**
- Confirm the MCP endpoint URL is reachable from your machine. For self-hosted instances, check that the server is running and `SERVER_URL` is set correctly.
File diff suppressed because one or more lines are too long
@@ -5,11 +5,13 @@ export const AUTHORIZE_APP = gql`
$clientId: String!
$codeChallenge: String
$redirectUrl: String!
$state: String
) {
authorizeApp(
clientId: $clientId
codeChallenge: $codeChallenge
redirectUrl: $redirectUrl
state: $state
) {
redirectUrl
}
@@ -4,10 +4,10 @@ export const FIND_APPLICATION_REGISTRATION_BY_CLIENT_ID = gql`
query FindApplicationRegistrationByClientId($clientId: String!) {
findApplicationRegistrationByClientId(clientId: $clientId) {
id
logoUrl
name
oAuthScopes
websiteUrl
logoUrl
}
}
`;
@@ -99,9 +99,13 @@ export const Authorize = () => {
profile: t`Read your profile`,
};
const clientId = searchParam.get('clientId');
const codeChallenge = searchParam.get('codeChallenge');
const redirectUrl = searchParam.get('redirectUrl');
// Support both camelCase (legacy) and standard OAuth snake_case params
const clientId = searchParam.get('client_id') ?? searchParam.get('clientId');
const codeChallenge =
searchParam.get('code_challenge') ?? searchParam.get('codeChallenge');
const redirectUrl =
searchParam.get('redirect_uri') ?? searchParam.get('redirectUrl');
const state = searchParam.get('state');
const {
data,
@@ -137,6 +141,7 @@ export const Authorize = () => {
clientId,
codeChallenge: codeChallenge ?? undefined,
redirectUrl,
state: state ?? undefined,
},
onCompleted: (responseData) => {
redirect(responseData.authorizeApp.redirectUrl);
@@ -1,16 +1,22 @@
import { useState } from 'react';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { H2Title, IconCopy, IconPlug } from 'twenty-ui/display';
import { Button, CodeEditor } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledWrapper = styled.div`
const StyledConfigWrapper = styled.div`
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.md};
margin: 0 ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[4]};
`;
const StyledCopyButton = styled.div`
@@ -32,11 +38,27 @@ const StyledEditorContainer = styled.div`
}
`;
type McpAuthMethod = 'oauth' | 'api-key';
export const SettingsAIMCP = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const [authMethod, setAuthMethod] = useState<McpAuthMethod>('oauth');
const mcpConfig = JSON.stringify(
const oauthConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
},
},
},
null,
2,
);
const apiKeyConfig = JSON.stringify(
{
mcpServers: {
twenty: {
@@ -52,54 +74,79 @@ export const SettingsAIMCP = () => {
2,
);
const isOAuth = authMethod === 'oauth';
const activeConfig = isOAuth ? oauthConfig : apiKeyConfig;
const editorHeight = isOAuth ? 170 : 230;
const codeEditorOptions = {
readOnly: true,
domReadOnly: true,
renderLineHighlight: 'none' as const,
renderLineHighlightOnlyWhenFocus: false,
lineNumbers: 'off' as const,
folding: false,
selectionHighlight: false,
occurrencesHighlight: 'off' as const,
hover: {
enabled: false,
},
guides: {
indentation: false,
bracketPairs: false,
bracketPairsHorizontal: false,
},
padding: {
top: 12,
},
};
return (
<Section>
<H2Title
title={t`MCP Server`}
description={t`Access your workspace data from your favorite MCP client like Claude Desktop, Windsurf or Cursor.`}
description={t`Access your workspace data from your favorite MCP client like Claude Desktop, Claude Code, Cursor, or ChatGPT. Once connected, try: "Show me the 5 most recently created companies" or "Create a new person named Jane Doe".`}
/>
<StyledWrapper>
<StyledEditorContainer style={{ position: 'relative' }}>
<StyledCopyButton>
<Button
Icon={IconCopy}
onClick={() => {
copyToClipboard(
mcpConfig,
t`MCP Configuration copied to clipboard`,
);
}}
type="button"
/>
</StyledCopyButton>
<CodeEditor
value={mcpConfig}
language="application/json"
options={{
readOnly: true,
domReadOnly: true,
renderLineHighlight: 'none',
renderLineHighlightOnlyWhenFocus: false,
lineNumbers: 'off',
folding: false,
selectionHighlight: false,
occurrencesHighlight: 'off',
hover: {
enabled: false,
},
guides: {
indentation: false,
bracketPairs: false,
bracketPairsHorizontal: false,
},
padding: {
top: 12,
},
}}
height={230}
<Card rounded>
<SettingsOptionCardContentSelect
Icon={IconPlug}
title={t`Authentication method`}
description={t`OAuth or API Key`}
>
<Select
dropdownId="mcp-auth-method-select"
value={authMethod}
onChange={(value) => setAuthMethod(value as McpAuthMethod)}
options={[
{ label: t`OAuth`, value: 'oauth' },
{ label: t`API Key`, value: 'api-key' },
]}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.Medium}
/>
</StyledEditorContainer>
</StyledWrapper>
</SettingsOptionCardContentSelect>
<StyledConfigWrapper>
<StyledEditorContainer style={{ position: 'relative' }}>
<StyledCopyButton>
<Button
Icon={IconCopy}
onClick={() => {
copyToClipboard(
activeConfig,
t`MCP Configuration copied to clipboard`,
);
}}
type="button"
/>
</StyledCopyButton>
<CodeEditor
value={activeConfig}
language="application/json"
options={codeEditorOptions}
height={editorHeight}
/>
</StyledEditorContainer>
</StyledConfigWrapper>
</Card>
</Section>
);
};
@@ -53,6 +53,10 @@ const SOURCE_TYPE_BADGE_CONFIG: Record<
label: 'Internal',
color: 'green',
},
[ApplicationRegistrationSourceType.OAUTH_ONLY]: {
label: 'OAuth',
color: 'blue',
},
};
export const SettingsApplicationsDeveloperTab = () => {
@@ -60,6 +60,7 @@ enum ApplicationRegistrationSourceType {
NPM
TARBALL
LOCAL
OAUTH_ONLY
}
type TwoFactorAuthenticationMethodSummary {
@@ -61,7 +61,7 @@ export interface ApplicationRegistration {
__typename: 'ApplicationRegistration'
}
export type ApplicationRegistrationSourceType = 'NPM' | 'TARBALL' | 'LOCAL'
export type ApplicationRegistrationSourceType = 'NPM' | 'TARBALL' | 'LOCAL' | 'OAUTH_ONLY'
export interface TwoFactorAuthenticationMethodSummary {
twoFactorAuthenticationMethodId: Scalars['UUID']
@@ -8307,7 +8307,8 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
export const enumApplicationRegistrationSourceType = {
NPM: 'NPM' as const,
TARBALL: 'TARBALL' as const,
LOCAL: 'LOCAL' as const
LOCAL: 'LOCAL' as const,
OAUTH_ONLY: 'OAUTH_ONLY' as const
}
export const enumRowLevelPermissionPredicateGroupLogicalOperator = {
@@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common';
import { Command, CommandRunner } from 'nest-commander';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
@@ -60,6 +61,7 @@ export class CronRegisterAllCommand extends CommandRunner {
private readonly twentyConfigService: TwentyConfigService,
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand,
private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand,
) {
super();
}
@@ -156,6 +158,10 @@ export class CronRegisterAllCommand extends CommandRunner {
name: 'EnterpriseKeyValidation',
command: this.enterpriseKeyValidationCronCommand,
},
{
name: 'StaleRegistrationCleanup',
command: this.staleRegistrationCleanupCronCommand,
},
];
let successCount = 0;
@@ -10,6 +10,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
import { StaleRegistrationCleanupModule } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/stale-registration-cleanup.module';
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
@@ -64,6 +65,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
TwentyConfigModule,
MarketplaceModule,
ApplicationUpgradeModule,
StaleRegistrationCleanupModule,
],
providers: [
DataSeedWorkspaceCommand,
@@ -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);
}
@@ -65,6 +65,17 @@ export class ApplicationInstallService {
return true;
}
if (
appRegistration.sourceType ===
ApplicationRegistrationSourceType.OAUTH_ONLY
) {
this.logger.log(
`Skipping install for OAUTH_ONLY app ${appRegistration.universalIdentifier} (OAuth-only clients have no code artifacts)`,
);
return true;
}
const lockKey = `app-install:${params.workspaceId}:${appRegistration.universalIdentifier}`;
return this.cacheLockService.withLock(
@@ -3,14 +3,17 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule as ApplicationCoreModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationOAuthResolver } from 'src/engine/core-modules/application/application-oauth/application-oauth.resolver';
import { OAuthDiscoveryController } from 'src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller';
import { OAuthRegistrationController } from 'src/engine/core-modules/application/application-oauth/controllers/oauth-registration.controller';
import { OAuthTokenController } from 'src/engine/core-modules/application/application-oauth/controllers/oauth-token.controller';
import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@@ -23,19 +26,25 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
TypeOrmModule.forFeature([
AppTokenEntity,
ApplicationEntity,
ApplicationRegistrationEntity,
UserWorkspaceEntity,
]),
ApplicationRegistrationModule,
ApplicationCoreModule,
ApplicationInstallModule,
TokenModule,
DomainServerConfigModule,
FeatureFlagModule,
PermissionsModule,
ThrottlerModule,
TwentyConfigModule,
WorkspaceCacheStorageModule,
],
controllers: [OAuthTokenController, OAuthDiscoveryController],
controllers: [
OAuthTokenController,
OAuthDiscoveryController,
OAuthRegistrationController,
],
providers: [OAuthService, ApplicationOAuthResolver],
exports: [OAuthService],
})
@@ -1,23 +1,29 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('.well-known')
export class OAuthDiscoveryController {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly domainServerConfigService: DomainServerConfigService,
) {}
@Get('oauth-authorization-server')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
getAuthorizationServerMetadata() {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const frontUrl = this.domainServerConfigService.getFrontUrl().toString();
return {
issuer: serverUrl,
authorization_endpoint: `${serverUrl}/authorize`,
authorization_endpoint: `${frontUrl.replace(/\/$/, '')}/authorize`,
token_endpoint: `${serverUrl}/oauth/token`,
registration_endpoint: `${serverUrl}/oauth/register`,
revocation_endpoint: `${serverUrl}/oauth/revoke`,
introspection_endpoint: `${serverUrl}/oauth/introspect`,
scopes_supported: ALL_OAUTH_SCOPES,
@@ -33,4 +39,18 @@ export class OAuthDiscoveryController {
introspection_endpoint_auth_methods_supported: ['client_secret_post'],
};
}
// RFC 9728: OAuth 2.0 Protected Resource Metadata
@Get('oauth-protected-resource')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
getProtectedResourceMetadata() {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return {
resource: `${serverUrl}/mcp`,
authorization_servers: [serverUrl],
scopes_supported: ALL_OAUTH_SCOPES,
bearer_methods_supported: ['header'],
};
}
}
@@ -0,0 +1,199 @@
import {
Body,
Controller,
HttpCode,
Post,
Req,
Res,
UseFilters,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { type Request, type Response } from 'express';
import { v4 } from 'uuid';
import { InjectRepository } from '@nestjs/typeorm';
import {
ALL_OAUTH_SCOPES,
type OAuthScope,
} from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
import { OAuthRegisterInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-register.input';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
import { ThrottlerException } from 'src/engine/core-modules/throttler/throttler.exception';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { Repository } from 'typeorm';
// RFC 7591: 10 registrations per hour per IP
const REGISTRATION_RATE_LIMIT_MAX =
process.env.NODE_ENV === NodeEnvironment.DEVELOPMENT ? 100 : 10;
const REGISTRATION_RATE_LIMIT_WINDOW_MS = 3_600_000;
const ALLOWED_GRANT_TYPES = ['authorization_code', 'refresh_token'];
const ALLOWED_RESPONSE_TYPES = ['code'];
@Controller('oauth')
@UseFilters(AuthRestApiExceptionFilter)
export class OAuthRegistrationController {
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly throttlerService: ThrottlerService,
) {}
@Post('register')
@HttpCode(201)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@UsePipes(new ValidationPipe())
async register(
@Body() body: OAuthRegisterInput,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const rateLimitResult = await this.applyRateLimit(req);
if (rateLimitResult) {
res.status(429);
return rateLimitResult;
}
// Validate redirect URIs
for (const uri of body.redirect_uris) {
const result = validateRedirectUri(uri);
if (!result.valid) {
res.status(400);
return {
error: 'invalid_client_metadata',
error_description: result.reason,
};
}
}
if (body.redirect_uris.length === 0) {
res.status(400);
return {
error: 'invalid_client_metadata',
error_description: 'At least one redirect_uri is required',
};
}
// Validate grant_types — only authorization_code allowed for dynamic clients
const grantTypes = body.grant_types ?? ['authorization_code'];
for (const grantType of grantTypes) {
if (!ALLOWED_GRANT_TYPES.includes(grantType)) {
res.status(400);
return {
error: 'invalid_client_metadata',
error_description: `Unsupported grant_type: ${grantType}. Only authorization_code and refresh_token are allowed for dynamic registrations.`,
};
}
}
// Validate response_types
const responseTypes = body.response_types ?? ['code'];
for (const responseType of responseTypes) {
if (!ALLOWED_RESPONSE_TYPES.includes(responseType)) {
res.status(400);
return {
error: 'invalid_client_metadata',
error_description: `Unsupported response_type: ${responseType}`,
};
}
}
// Validate token_endpoint_auth_method — only 'none' for public clients
const tokenEndpointAuthMethod = body.token_endpoint_auth_method ?? 'none';
if (tokenEndpointAuthMethod !== 'none') {
res.status(400);
return {
error: 'invalid_client_metadata',
error_description:
'Only token_endpoint_auth_method "none" is supported for dynamic registrations (public clients with PKCE)',
};
}
// Parse and validate scopes — cap to allowed scopes
const validScopes: readonly string[] = ALL_OAUTH_SCOPES;
const requestedScopes = body.scope
? body.scope.split(' ').filter((s) => validScopes.includes(s))
: [...ALL_OAUTH_SCOPES];
const clientId = v4();
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: v4(),
name: body.client_name,
description: null,
logoUrl: body.logo_uri ?? null,
author: null,
oAuthClientId: clientId,
oAuthClientSecretHash: null,
oAuthRedirectUris: body.redirect_uris,
oAuthScopes: requestedScopes as OAuthScope[],
createdByUserId: null,
ownerWorkspaceId: null,
sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY,
websiteUrl: body.client_uri ?? null,
});
await this.applicationRegistrationRepository.save(registration);
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
return {
client_id: clientId,
client_name: body.client_name,
redirect_uris: body.redirect_uris,
grant_types: grantTypes,
response_types: responseTypes,
token_endpoint_auth_method: tokenEndpointAuthMethod,
scope: requestedScopes.join(' '),
client_id_issued_at: Math.floor(Date.now() / 1000),
};
}
private async applyRateLimit(
req: Request,
): Promise<{ error: string; error_description: string } | null> {
const rateLimitKey = `oauth-register:${req.ip}`;
try {
await this.throttlerService.tokenBucketThrottleOrThrow(
rateLimitKey,
1,
REGISTRATION_RATE_LIMIT_MAX,
REGISTRATION_RATE_LIMIT_WINDOW_MS,
);
return null;
} catch (error) {
if (error instanceof ThrottlerException) {
return {
error: 'rate_limit_exceeded',
error_description:
'Too many registration requests, please try again later',
};
}
throw error;
}
}
}
@@ -0,0 +1,52 @@
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
// RFC 7591: OAuth 2.0 Dynamic Client Registration
export class OAuthRegisterInput {
@IsString()
@MaxLength(256)
client_name: string;
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
@MaxLength(2048, { each: true })
redirect_uris: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(5)
grant_types?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(5)
response_types?: string[];
@IsOptional()
@IsString()
@MaxLength(50)
token_endpoint_auth_method?: string;
@IsOptional()
@IsString()
@MaxLength(256)
scope?: string;
@IsOptional()
@IsString()
@MaxLength(2048)
client_uri?: string;
@IsOptional()
@IsString()
@MaxLength(2048)
logo_uri?: string;
}
@@ -0,0 +1,33 @@
import { Command, CommandRunner } from 'nest-commander';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { STALE_REGISTRATION_CLEANUP_CRON_PATTERN } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/constants/stale-registration-cleanup-cron-pattern.constant';
import { StaleRegistrationCleanupCronJob } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/crons/stale-registration-cleanup.cron.job';
@Command({
name: 'cron:stale-registration-cleanup',
description:
'Starts a cron job to clean up stale OAuth-only application registrations',
})
export class StaleRegistrationCleanupCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: StaleRegistrationCleanupCronJob.name,
data: undefined,
options: {
repeat: {
pattern: STALE_REGISTRATION_CLEANUP_CRON_PATTERN,
},
},
});
}
}
@@ -0,0 +1,2 @@
// Runs daily at 02:30 AM UTC
export const STALE_REGISTRATION_CLEANUP_CRON_PATTERN = '30 2 * * *';
@@ -0,0 +1,41 @@
import { Injectable, Logger } from '@nestjs/common';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { STALE_REGISTRATION_CLEANUP_CRON_PATTERN } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/constants/stale-registration-cleanup-cron-pattern.constant';
import { StaleRegistrationCleanupService } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/services/stale-registration-cleanup.service';
@Injectable()
@Processor(MessageQueue.cronQueue)
export class StaleRegistrationCleanupCronJob {
private readonly logger = new Logger(StaleRegistrationCleanupCronJob.name);
constructor(
private readonly staleRegistrationCleanupService: StaleRegistrationCleanupService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
@Process(StaleRegistrationCleanupCronJob.name)
@SentryCronMonitor(
StaleRegistrationCleanupCronJob.name,
STALE_REGISTRATION_CLEANUP_CRON_PATTERN,
)
async handle(): Promise<void> {
this.logger.log('Starting stale OAuth registration cleanup');
try {
const deletedCount =
await this.staleRegistrationCleanupService.cleanupStaleRegistrations();
this.logger.log(
`Stale OAuth registration cleanup completed: ${deletedCount} registration(s) deleted`,
);
} catch (error) {
this.exceptionHandlerService.captureExceptions([error]);
throw error;
}
}
}
@@ -0,0 +1,131 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { STALE_REGISTRATION_CLEANUP_BATCH_SIZE } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/constants/stale-registration-cleanup-batch-size.constant';
import { STALE_REGISTRATION_GRACE_PERIOD_DAYS } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/constants/stale-registration-grace-period-days.constant';
@Injectable()
export class StaleRegistrationCleanupService {
private readonly logger = new Logger(StaleRegistrationCleanupService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async cleanupStaleRegistrations(): Promise<number> {
const cutoffDate = this.calculateCutoffDate();
let totalDeleted = 0;
let lastCreatedAt: Date | undefined;
while (true) {
const staleRegistrations = await this.findStaleRegistrationBatch(
cutoffDate,
STALE_REGISTRATION_CLEANUP_BATCH_SIZE,
lastCreatedAt,
);
if (staleRegistrations.length === 0) {
break;
}
lastCreatedAt =
staleRegistrations[staleRegistrations.length - 1].createdAt;
const staleIds = staleRegistrations.map(
(registration) => registration.id,
);
// Filter out registrations that have active (non-deleted) installations
const registrationsWithInstallations = await this.applicationRepository
.createQueryBuilder('application')
.select('application.applicationRegistrationId')
.where(
'application.applicationRegistrationId IN (:...registrationIds)',
{ registrationIds: staleIds },
)
.andWhere('application.deletedAt IS NULL')
.groupBy('application.applicationRegistrationId')
.getRawMany<{ application_applicationRegistrationId: string }>();
const registrationIdsWithInstallations = new Set(
registrationsWithInstallations.map(
(row) => row.application_applicationRegistrationId,
),
);
const idsToDelete = staleIds.filter(
(id) => !registrationIdsWithInstallations.has(id),
);
if (idsToDelete.length > 0) {
await this.applicationRegistrationRepository.softDelete({
id: In(idsToDelete),
});
this.logger.log(
`Deleted ${idsToDelete.length} stale OAuth registration(s)`,
);
totalDeleted += idsToDelete.length;
}
if (staleRegistrations.length < STALE_REGISTRATION_CLEANUP_BATCH_SIZE) {
break;
}
}
return totalDeleted;
}
private async findStaleRegistrationBatch(
cutoffDate: Date,
batchSize: number,
afterCreatedAt?: Date,
): Promise<Array<{ id: string; createdAt: Date }>> {
const queryBuilder = this.applicationRegistrationRepository
.createQueryBuilder('registration')
.select('registration.id', 'id')
.addSelect('registration.createdAt', 'createdAt')
.where('registration.sourceType = :sourceType', {
sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY,
})
.andWhere('registration.createdAt < :cutoffDate', { cutoffDate })
.orderBy('registration.createdAt', 'ASC')
.take(batchSize);
if (afterCreatedAt) {
queryBuilder.andWhere('registration.createdAt > :afterCreatedAt', {
afterCreatedAt,
});
}
const rows = await queryBuilder.getRawMany<{
id: string;
createdAt: Date;
}>();
return rows.map((row) => ({
id: row.id,
createdAt: new Date(row.createdAt),
}));
}
private calculateCutoffDate(): Date {
const cutoffDate = new Date();
cutoffDate.setUTCHours(0, 0, 0, 0);
cutoffDate.setUTCDate(
cutoffDate.getUTCDate() - STALE_REGISTRATION_GRACE_PERIOD_DAYS,
);
return cutoffDate;
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
import { StaleRegistrationCleanupCronJob } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/crons/stale-registration-cleanup.cron.job';
import { StaleRegistrationCleanupService } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/services/stale-registration-cleanup.service';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationRegistrationEntity,
ApplicationEntity,
]),
],
providers: [
StaleRegistrationCleanupService,
StaleRegistrationCleanupCronJob,
StaleRegistrationCleanupCronCommand,
],
exports: [StaleRegistrationCleanupCronCommand],
})
export class StaleRegistrationCleanupModule {}
@@ -92,6 +92,7 @@ export class ApplicationPackageFetcherService implements OnModuleInit {
case ApplicationRegistrationSourceType.TARBALL:
return this.resolveFromTarball(appRegistration);
case ApplicationRegistrationSourceType.LOCAL:
case ApplicationRegistrationSourceType.OAUTH_ONLY:
return null;
}
}
@@ -4,6 +4,7 @@ export enum ApplicationRegistrationSourceType {
NPM = 'npm',
TARBALL = 'tarball',
LOCAL = 'local',
OAUTH_ONLY = 'oauth-only',
}
registerEnumType(ApplicationRegistrationSourceType, {
@@ -101,10 +101,13 @@ export class ApplicationUpgradeService {
if (
appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL ||
appRegistration.sourceType === ApplicationRegistrationSourceType.TARBALL
appRegistration.sourceType ===
ApplicationRegistrationSourceType.TARBALL ||
appRegistration.sourceType ===
ApplicationRegistrationSourceType.OAUTH_ONLY
) {
throw new ApplicationException(
'Cannot upgrade an app installed from a tarball or local source',
'Cannot upgrade an app installed from a tarball, local source, or OAuth-only registration',
ApplicationExceptionCode.UPGRADE_FAILED,
);
}
@@ -53,6 +53,30 @@ describe('validateRedirectUri', () => {
}
});
it('should accept cursor:// custom scheme for desktop app OAuth', () => {
const result = validateRedirectUri(
'cursor://anysphere.cursor-mcp/oauth/callback',
);
expect(result.valid).toBe(true);
});
it('should accept vscode:// custom scheme', () => {
const result = validateRedirectUri('vscode://vscode.github/oauth/callback');
expect(result.valid).toBe(true);
});
it('should reject unknown custom schemes', () => {
const result = validateRedirectUri('evilapp://callback');
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.reason).toContain('allowed custom scheme');
}
});
it('should accept HTTPS with query parameters', () => {
const result = validateRedirectUri(
'https://example.com/callback?state=abc',
@@ -1,4 +1,7 @@
// RFC 6749 redirect URI validation: must be absolute, HTTPS (except localhost), no fragments
// Custom URI schemes (cursor://, vscode://) allowed for desktop app OAuth flows
const ALLOWED_CUSTOM_SCHEMES = ['cursor:', 'vscode:', 'code:'];
export const validateRedirectUri = (
uri: string,
): { valid: true; parsed: URL } | { valid: false; reason: string } => {
@@ -12,11 +15,14 @@ export const validateRedirectUri = (
const isLocalhost =
parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
const isAllowedCustomScheme = ALLOWED_CUSTOM_SCHEMES.includes(
parsed.protocol,
);
if (parsed.protocol !== 'https:' && !isLocalhost) {
if (parsed.protocol !== 'https:' && !isLocalhost && !isAllowedCustomScheme) {
return {
valid: false,
reason: `Redirect URIs must use HTTPS (except localhost): ${uri}`,
reason: `Redirect URIs must use HTTPS (except localhost) or an allowed custom scheme: ${uri}`,
};
}
@@ -48,7 +48,10 @@ export class JwtAuthGuard implements CanActivate {
return true;
} catch (error) {
this.logger.warn(`Auth failed with error: ${error}`);
const errorMessage =
error instanceof Error ? error.message : String(error);
this.logger.warn(`Auth failed: ${errorMessage}`);
return false;
}
@@ -1,15 +1,28 @@
import { type CanActivate, type ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import {
type CanActivate,
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { type Observable } from 'rxjs';
import { getRequest } from 'src/utils/extract-request';
@Injectable()
export class WorkspaceAuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
const request = getRequest(context);
return request.workspace !== undefined;
if (!request) {
return false;
}
if (!request.workspace) {
return false;
}
return true;
}
}