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:
+24
@@ -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',
|
||||
|
||||
+8
-2
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user