Merge remote-tracking branch 'origin/master' into ADO-4283

This commit is contained in:
Charlie Kolb 2025-11-11 11:57:13 +01:00
commit 1ad4918e15
No known key found for this signature in database
327 changed files with 15617 additions and 3831 deletions

View File

@ -0,0 +1,97 @@
name: Trigger build/unit tests on PR comment
on:
issue_comment:
types: [created]
permissions:
pull-requests: read
contents: read
actions: write
jobs:
validate_and_dispatch:
name: Validate user and dispatch CI workflow
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/build-unit-test')
runs-on: ubuntu-latest
steps:
- name: Validate user permissions and collect PR data
id: check_permissions
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commenter = context.actor;
const body = (context.payload.comment.body || '').trim();
const isCommand = body.startsWith('/build-unit-test');
const allowedPermissions = ['admin', 'write', 'maintain'];
const commentId = context.payload.comment.id;
const { owner, repo } = context.repo;
async function react(content) {
try {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: commentId,
content,
});
} catch (error) {
console.log(`Failed to add reaction '${content}': ${error.message}`);
}
}
core.setOutput('proceed', 'false');
core.setOutput('headSha', '');
core.setOutput('prNumber', '');
if (!context.payload.issue.pull_request || !isCommand) {
console.log('Comment is not /build-unit-test on a pull request. Skipping.');
return;
}
let permission;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: commenter,
});
permission = data.permission;
} catch (error) {
console.log(`Could not verify permissions for @${commenter}: ${error.message}`);
await react('confused');
return;
}
if (!allowedPermissions.includes(permission)) {
console.log(`User @${commenter} has '${permission}' permission; requires admin/write/maintain.`);
await react('-1');
return;
}
try {
const prNumber = context.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
await react('+1');
core.setOutput('proceed', 'true');
core.setOutput('headSha', pr.head.sha);
core.setOutput('prNumber', String(prNumber));
} catch (error) {
console.log(`Failed to fetch PR details for PR #${context.issue.number}: ${error.message}`);
await react('confused');
}
- name: Dispatch build/unit test workflow
if: ${{ steps.check_permissions.outputs.proceed == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ steps.check_permissions.outputs.headSha }}
run: |
gh workflow run ci-manual-build-unit-tests.yml \
--repo "${{ github.repository }}" \
-f ref="${HEAD_SHA}"

View File

@ -0,0 +1,55 @@
name: Build, unit test and lint (manual trigger)
on:
workflow_dispatch:
inputs:
ref:
description: Commit SHA or ref to check out
required: true
jobs:
install-and-build:
name: Install & Build
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=6144'
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ inputs.ref }}
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Run format check
run: pnpm format:check
- name: Run typecheck
run: pnpm typecheck
unit-tests:
name: Unit tests
needs: install-and-build
uses: ./.github/workflows/units-tests-reusable.yml
with:
ref: ${{ inputs.ref }}
collectCoverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
lint:
name: Lint
needs: install-and-build
uses: ./.github/workflows/linting-reusable.yml
with:
ref: ${{ inputs.ref }}
post-build-unit-tests:
name: Build & Unit Tests - Checks
runs-on: ubuntu-latest
needs: [install-and-build, unit-tests, lint]
if: always()
steps:
- name: Fail if any job failed
if: needs.install-and-build.result == 'failure' || needs.unit-tests.result == 'failure' || needs.lint.result == 'failure'
run: exit 1

View File

@ -26,6 +26,7 @@ jobs:
publish-to-npm:
name: Publish to NPM
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 20
permissions:
id-token: write

View File

@ -1,3 +1,49 @@
# [1.120.0](https://github.com/n8n-io/n8n/compare/n8n@1.119.0...n8n@1.120.0) (2025-11-10)
### Bug Fixes
* **ai-builder:** Add support for node versions in searching, adding and updating nodes ([#21488](https://github.com/n8n-io/n8n/issues/21488)) ([8270f37](https://github.com/n8n-io/n8n/commit/8270f37df5fa1d14b1eb2f16606a9128aeab74e3))
* **ai-builder:** Reduce "workflow state too big" errors ([#21542](https://github.com/n8n-io/n8n/issues/21542)) ([e5d7fb9](https://github.com/n8n-io/n8n/commit/e5d7fb971d6f8f68480dc8c0ac55c6852e248dbe))
* **API:** Fix returning role as slug on the users api handler ([#21490](https://github.com/n8n-io/n8n/issues/21490)) ([941a54e](https://github.com/n8n-io/n8n/commit/941a54e723768c317d82d034737af6c33e4107b6))
* Change unicode range to support more characters in expression parser ([#21394](https://github.com/n8n-io/n8n/issues/21394)) ([0a799e1](https://github.com/n8n-io/n8n/commit/0a799e1cabe17518dc6aa5a36fc303e79505492c))
* **Code Node:** Update error message when using `.item` in `Run once for all items` mode ([#21416](https://github.com/n8n-io/n8n/issues/21416)) ([306972d](https://github.com/n8n-io/n8n/commit/306972d914c3f698ec1f43e2a0dc0839f06395d0))
* **core:** Column size for token column ([#21609](https://github.com/n8n-io/n8n/issues/21609)) ([8504beb](https://github.com/n8n-io/n8n/commit/8504beb154ef6ec2e6892cca851b0efdae366ddb))
* **core:** Include role in user-invite-email-click ([#21546](https://github.com/n8n-io/n8n/issues/21546)) ([27fd768](https://github.com/n8n-io/n8n/commit/27fd768deb9a1fc4e38cc8a524d224049c210da8))
* **core:** Insights fix same day queries ([#21574](https://github.com/n8n-io/n8n/issues/21574)) ([c100736](https://github.com/n8n-io/n8n/commit/c1007367458f1b0554c4f2b00f6fd907ef23d000))
* **core:** Insights use time aware range when end date is today, and start of day for past ranges ([#21540](https://github.com/n8n-io/n8n/issues/21540)) ([4dc58aa](https://github.com/n8n-io/n8n/commit/4dc58aacf851ab41039a12f8c96eacbe57b6b2cb))
* **editor:** Ensure license activation modal works when used without EULA ([#21681](https://github.com/n8n-io/n8n/issues/21681)) ([4e70050](https://github.com/n8n-io/n8n/commit/4e70050ab250417c12dc018d2ae972018ff4cb85))
* **editor:** Fix button image link in easy AI template sticky note for UK users ([#21527](https://github.com/n8n-io/n8n/issues/21527)) ([74a0b51](https://github.com/n8n-io/n8n/commit/74a0b51c4636b1760a10c5cb83bebbfdbcb8fca7))
* **editor:** Fix hanging logs panel tooltip ([#21631](https://github.com/n8n-io/n8n/issues/21631)) ([53efa28](https://github.com/n8n-io/n8n/commit/53efa2842ebc3b6e015a718e383f6ad4b1cbb107))
* **editor:** Fix main button create variable disable state based on scopes ([#21521](https://github.com/n8n-io/n8n/issues/21521)) ([d2e623e](https://github.com/n8n-io/n8n/commit/d2e623e2050e20e6cc44cb0233958ff0186e1e1e))
* **editor:** Fix preview for json output with long values ([#21412](https://github.com/n8n-io/n8n/issues/21412)) ([f354200](https://github.com/n8n-io/n8n/commit/f354200c84e6c9e43ec34bda56781b58ac61cf44))
* **editor:** Limit telemetry event size to 32kb ([#21312](https://github.com/n8n-io/n8n/issues/21312)) ([b68d3bf](https://github.com/n8n-io/n8n/commit/b68d3bf534fb2e1e4a9c7d5e469852a586185bd2))
* **editor:** Log view doesn't scroll in manual execution ([#21529](https://github.com/n8n-io/n8n/issues/21529)) ([6945e21](https://github.com/n8n-io/n8n/commit/6945e214233fdd3635d1bcb03968350a5d0905df))
* **Embeddings AWS Bedrock Node, AWS Bedrock Chat Model Node:** Fix HTTP proxy ([#21509](https://github.com/n8n-io/n8n/issues/21509)) ([53d91ee](https://github.com/n8n-io/n8n/commit/53d91ee89fbb6d28c63f841ca1b1acd21d6ab66f))
* Ensure workflows and folders updatedAt/createdAt aren't mixed up in project sorting ([#21484](https://github.com/n8n-io/n8n/issues/21484)) ([d9d36bf](https://github.com/n8n-io/n8n/commit/d9d36bf28f361d2c333b375744b4d7b51619e5a9))
* **Google Workspace Admin Node:** Include `changePasswordAtNextLogin`, `password` in update ([#21522](https://github.com/n8n-io/n8n/issues/21522)) ([477ffea](https://github.com/n8n-io/n8n/commit/477ffea4ced772de7aa06033536c074a4720089a))
* Prevent multiple api requests when changing workflow owner ([#21335](https://github.com/n8n-io/n8n/issues/21335)) ([b610e55](https://github.com/n8n-io/n8n/commit/b610e550f76723a33872c8408e9d506112a1c589))
* **SendGrid Node:** Use `/scopes` for credential testing ([#21499](https://github.com/n8n-io/n8n/issues/21499)) ([c5db57f](https://github.com/n8n-io/n8n/commit/c5db57fd8b7fd32009aacb24e12357a6120eeca1))
* **Slack Node:** Prevent invalid array arg on team join ([#20382](https://github.com/n8n-io/n8n/issues/20382)) ([afd40c6](https://github.com/n8n-io/n8n/commit/afd40c67093e551fc5c16dd39c57bf54446e32e3))
### Features
* Add support for mysql / mariadb ([#21525](https://github.com/n8n-io/n8n/issues/21525)) ([9bcad5a](https://github.com/n8n-io/n8n/commit/9bcad5ae2d63cdff0218636a845a1c7556dbb957))
* Add unit tests for getAttributesFromLoginResponse and handleSamlLogin ([#21678](https://github.com/n8n-io/n8n/issues/21678)) ([9e240d6](https://github.com/n8n-io/n8n/commit/9e240d6d748381a441c9f02ea5311da3f229f74b))
* Allow CORS in the discovery endpoints ([#21602](https://github.com/n8n-io/n8n/issues/21602)) ([3070e44](https://github.com/n8n-io/n8n/commit/3070e446bfec16c743cc2ac58c6ca9a9bd9106ee))
* **core:** Adapt breaking changes report data to UI needs ([#21442](https://github.com/n8n-io/n8n/issues/21442)) ([a2a484e](https://github.com/n8n-io/n8n/commit/a2a484ecf23b7c64f9cb98737de0ae202d0b70dc))
* **core:** Add OAuth to MCP server ([#21469](https://github.com/n8n-io/n8n/issues/21469)) ([cd167ac](https://github.com/n8n-io/n8n/commit/cd167ac6db5d30b21b9dbcee2ca870cfcfa0bcbe))
* **core:** Add workflow descriptions ([#21526](https://github.com/n8n-io/n8n/issues/21526)) ([ecc6706](https://github.com/n8n-io/n8n/commit/ecc67062a435a1924efbc0f6be7a03d4076fae19))
* **core:** Just in time role provisioning for SAML login ([#21387](https://github.com/n8n-io/n8n/issues/21387)) ([2eb1de6](https://github.com/n8n-io/n8n/commit/2eb1de6c82cd3d642ff3bbb50489c9c1b552ca4a))
* **editor:** Data size warning in AI Logs input/output sections ([#21555](https://github.com/n8n-io/n8n/issues/21555)) ([09f91a8](https://github.com/n8n-io/n8n/commit/09f91a8f45b702d168b466e183c0f09ce9e7fda4))
* **Extract from File Node:** Add `Skip Records With Errors` option ([#21347](https://github.com/n8n-io/n8n/issues/21347)) ([0ccf470](https://github.com/n8n-io/n8n/commit/0ccf47044a2ba5b94140bfdd2ba36b868091288d))
* Provide data export of access settings when enabling JIT ([#21532](https://github.com/n8n-io/n8n/issues/21532)) ([146e4ad](https://github.com/n8n-io/n8n/commit/146e4ad268c7d0e17af66bd9a00599e034e9ee54))
* **Redis Node:** Add list length (LLEN) operation ([#21420](https://github.com/n8n-io/n8n/issues/21420)) ([b0df438](https://github.com/n8n-io/n8n/commit/b0df43828604c23b6d95ed10465e2951b251355e))
* Use experiment feature flag for SSO provisioning (no changelog) ([#21494](https://github.com/n8n-io/n8n/issues/21494)) ([a2d6c8d](https://github.com/n8n-io/n8n/commit/a2d6c8d65f82b475d891a71382641404bbe36b05))
# [1.119.0](https://github.com/n8n-io/n8n/compare/n8n@1.118.0...n8n@1.119.0) (2025-11-03)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.119.0",
"version": "1.120.0",
"private": true,
"engines": {
"node": ">=22.16",
@ -105,6 +105,7 @@
"tsconfig-paths": "^4.2.0",
"typescript": "catalog:",
"vue-tsc": "^2.2.8",
"gaxios": ">=7.1.1",
"google-gax": "^4.3.7",
"ws": ">=8.17.1",
"brace-expansion@1": "1.1.12",

View File

@ -95,52 +95,52 @@ export async function generateTestCases(
export const basicTestCases: TestCase[] = [
{
id: 'invoice-pipeline',
name: 'Invoice processing pipeline',
id: 'multi-agent-research',
name: 'Multi-agent research workflow',
prompt:
'Create an invoice parsing workflow using n8n forms. Extract key information (vendor, date, amount, line items) using AI, validate the data, and store structured information in Airtable. Generate a weekly spending report every Sunday at 6 PM using AI analysis and send via email.',
},
{
id: 'ai-news-digest',
name: 'Daily AI news digest',
prompt:
'Create a workflow that fetches the latest AI news every morning at 8 AM. It should aggregate news from multiple sources, use LLM to summarize the top 5 stories, generate a relevant image using AI, and send everything as a structured Telegram message with article links. I should be able to chat about the news with the LLM so at least 40 last messages should be stored.',
},
{
id: 'rag-assistant',
name: 'RAG knowledge assistant',
prompt:
'Build a pipeline that accepts PDF, CSV, or JSON files through an n8n form. Chunk documents into 1000-token segments, generate embeddings, and store in a vector database. Use the filename as the document key and add metadata including upload date and file type. Include a chatbot that can answer questions based on a knowledge base.',
'Create a multi-agent AI workflow using GPT-4.1-mini where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.',
},
{
id: 'email-summary',
name: 'Summarize emails with AI',
prompt:
'Build a workflow that retrieves the last 50 emails from multiple email accounts. Merge all emails, perform AI analysis to identify action items, priorities, and sentiment. Generate a brief summary and send to Slack with categorized insights and recommended actions.',
'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with GPT-4.1-mini to find action items and priorities, and emails me a structured email using Gmail.',
},
{
id: 'youtube-auto-chapters',
name: 'YouTube video chapters',
id: 'ai-news-digest',
name: 'Daily AI news digest',
prompt:
"I want to build an n8n workflow that automatically creates YouTube chapter timestamps by analyzing the video captions. When I trigger it manually, it should take a video ID as input, fetch the existing video metadata and captions from YouTube, use an AI language model like Google Gemini to parse the transcript into chapters with timestamps, and then update the video's description with these chapters appended. The goal is to save time and improve SEO by automating the whole process.",
'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI GPT-4.1-mini to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.',
},
{
id: 'pizza-delivery',
name: 'Pizza delivery chatbot',
id: 'daily-weather-report',
name: 'Daily weather report',
prompt:
"I need an n8n workflow that creates a chatbot for my pizza delivery service. The bot should be able to answer customer questions about our pizza menu, take their orders accurately by capturing pizza type, quantity, and customer details, and also provide real-time updates when customers ask about their order status. It should use OpenAI's gpt-4.1-mini to handle conversations and integrate with HTTP APIs to get product info and manage orders. The workflow must maintain conversation context so the chatbot feels natural and can process multiple user queries sequentially.",
'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI GPT-4.1-mini to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.',
},
{
id: 'invoice-pipeline',
name: 'Invoice processing pipeline',
prompt:
'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI GPT-4.1-mini to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using GPT-4.1-mini based on stored invoices and send a clean email using Gmail.',
},
{
id: 'rag-assistant',
name: 'RAG knowledge assistant',
prompt:
'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI GPT-4.1-mini model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into GPT-4.1-mini as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.',
},
{
id: 'lead-qualification',
name: 'Lead qualification and call scheduling',
prompt:
'Create a form with fields for email, company, and role. Build an automation that processes form submissions, enrich with company data from their website, uses AI to qualify the lead, sends data to Google Sheets. For high-score leads it should also schedule a 15-min call in a free slot in my calendar and send a confirmation email to both me and the lead.',
'Create an n8n form with a lead generation form I can embed on my website homepage. Build an automation that processes form submissions, uses AI to qualify the lead, sends data to an n8n data table. For high-score leads, it should also email them to offer to schedule a 15-min call in a free slot in my calendar.',
},
{
id: 'multi-agent-research',
name: 'Multi-agent research workflow',
id: 'youtube-auto-chapters',
name: 'YouTube video chapters',
prompt:
'Create a multi-agent AI workflow where different AI agents collaborate to research a topic, fact-check information, and compile comprehensive reports.',
"Build an n8n workflow that automatically generates YouTube chapter timestamps from video captions. Use the n8n chat trigger for me to enter the URL of the YouTube video. Use the YouTube Get a video node to get the video title, description, and existing metadata. Use the YouTube Captions API to download the transcript for the given video ID. Send the transcript to AI agent using Anthropic's Claude model. Prompt the model to identify topic shifts and return structured output in timestamp - chapter format. Append the generated chapter list to the existing video description. Use the YouTube Update a video node to update the video description. Respond back with the updates using the respond to chat node.",
},
{
id: 'google-sheets-processing',

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-workflow-builder",
"version": "0.29.0",
"version": "0.30.0",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

@ -35,4 +35,4 @@ export const MAX_WORKFLOW_LENGTH_TOKENS = 30_000;
* Average character-to-token ratio for Anthropic models.
* Used for rough token count estimation from character counts.
*/
export const AVG_CHARS_PER_TOKEN_ANTHROPIC = 2.5;
export const AVG_CHARS_PER_TOKEN_ANTHROPIC = 3.5;

View File

@ -354,6 +354,111 @@ describe('WorkflowBuilderAgent', () => {
const state = createMockState('Short message', 'Custom Name', 1);
expect(shouldModifyState(state, autoCompactThresholdTokens)).toBe('agent');
});
it('should return "auto_compact_messages" when messages with workflow context exceed threshold', () => {
// Use a smaller threshold for this test to make it easier to exceed
const smallThreshold = 5000; // tokens
// Create a long message that when combined with workflow context will exceed threshold
// With AVG_CHARS_PER_TOKEN_ANTHROPIC = 3.5, we need ~17500 characters to exceed 5000 tokens
const longMessage = 'x'.repeat(10000);
const state = createMockState(longMessage, 'Custom Name', 1, 10);
// Add substantial workflow data that will be included in context
state.workflowJSON = {
nodes: Array.from({ length: 50 }, (_, i) => ({
id: `node-${i}`,
name: `Node ${i}`,
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [i * 100, i * 100] as [number, number],
parameters: { data: 'x'.repeat(200) },
})),
connections: {},
name: 'Test Workflow',
};
expect(shouldModifyState(state, smallThreshold)).toBe('auto_compact_messages');
});
it('should return "agent" when messages with workflow context stay below threshold', () => {
const shortMessage = 'Create a simple workflow';
const state = createMockState(shortMessage, 'Custom Name', 1, 2);
// Small workflow that won't push us over threshold
state.workflowJSON = {
nodes: [
{
id: 'node-1',
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
],
connections: {},
name: 'Simple Workflow',
};
expect(shouldModifyState(state, autoCompactThresholdTokens)).toBe('agent');
});
it('should consider execution data and schema in token estimation', () => {
// Use a smaller threshold for this test
const smallThreshold = 3000; // tokens
const message = 'x'.repeat(3000);
const state = createMockState(message, 'Custom Name', 1, 5);
// Add execution data and schema that contribute to token count
state.workflowContext = {
...state.workflowContext,
executionData: {
runData: {
'node-1': [
{
data: {
main: [[{ json: { data: 'x'.repeat(2000) } }]],
},
executionTime: 100,
startTime: Date.now(),
executionIndex: 0,
source: [],
},
],
},
},
executionSchema: [
{
nodeName: 'node-1',
schema: {
type: 'object',
value: [
{ key: 'field1', type: 'string', value: 'x'.repeat(1000), path: '.field1' },
{ key: 'field2', type: 'string', value: 'x'.repeat(1000), path: '.field2' },
],
path: '',
},
},
],
};
state.workflowJSON = {
nodes: Array.from({ length: 20 }, (_, i) => ({
id: `node-${i}`,
name: `Node ${i}`,
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [i * 100, i * 100] as [number, number],
parameters: { data: 'x'.repeat(100) },
})),
connections: {},
name: 'Complex Workflow',
};
expect(shouldModifyState(state, smallThreshold)).toBe('auto_compact_messages');
});
});
});
});

View File

@ -78,8 +78,14 @@ export function shouldModifyState(
return 'create_workflow_name';
}
const workflowContextToAppend = getWorkflowContext(state);
// Check if we should auto-compact based on token count
const estimatedTokens = estimateTokenCountFromMessages(messages);
const estimatedTokens = estimateTokenCountFromMessages([
...messages,
// appended later to last message
new HumanMessage(workflowContextToAppend),
]);
if (estimatedTokens > autoCompactThresholdTokens) {
return 'auto_compact_messages';
}
@ -87,6 +93,32 @@ export function shouldModifyState(
return 'agent';
}
function getWorkflowContext(state: typeof WorkflowState.State) {
const trimmedWorkflow = trimWorkflowJSON(state.workflowJSON);
const executionData = state.workflowContext?.executionData ?? {};
const executionSchema = state.workflowContext?.executionSchema ?? [];
const workflowContext = [
'',
'<current_workflow_json>',
JSON.stringify(trimmedWorkflow),
'</current_workflow_json>',
'<trimmed_workflow_json_note>',
'Note: Large property values of the nodes in the workflow JSON above may be trimmed to fit within token limits.',
'Use get_node_parameter tool to get full details when needed.',
'</trimmed_workflow_json_note>',
'',
'<current_simplified_execution_data>',
JSON.stringify(executionData),
'</current_simplified_execution_data>',
'',
'<current_execution_nodes_schemas>',
JSON.stringify(executionSchema),
'</current_execution_nodes_schemas>',
].join('\n');
return workflowContext;
}
export interface WorkflowBuilderAgentConfig {
parsedNodeTypes: INodeTypeDescription[];
llmSimpleTask: BaseChatModel;
@ -168,9 +200,6 @@ export class WorkflowBuilderAgent {
}
const hasPreviousSummary = state.previousSummary && state.previousSummary !== 'EMPTY';
const trimmedWorkflow = trimWorkflowJSON(state.workflowJSON);
const executionData = state.workflowContext?.executionData ?? {};
const executionSchema = state.workflowContext?.executionSchema ?? [];
const prompt = await mainAgentPrompt.invoke({
...state,
@ -182,24 +211,7 @@ export class WorkflowBuilderAgent {
previousSummary: hasPreviousSummary ? state.previousSummary : '',
});
const workflowContext = [
'',
'<current_workflow_json>',
JSON.stringify(trimmedWorkflow, null, 2),
'</current_workflow_json>',
'<trimmed_workflow_json_note>',
'Note: Large property values of the nodes in the workflow JSON above may be trimmed to fit within token limits.',
'Use get_node_parameter tool to get full details when needed.',
'</trimmed_workflow_json_note>',
'',
'<current_simplified_execution_data>',
JSON.stringify(executionData, null, 2),
'</current_simplified_execution_data>',
'',
'<current_execution_nodes_schemas>',
JSON.stringify(executionSchema, null, 2),
'</current_execution_nodes_schemas>',
].join('\n');
const workflowContext = getWorkflowContext(state);
// Optimize prompts for Anthropic's caching by:
// 1. Finding all user/tool message positions (cache breakpoints)

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.53.0",
"version": "0.54.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -100,4 +100,9 @@ export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto';
export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto';
export { MoveDataTableColumnDto } from './data-table/move-data-table-column.dto';
export {
OAuthClientResponseDto,
ListOAuthClientsResponseDto,
DeleteOAuthClientResponseDto,
} from './oauth/oauth-client.dto';
export { ProvisioningConfigDto, ProvisioningConfigPatchDto } from './provisioning/config.dto';

View File

@ -0,0 +1 @@
export * from './oauth-client.dto';

View File

@ -0,0 +1,41 @@
import { z } from 'zod';
import { Z } from 'zod-class';
/**
* DTO for OAuth client response (excludes sensitive data like clientSecret)
*/
export class OAuthClientResponseDto extends Z.class({
id: z.string(),
name: z.string(),
redirectUris: z.array(z.string()),
grantTypes: z.array(z.string()),
tokenEndpointAuthMethod: z.string(),
createdAt: z.string().datetime(), // Using string for date serialization over HTTP
updatedAt: z.string().datetime(),
}) {}
/**
* DTO for listing OAuth clients response
*/
export class ListOAuthClientsResponseDto extends Z.class({
data: z.array(
z.object({
id: z.string(),
name: z.string(),
redirectUris: z.array(z.string()),
grantTypes: z.array(z.string()),
tokenEndpointAuthMethod: z.string(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
}),
),
count: z.number(),
}) {}
/**
* DTO for deleting an OAuth client response
*/
export class DeleteOAuthClientResponseDto extends Z.class({
success: z.boolean(),
message: z.string(),
}) {}

View File

@ -46,7 +46,6 @@ export interface IEnterpriseSettings {
showNonProdBanner: boolean;
debugInEditor: boolean;
binaryDataS3: boolean;
workflowHistory: boolean;
workerView: boolean;
advancedPermissions: boolean;
apiKeyScopes: boolean;

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "0.29.0",
"version": "0.30.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -130,10 +130,6 @@ export class LicenseState {
return this.isLicensed('feat:externalSecrets');
}
isWorkflowHistoryLicensed() {
return this.isLicensed('feat:workflowHistory');
}
isAPIDisabled() {
return this.isLicensed('feat:apiDisabled');
}

View File

@ -35,6 +35,7 @@ export class ModuleRegistry {
'data-table',
'provisioning',
'breaking-changes',
'mcp',
];
private readonly activeModules: string[] = [];

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-test-utils",
"version": "0.22.0",
"version": "0.23.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -82,7 +82,12 @@ type EntityName =
| 'DataTable'
| 'DataTableColumn'
| 'ChatHubSession'
| 'ChatHubMessage';
| 'ChatHubMessage'
| 'OAuthClient'
| 'AuthorizationCode'
| 'AccessToken'
| 'RefreshToken'
| 'UserConsent';
/**
* Truncate specific DB tables in a test DB.

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.61.0",
"version": "1.62.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -59,6 +59,14 @@ export class TaskRunnersConfig {
@Env('N8N_RUNNERS_TASK_TIMEOUT')
taskTimeout: number = 300; // 5 minutes
/**
* How long (in seconds) a task request can wait for a runner to become
* available before timing out. This prevents workflows from hanging
* indefinitely when no runners are available. Must be greater than 0.
*/
@Env('N8N_RUNNERS_TASK_REQUEST_TIMEOUT')
taskRequestTimeout: number = 60;
/** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')
heartbeatInterval: number = 30;

View File

@ -2,10 +2,6 @@ import { Config, Env } from '../decorators';
@Config
export class WorkflowHistoryConfig {
/** Whether to save workflow history versions. */
@Env('N8N_WORKFLOW_HISTORY_ENABLED')
enabled: boolean = true;
/** Time (in hours) to keep workflow history versions for. `-1` means forever. */
@Env('N8N_WORKFLOW_HISTORY_PRUNE_TIME')
pruneTime: number = -1;

View File

@ -265,6 +265,7 @@ describe('GlobalConfig', () => {
maxOldSpaceSize: '',
maxConcurrency: 10,
taskTimeout: 300,
taskRequestTimeout: 60,
heartbeatInterval: 30,
insecureMode: false,
isNativePythonRunnerEnabled: false,
@ -361,7 +362,6 @@ describe('GlobalConfig', () => {
disabled: false,
},
workflowHistory: {
enabled: true,
pruneTime: -1,
},
sso: {

View File

@ -17,7 +17,6 @@ export const LICENSE_FEATURES = {
API_DISABLED: 'feat:apiDisabled',
EXTERNAL_SECRETS: 'feat:externalSecrets',
SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner',
WORKFLOW_HISTORY: 'feat:workflowHistory',
DEBUG_IN_EDITOR: 'feat:debugInEditor',
BINARY_DATA_S3: 'feat:binaryDataS3',
MULTIPLE_MAIN_INSTANCES: 'feat:multipleMainInstances',
@ -54,6 +53,7 @@ export const LICENSE_QUOTAS = {
} as const;
export const UNLIMITED_LICENSE_QUOTA = -1;
export const DEFAULT_WORKFLOW_HISTORY_PRUNE_LIMIT = 24;
export type BooleanLicenseFeature = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES];
export type NumericLicenseFeature = (typeof LICENSE_QUOTAS)[keyof typeof LICENSE_QUOTAS];

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "0.30.0",
"version": "0.31.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -217,7 +217,14 @@ export namespace ListQueryDb {
* Slim workflow returned from a list query operation.
*/
export namespace Workflow {
type OptionalBaseFields = 'name' | 'active' | 'versionId' | 'createdAt' | 'updatedAt' | 'tags';
type OptionalBaseFields =
| 'name'
| 'active'
| 'versionId'
| 'createdAt'
| 'updatedAt'
| 'tags'
| 'description';
type BaseFields = Pick<WorkflowEntity, 'id'> &
Partial<Pick<WorkflowEntity, OptionalBaseFields>>;

View File

@ -32,6 +32,9 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl
@Column({ length: 128 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column()
active: boolean;

View File

@ -0,0 +1,108 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateOAuthEntities1760116750277 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
// Create oauth_clients table
await createTable('oauth_clients').withColumns(
column('id').varchar().primary.notNull,
column('name').varchar(255).notNull,
column('redirectUris').json.notNull,
column('grantTypes').json.notNull,
column('clientSecret').varchar(255),
column('clientSecretExpiresAt').bigint,
column('tokenEndpointAuthMethod')
.varchar(255)
.notNull.default("'none'")
.comment('Possible values: none, client_secret_basic or client_secret_post'),
).withTimestamps;
// Create oauth_authorization_codes table
await createTable('oauth_authorization_codes')
.withColumns(
column('code').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('redirectUri').varchar(255).notNull,
column('codeChallenge').varchar(255).notNull,
column('codeChallengeMethod').varchar(255).notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
column('state').varchar(255), // Should be nullable
column('used').bool.notNull.default(false),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// Create oauth_access_tokens table
await createTable('oauth_access_tokens')
.withColumns(
column('token').varchar().primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
});
// Create oauth_refresh_tokens table
await createTable('oauth_refresh_tokens')
.withColumns(
column('token').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// Create oauth_user_consents table
await createTable('oauth_user_consents')
.withColumns(
column('id').int.primary.autoGenerate2.notNull,
column('userId').uuid.notNull,
column('clientId').varchar().notNull,
column('grantedAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
})
.withUniqueConstraintOn(['userId', 'clientId']);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('oauth_user_consents');
await dropTable('oauth_refresh_tokens');
await dropTable('oauth_access_tokens');
await dropTable('oauth_authorization_codes');
await dropTable('oauth_clients');
}
}

View File

@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddWorkflowDescriptionColumn1762177736257 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('workflow_entity', [column('description').text]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('workflow_entity', ['description']);
}
}

View File

@ -0,0 +1,76 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
export class BackfillMissingWorkflowHistoryRecords1762763704614 implements IrreversibleMigration {
/**
* 1. Generate versionIds for workflows with NULL versionId (only possible for manual inserts)
* 2. Create workflow_history records for all workflows missing them
* 3. Make versionId NOT NULL to ensure data consistency
*/
async up({ escape, runQuery, schemaBuilder }: MigrationContext) {
const workflowTable = escape.tableName('workflow_entity');
const historyTable = escape.tableName('workflow_history');
const versionIdColumn = escape.columnName('versionId');
const idColumn = escape.columnName('id');
const workflowIdColumn = escape.columnName('workflowId');
const nodesColumn = escape.columnName('nodes');
const connectionsColumn = escape.columnName('connections');
const authorsColumn = escape.columnName('authors');
const createdAtColumn = escape.columnName('createdAt');
const updatedAtColumn = escape.columnName('updatedAt');
// Step 1: Generate versionIds for workflows that have NULL or empty versionId
const workflowsWithoutVersionId = await runQuery<Array<{ id: string }>>(`
SELECT ${idColumn} as id
FROM ${workflowTable}
WHERE ${versionIdColumn} IS NULL OR ${versionIdColumn} = ''
`);
// Running in a loop to avoid using DB-specific syntax for generating UUIDs
for (const workflow of workflowsWithoutVersionId) {
const versionId = crypto.randomUUID();
await runQuery(
`
UPDATE ${workflowTable}
SET ${versionIdColumn} = :versionId
WHERE ${idColumn} = :id
`,
{ versionId, id: workflow.id },
);
}
// Step 2: Create workflow_history records for workflows missing them
await runQuery(
`
INSERT INTO ${historyTable} (
${versionIdColumn},
${workflowIdColumn},
${authorsColumn},
${nodesColumn},
${connectionsColumn},
${createdAtColumn},
${updatedAtColumn}
)
SELECT
w.${versionIdColumn},
w.${idColumn},
:authors,
w.${nodesColumn},
w.${connectionsColumn},
:createdAt,
:updatedAt
FROM ${workflowTable} w
LEFT JOIN ${historyTable} wh
ON w.${versionIdColumn} = wh.${versionIdColumn}
WHERE wh.${versionIdColumn} IS NULL
`,
{
authors: 'system migration',
createdAt: new Date(),
updatedAt: new Date(),
},
);
// Step 3: Make versionId NOT NULL
await schemaBuilder.addNotNull('workflow_entity', 'versionId');
}
}

View File

@ -1,6 +1,6 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddWorkflowVersionIdToExecutionData1762310956274 implements ReversibleMigration {
export class AddWorkflowVersionIdToExecutionData1762858574621 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('execution_data', [column('workflowVersionId').varchar()]);
}

View File

@ -1,8 +1,3 @@
import { AddWorkflowVersionIdToExecutionData1762310956274 } from './../common/1762310956274-AddWorkflowVersionIdToExecutionData';
import { DropUnusedChatHubColumns1760965142113 } from './1760965142113-DropUnusedChatHubColumns';
import { AddAudienceColumnToApiKeys1758731786132 } from '../common/1758731786132-AddAudienceColumnToApiKey';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { InitialMigration1588157391238 } from './1588157391238-InitialMigration';
import { WebhookModel1592447867632 } from './1592447867632-WebhookModel';
import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt';
@ -49,12 +44,15 @@ import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-M
import { CreateTestMetricTable1732271325258 } from './1732271325258-CreateTestMetricTable';
import { AddStatsColumnsToTestRun1736172058779 } from './1736172058779-AddStatsColumnsToTestRun';
import { FixTestDefinitionPrimaryKey1739873751194 } from './1739873751194-FixTestDefinitionPrimaryKey';
import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn';
import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable';
import { DropUnusedChatHubColumns1760965142113 } from './1760965142113-DropUnusedChatHubColumns';
import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn';
import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson';
import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities';
import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections';
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
import { AddMfaColumns1690000000030 } from '../common/1690000000040-AddMfaColumns';
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable';
import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete';
@ -100,16 +98,21 @@ import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTab
import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable';
import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { LinkRoleToProjectRelationTable1753953244168 } from '../common/1753953244168-LinkRoleToProjectRelationTable';
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes';
import { AddAudienceColumnToApiKeys1758731786132 } from '../common/1758731786132-AddAudienceColumnToApiKey';
import { ChangeValueTypesForInsights1759399811000 } from '../common/1759399811000-ChangeValueTypesForInsights';
import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables';
import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable';
import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames';
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData';
import type { Migration } from '../migration-types';
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -222,5 +225,8 @@ export const mysqlMigrations: Migration[] = [
DropUnusedChatHubColumns1760965142113,
AddWorkflowVersionColumn1761047826451,
ChangeDependencyInfoToJson1761655473000,
AddWorkflowVersionIdToExecutionData1762310956274,
AddWorkflowDescriptionColumn1762177736257,
CreateOAuthEntities1760116750277,
BackfillMissingWorkflowHistoryRecords1762763704614,
AddWorkflowVersionIdToExecutionData1762858574621,
];

View File

@ -1,6 +1,3 @@
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
import { InitialMigration1587669153312 } from './1587669153312-InitialMigration';
import { WebhookModel1589476000887 } from './1589476000887-WebhookModel';
import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt';
@ -51,6 +48,7 @@ import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeD
import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities';
import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections';
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
import { AddMfaColumns1690000000030 } from '../common/1690000000040-AddMfaColumns';
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable';
import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete';
@ -97,6 +95,8 @@ import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTab
import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables';
import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable';
import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { LinkRoleToProjectRelationTable1753953244168 } from '../common/1753953244168-LinkRoleToProjectRelationTable';
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes';
@ -105,9 +105,12 @@ import { ChangeValueTypesForInsights1759399811000 } from '../common/175939981100
import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables';
import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable';
import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames';
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
import { AddWorkflowVersionIdToExecutionData1762310956274 } from '../common/1762310956274-AddWorkflowVersionIdToExecutionData';
import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@ -220,5 +223,8 @@ export const postgresMigrations: Migration[] = [
DropUnusedChatHubColumns1760965142113,
AddWorkflowVersionColumn1761047826451,
ChangeDependencyInfoToJson1761655473000,
AddWorkflowVersionIdToExecutionData1762310956274,
AddWorkflowDescriptionColumn1762177736257,
CreateOAuthEntities1760116750277,
BackfillMissingWorkflowHistoryRecords1762763704614,
AddWorkflowVersionIdToExecutionData1762858574621,
];

View File

@ -1,4 +1,3 @@
import { AddAudienceColumnToApiKeys1758731786132 } from './../common/1758731786132-AddAudienceColumnToApiKey';
import { InitialMigration1588102412422 } from './1588102412422-InitialMigration';
import { WebhookModel1592445003908 } from './1592445003908-WebhookModel';
import { CreateIndexStoppedAt1594825041918 } from './1594825041918-CreateIndexStoppedAt';
@ -44,6 +43,7 @@ import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-M
import { CreateFolderTable1738709609940 } from './1738709609940-CreateFolderTable';
import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn';
import { AddScopesColumnToApiKeys1742918400000 } from './1742918400000-AddScopesColumnToApiKeys';
import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable';
import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn';
import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
@ -93,19 +93,22 @@ import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTab
import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable';
import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { LinkRoleToProjectRelationTable1753953244168 } from '../common/1753953244168-LinkRoleToProjectRelationTable';
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes';
import { AddAudienceColumnToApiKeys1758731786132 } from '../common/1758731786132-AddAudienceColumnToApiKey';
import { ChangeValueTypesForInsights1759399811000 } from '../common/1759399811000-ChangeValueTypesForInsights';
import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames';
import type { Migration } from '../migration-types';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable';
import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables';
import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-CreateChatHubAgentTable';
import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames';
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
import { AddWorkflowVersionIdToExecutionData1762310956274 } from '../common/1762310956274-AddWorkflowVersionIdToExecutionData';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData';
import type { Migration } from '../migration-types';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -214,7 +217,10 @@ const sqliteMigrations: Migration[] = [
DropUnusedChatHubColumns1760965142113,
AddWorkflowVersionColumn1761047826451,
ChangeDependencyInfoToJson1761655473000,
AddWorkflowVersionIdToExecutionData1762310956274,
AddWorkflowDescriptionColumn1762177736257,
CreateOAuthEntities1760116750277,
BackfillMissingWorkflowHistoryRecords1762763704614,
AddWorkflowVersionIdToExecutionData1762858574621,
];
export { sqliteMigrations };

View File

@ -12,9 +12,15 @@ export class UserRepository extends Repository<User> {
super(User, dataSource.manager);
}
async findManyByIds(userIds: string[]) {
async findManyByIds(
userIds: string[],
options?: {
includeRole: boolean;
},
) {
return await this.find({
where: { id: In(userIds) },
relations: options?.includeRole ? ['role'] : undefined,
});
}

View File

@ -1,7 +1,7 @@
import { Service } from '@n8n/di';
import { DataSource, LessThan, Repository } from '@n8n/typeorm';
import { WorkflowHistory } from '../entities';
import { WorkflowHistory, WorkflowEntity } from '../entities';
@Service()
export class WorkflowHistoryRepository extends Repository<WorkflowHistory> {
@ -12,4 +12,24 @@ export class WorkflowHistoryRepository extends Repository<WorkflowHistory> {
async deleteEarlierThan(date: Date) {
return await this.delete({ createdAt: LessThan(date) });
}
/**
* Delete workflow history records earlier than a given date, except for current workflow versions.
*/
async deleteEarlierThanExceptCurrent(date: Date) {
const currentVersionIdsSubquery = this.manager
.createQueryBuilder()
.subQuery()
.select('w.versionId')
.from(WorkflowEntity, 'w')
.getQuery();
return await this.manager
.createQueryBuilder()
.delete()
.from(WorkflowHistory)
.where('createdAt < :date', { date })
.andWhere(`versionId NOT IN (${currentVersionIdsSubquery})`)
.execute();
}
}

View File

@ -147,27 +147,56 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
private buildBaseUnionQuery(workflowIds: string[], options: ListQuery.Options = {}) {
const subQueryParameters: ListQuery.Options = {
// Common fields for both folders and workflows
const commonFields = {
createdAt: true,
updatedAt: true,
id: true,
name: true,
} as const;
// Transform `query` => `name` for folder repository
const folderFilter = options.filter ? { ...options.filter } : undefined;
if (folderFilter?.query) {
folderFilter.name = folderFilter.query;
}
const folderQueryParameters: ListQuery.Options = {
select: commonFields,
filter: folderFilter,
};
const workflowQueryParameters: ListQuery.Options = {
select: {
createdAt: true,
...commonFields,
description: true,
// For some reason the order of updatedAt and createdAt here is load-bearing
// and the generated sql queries below risk switching up the order otherwise
// depending on whether this code is called for a project or the overview
// A proper fix would sort the columnNames here and in the folder and workflow queries
// but that risks breaking other use cases
// https://linear.app/n8n/issue/ADO-4376/tech-debt-investigate-and-fix-root-cause-of-incorrect-sql-column
updatedAt: true,
createdAt: true,
id: true,
name: true,
},
filter: options.filter,
};
const columnNames = [...Object.keys(subQueryParameters.select ?? {}), 'resource'];
// For union, we need to have the same columns, so add NULL as description for folders
const columnNames = [...Object.keys(workflowQueryParameters.select ?? {}), 'resource'];
const [sortByColumn, sortByDirection] = this.parseSortingParams(
options.sortBy ?? 'updatedAt:asc',
);
const foldersQuery = this.folderRepository
.getManyQuery(subQueryParameters)
.getManyQuery(folderQueryParameters)
.addSelect('NULL', 'description') // Add NULL for description in folders
.addSelect("'folder'", 'resource');
const workflowsQuery = this.getManyQuery(workflowIds, subQueryParameters).addSelect(
const workflowsQuery = this.getManyQuery(workflowIds, workflowQueryParameters).addSelect(
"'workflow'",
'resource',
);
@ -290,7 +319,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
typeof options.filter?.parentFolderId === 'string' &&
options.filter.parentFolderId !== PROJECT_ROOT &&
typeof options.filter?.projectId === 'string' &&
options.filter.name
options.filter.query
) {
const folderIds = await this.folderRepository.getAllFolderIdsInHierarchy(
options.filter.parentFolderId,
@ -472,10 +501,14 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
qb: SelectQueryBuilder<WorkflowEntity>,
filter: ListQuery.Options['filter'],
): void {
if (typeof filter?.name === 'string' && filter.name !== '') {
qb.andWhere('LOWER(workflow.name) LIKE :name', {
name: `%${filter.name.toLowerCase()}%`,
});
const searchValue = filter?.query;
if (typeof searchValue === 'string' && searchValue !== '') {
const searchTerm = `%${searchValue.toLowerCase()}%`;
qb.andWhere(
"(LOWER(workflow.name) LIKE :searchTerm OR LOWER(COALESCE(workflow.description, '')) LIKE :searchTerm)",
{ searchTerm },
);
}
}
@ -607,6 +640,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
'workflow.updatedAt',
'workflow.versionId',
'workflow.settings',
'workflow.description',
]);
return;
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "0.29.0",
"version": "0.30.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,9 +1,9 @@
export { Body, Query, Param } from './args';
export { RestController } from './rest-controller';
export { RootLevelController } from './root-level-controller';
export { Get, Post, Put, Patch, Delete } from './route';
export { Get, Post, Put, Patch, Delete, Head, Options } from './route';
export { Middleware } from './middleware';
export { ControllerRegistryMetadata } from './controller-registry-metadata';
export { Licensed } from './licensed';
export { GlobalScope, ProjectScope } from './scoped';
export type { AccessScope, Controller, RateLimit } from './types';
export type { AccessScope, Controller, RateLimit, StaticRouterMetadata } from './types';

View File

@ -42,3 +42,5 @@ export const Post = RouteFactory('post');
export const Put = RouteFactory('put');
export const Patch = RouteFactory('patch');
export const Delete = RouteFactory('delete');
export const Head = RouteFactory('head');
export const Options = RouteFactory('options');

View File

@ -1,9 +1,9 @@
import type { BooleanLicenseFeature } from '@n8n/constants';
import type { Constructable } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { RequestHandler } from 'express';
import type { RequestHandler, Router } from 'express';
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options';
export type Arg = { type: 'body' | 'query' } | { type: 'param'; key: string };
@ -40,8 +40,29 @@ export interface RouteMetadata {
licenseFeature?: BooleanLicenseFeature;
accessScope?: AccessScope;
args: Arg[];
router?: Router;
}
/**
* Metadata for static routers mounted on a controller.
* Picks relevant fields from RouteMetadata and makes router required.
*/
export type StaticRouterMetadata = {
path: string;
router: Router;
} & Partial<
Pick<
RouteMetadata,
| 'skipAuth'
| 'allowSkipPreviewAuth'
| 'allowSkipMFA'
| 'middlewares'
| 'rateLimit'
| 'licenseFeature'
| 'accessScope'
>
>;
export interface ControllerMetadata {
basePath: `/${string}`;
// If true, the controller will be registered on the root path without the any prefix

View File

@ -5,7 +5,7 @@ type UserLike = {
email?: string;
firstName?: string;
lastName?: string;
role: {
role?: {
slug: string;
};
};
@ -24,7 +24,7 @@ function toRedactable(userLike: UserLike) {
_email: userLike.email,
_firstName: userLike.firstName,
_lastName: userLike.lastName,
globalRole: userLike.role.slug,
globalRole: userLike.role?.slug,
};
}

View File

@ -1,4 +1,10 @@
import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime';
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
import { BedrockEmbeddings } from '@langchain/aws';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { getNodeProxyAgent } from '@utils/httpProxyAgent';
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
import {
NodeConnectionTypes,
type INodeType,
@ -7,9 +13,6 @@ import {
type SupplyData,
} from 'n8n-workflow';
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
export class EmbeddingsAwsBedrock implements INodeType {
description: INodeTypeDescription = {
displayName: 'Embeddings AWS Bedrock',
@ -104,18 +107,37 @@ export class EmbeddingsAwsBedrock implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('aws');
const credentials = await this.getCredentials<{
region: string;
secretAccessKey: string;
accessKeyId: string;
sessionToken: string;
}>('aws');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const clientConfig: BedrockRuntimeClientConfig = {
region: credentials.region,
credentials: {
secretAccessKey: credentials.secretAccessKey,
accessKeyId: credentials.accessKeyId,
sessionToken: credentials.sessionToken,
},
};
const proxyAgent = getNodeProxyAgent();
if (proxyAgent) {
clientConfig.requestHandler = new NodeHttpHandler({
httpAgent: proxyAgent,
httpsAgent: proxyAgent,
});
}
const client = new BedrockRuntimeClient(clientConfig);
const embeddings = new BedrockEmbeddings({
region: credentials.region as string,
client,
model: modelName,
maxRetries: 3,
credentials: {
secretAccessKey: credentials.secretAccessKey as string,
accessKeyId: credentials.accessKeyId as string,
sessionToken: credentials.sessionToken as string,
},
region: credentials.region,
});
return {

View File

@ -1,4 +1,9 @@
import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime';
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
import { ChatBedrockConverse } from '@langchain/aws';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { getNodeProxyAgent } from '@utils/httpProxyAgent';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
import {
NodeConnectionTypes,
type INodeType,
@ -7,9 +12,6 @@ import {
type SupplyData,
} from 'n8n-workflow';
import { getProxyAgent } from '@utils/httpProxyAgent';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
import { N8nLlmTracing } from '../N8nLlmTracing';
@ -222,26 +224,45 @@ export class LmChatAwsBedrock implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('aws');
const credentials = await this.getCredentials<{
region: string;
secretAccessKey: string;
accessKeyId: string;
sessionToken: string;
}>('aws');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
temperature: number;
maxTokensToSample: number;
};
// We set-up client manually to pass httpAgent and httpsAgent
const proxyAgent = getNodeProxyAgent();
const clientConfig: BedrockRuntimeClientConfig = {
region: credentials.region,
credentials: {
secretAccessKey: credentials.secretAccessKey,
accessKeyId: credentials.accessKeyId,
...(credentials.sessionToken && { sessionToken: credentials.sessionToken }),
},
};
if (proxyAgent) {
clientConfig.requestHandler = new NodeHttpHandler({
httpAgent: proxyAgent,
httpsAgent: proxyAgent,
});
}
// Pass the pre-configured client to avoid credential resolution proxy issues
const client = new BedrockRuntimeClient(clientConfig);
const model = new ChatBedrockConverse({
region: credentials.region as string,
client,
model: modelName,
region: credentials.region,
temperature: options.temperature,
maxTokens: options.maxTokensToSample,
clientConfig: {
httpAgent: getProxyAgent(),
},
credentials: {
secretAccessKey: credentials.secretAccessKey as string,
accessKeyId: credentials.accessKeyId as string,
sessionToken: credentials.sessionToken as string,
},
callbacks: [new N8nLlmTracing(this)],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
});

View File

@ -1,3 +1,4 @@
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import {
type IDataObject,
type IExecuteFunctions,
@ -394,7 +395,7 @@ export class McpClientTool implements INodeType {
this.logger.debug('McpClientTool: Successfully connected to MCP Server');
if (!mcpTools || !mcpTools.length) {
if (!mcpTools?.length) {
return setError(
'MCP Server returned no tools',
'Connected successfully to your MCP server but it returned an empty list of tools.',
@ -461,7 +462,9 @@ export class McpClientTool implements INodeType {
name: tool.name,
arguments: toolArguments,
};
const result = await client.callTool(params);
const result = await client.callTool(params, CallToolResultSchema, {
timeout: config.timeout,
});
returnData.push({
json: {
response: result.content as IDataObject,

View File

@ -1,8 +1,9 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { mock } from 'jest-mock-extended';
import { McpError, ErrorCode, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { mock, mockDeep } from 'jest-mock-extended';
import {
type IExecuteFunctions,
NodeConnectionTypes,
NodeOperationError,
type ILoadOptionsFunctions,
@ -463,10 +464,14 @@ describe('McpClientTool', () => {
],
]);
expect(Client.prototype.callTool).toHaveBeenCalledWith({
name: 'get_weather',
arguments: { location: 'Berlin' },
});
expect(Client.prototype.callTool).toHaveBeenCalledWith(
{
name: 'get_weather',
arguments: { location: 'Berlin' },
},
expect.anything(),
expect.anything(),
);
});
it('should not execute if tool name does not match', async () => {
@ -667,5 +672,53 @@ describe('McpClientTool', () => {
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.response).toEqual([{ type: 'text', text: 'Weather is sunny' }]);
});
it('should execute tool with timeout', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
content: [{ type: 'text', text: 'Weather is sunny' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'get_weather',
description: 'Gets the weather',
inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
},
],
});
const mockNode = mock<INode>({ typeVersion: 1.2, type: 'mcpClientTool' });
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getInputData.mockReturnValue([
{
json: {
tool: 'get_weather',
location: 'Berlin',
},
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((key, _idx, defaultValue) => {
const params = {
include: 'all',
authentication: 'none',
serverTransport: 'httpStreamable',
endpointUrl: 'https://test.com/mcp',
'options.timeout': 12345,
};
return params[key as keyof typeof params] ?? defaultValue;
});
await new McpClientTool().execute.call(mockExecuteFunctions);
expect(Client.prototype.callTool).toHaveBeenCalledWith(
{
name: 'get_weather',
arguments: { location: 'Berlin' },
},
CallToolResultSchema,
{ timeout: 12345 },
);
});
});
});

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "1.118.0",
"version": "1.119.0",
"description": "",
"main": "index.js",
"scripts": {

View File

@ -1,15 +1,28 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import proxyFromEnv from 'proxy-from-env';
import { ProxyAgent } from 'undici';
/**
* Resolves the proxy URL from environment variables for a given target URL.
*
* @param targetUrl - The target URL to check proxy configuration for (optional)
* @returns The proxy URL string or undefined if no proxy is configured
*
* @remarks
* There are cases where we don't know the target URL in advance (e.g. when we need to provide a proxy agent to ChatAwsBedrock).
* In such case we use a dummy URL.
* This will lead to `NO_PROXY` environment variable not being respected, but it is better than not having a proxy agent at all.
*/
function getProxyUrlFromEnv(targetUrl?: string): string {
return proxyFromEnv.getProxyForUrl(targetUrl ?? 'https://example.nonexistent/');
}
/**
* Returns a ProxyAgent or undefined based on the environment variables and target URL.
* When target URL is not provided, NO_PROXY environment variable is not respected.
*/
export function getProxyAgent(targetUrl?: string) {
// There are cases where we don't know the target URL in advance (e.g. when we need to provide a proxy agent to ChatAwsBedrock)
// In such case we use a dummy URL.
// This will lead to `NO_PROXY` environment variable not being respected, but it is better than not having a proxy agent at all.
const proxyUrl = proxyFromEnv.getProxyForUrl(targetUrl ?? 'https://example.nonexistent/');
const proxyUrl = getProxyUrlFromEnv(targetUrl);
if (!proxyUrl) {
return undefined;
@ -17,3 +30,20 @@ export function getProxyAgent(targetUrl?: string) {
return new ProxyAgent(proxyUrl);
}
/**
* Returns a Node.js HTTP/HTTPS proxy agent for use with AWS SDK v3 clients.
* AWS SDK v3 requires Node.js http.Agent/https.Agent instances (not undici ProxyAgent).
*
* @param targetUrl - The target URL to check proxy configuration for
* @returns HttpsProxyAgent instance or undefined if no proxy is configured
*/
export function getNodeProxyAgent(targetUrl?: string) {
const proxyUrl = getProxyUrlFromEnv(targetUrl);
if (!proxyUrl) {
return undefined;
}
return new HttpsProxyAgent(proxyUrl);
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/permissions",
"version": "0.41.0",
"version": "0.42.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -32,7 +32,7 @@ export const RESOURCES = {
execution: ['delete', 'read', 'retry', 'list', 'get'] as const,
workflowTags: ['update', 'list'] as const,
role: ['manage'] as const,
mcp: ['manage'] as const,
mcp: ['manage', 'oauth'] as const,
mcpApiKey: ['create', 'rotate'] as const,
chatHub: ['manage', 'message'] as const,
chatHubAgent: [...DEFAULT_OPERATIONS] as const,

View File

@ -100,6 +100,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'dataTable:writeRow',
'role:manage',
'mcp:manage',
'mcp:oauth',
'mcpApiKey:create',
'mcpApiKey:rotate',
'chatHub:manage',
@ -130,6 +131,7 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [
'variable:list',
'variable:read',
'dataTable:list',
'mcp:oauth',
'mcpApiKey:create',
'mcpApiKey:rotate',
'chatHub:message',

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/task-runner",
"version": "1.55.0",
"version": "1.56.0",
"scripts": {
"clean": "rimraf dist .turbo",
"start": "node dist/start.js",

View File

@ -87,6 +87,12 @@ export namespace BrokerMessage {
error: unknown;
}
export interface RequestExpired {
type: 'broker:requestexpired';
requestId: string;
reason: 'timeout';
}
export interface TaskDataRequest {
type: 'broker:taskdatarequest';
taskId: string;
@ -109,7 +115,14 @@ export namespace BrokerMessage {
params: unknown[];
}
export type All = TaskReady | TaskDone | TaskError | TaskDataRequest | NodeTypesRequest | RPC;
export type All =
| TaskReady
| TaskDone
| TaskError
| RequestExpired
| TaskDataRequest
| NodeTypesRequest
| RPC;
}
}

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "1.119.0",
"version": "1.120.0",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",
@ -20,17 +20,17 @@
"start": "run-script-os",
"start:default": "cd bin && ./n8n",
"start:windows": "cd bin && n8n",
"test": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest",
"test:unit": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.unit.js",
"test:integration": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.integration.js",
"test:dev": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --watch",
"test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage",
"test": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest",
"test:unit": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.unit.js",
"test:integration": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js",
"test:dev": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --watch",
"test:sqlite": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage",
"test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage",
"test:mariadb": "N8N_LOG_LEVEL=silent DB_TYPE=mariadb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage",
"test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage --maxWorkers=1",
"test:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest",
"test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest --watch",
"test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest --config=jest.config.integration.js",
"test:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest",
"test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --watch",
"test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --config=jest.config.integration.js",
"test:postgres:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=postgresdb&& set DB_POSTGRESDB_SCHEMA=alt_schema&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage",
"test:mariadb:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mariadb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage",
"test:mysql:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mysqldb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage",

View File

@ -86,6 +86,7 @@ export class AuthService {
// Skip browser ID check for type files
'/types/nodes.json',
'/types/credentials.json',
'/mcp-oauth/authorize/',
];
}

View File

@ -35,7 +35,7 @@ import { CommunityPackagesConfig } from '@/modules/community-packages/community-
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { ShutdownService } from '@/shutdown/shutdown.service';
import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee';
import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager';
export abstract class BaseCommand<F = never> {
readonly flags: F;

View File

@ -86,6 +86,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
'googleOAuth2Api',
'microsoftOAuth2Api',
'highLevelOAuth2Api',
'mcpOAuth2Api',
];
export const ARTIFICIAL_TASK_DATA = {

View File

@ -4,7 +4,7 @@ import { GlobalConfig } from '@n8n/config';
import { type BooleanLicenseFeature } from '@n8n/constants';
import type { AuthenticatedRequest } from '@n8n/db';
import { ControllerRegistryMetadata } from '@n8n/decorators';
import type { AccessScope, Controller, RateLimit } from '@n8n/decorators';
import type { AccessScope, Controller, RateLimit, StaticRouterMetadata } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { Router } from 'express';
import type { Application, Request, Response, RequestHandler } from 'express';
@ -19,7 +19,7 @@ import { AuthService } from '@/auth/auth.service';
import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error';
import { License } from '@/license';
import { userHasScopes } from '@/permissions.ee/check-access';
import { send } from '@/response-helper'; // TODO: move `ResponseHelper.send` to this file
import { send } from '@/response-helper';
@Service()
export class ControllerRegistry {
@ -52,7 +52,23 @@ export class ControllerRegistry {
(handlerName) => controller[handlerName].bind(controller) as RequestHandler,
);
const staticRouters = (controllerClass as any).routers as StaticRouterMetadata[] | undefined;
if (staticRouters) {
for (const routerConfig of staticRouters) {
if (!routerConfig.router) {
throw new UnexpectedError(
`Router is undefined for path "${routerConfig.path}" in controller "${controllerClass.name}"`,
);
}
const middlewares = this.buildMiddlewares(routerConfig, controllerMiddlewares);
router.use(routerConfig.path, ...middlewares, routerConfig.router);
}
}
// Register regular routes
for (const [handlerName, route] of metadata.routes) {
// Original handler logic for non-router routes
const argTypes = Reflect.getMetadata(
'design:paramtypes',
controller,
@ -63,7 +79,7 @@ export class ControllerRegistry {
const args: unknown[] = [req, res];
for (let index = 0; index < route.args.length; index++) {
const arg = route.args[index];
if (!arg) continue; // Skip args without any decorators
if (!arg) continue;
if (arg.type === 'param') args.push(req.params[arg.key]);
else if (['body', 'query'].includes(arg.type)) {
const paramType = argTypes[index] as ZodClass;
@ -79,34 +95,64 @@ export class ControllerRegistry {
return await controller[handlerName](...args);
};
router[route.method](
route.path,
...(inProduction && route.rateLimit
? [this.createRateLimitMiddleware(route.rateLimit)]
: []),
const middlewares = this.buildMiddlewares(route, controllerMiddlewares);
const finalHandler = route.usesTemplates
? async (req: Request, res: Response) => {
await handler(req, res);
}
: send(handler);
...(route.skipAuth
? []
: ([
this.authService.createAuthMiddleware({
allowSkipMFA: route.allowSkipMFA,
allowSkipPreviewAuth: route.allowSkipPreviewAuth,
}),
this.lastActiveAtService.middleware.bind(this.lastActiveAtService),
] as RequestHandler[])),
...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []),
...(route.accessScope ? [this.createScopedMiddleware(route.accessScope)] : []),
...controllerMiddlewares,
...route.middlewares,
route.usesTemplates
? async (req, res) => {
// When using templates, intentionally drop the return value,
// since template rendering writes directly to the response.
await handler(req, res);
}
: send(handler),
router[route.method](route.path, ...middlewares, finalHandler);
}
}
/**
* Builds middleware array based on route configuration.
* Used for both static routers and inline router definitions.
*/
private buildMiddlewares(
route: {
skipAuth?: boolean;
allowSkipMFA?: boolean;
allowSkipPreviewAuth?: boolean;
rateLimit?: boolean | RateLimit;
licenseFeature?: BooleanLicenseFeature;
accessScope?: AccessScope;
middlewares?: RequestHandler[];
},
controllerMiddlewares: RequestHandler[],
): RequestHandler[] {
const middlewares: RequestHandler[] = [];
if (inProduction && route.rateLimit) {
middlewares.push(this.createRateLimitMiddleware(route.rateLimit));
}
if (!route.skipAuth) {
middlewares.push(
this.authService.createAuthMiddleware({
allowSkipMFA: route.allowSkipMFA ?? false,
allowSkipPreviewAuth: route.allowSkipPreviewAuth ?? false,
}),
this.lastActiveAtService.middleware.bind(this.lastActiveAtService) as RequestHandler,
);
}
if (route.licenseFeature) {
middlewares.push(this.createLicenseMiddleware(route.licenseFeature));
}
if (route.accessScope) {
middlewares.push(this.createScopedMiddleware(route.accessScope));
}
middlewares.push(...controllerMiddlewares);
if (route.middlewares) {
middlewares.push(...route.middlewares);
}
return middlewares;
}
private createRateLimitMiddleware(rateLimit: true | RateLimit): RequestHandler {

View File

@ -150,7 +150,9 @@ export class AuthController {
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
const users = await this.userRepository.findManyByIds([inviterId, inviteeId]);
const users = await this.userRepository.findManyByIds([inviterId, inviteeId], {
includeRole: true,
});
if (users.length !== 2) {
this.logger.debug(

View File

@ -95,7 +95,6 @@ export class E2EController {
[LICENSE_FEATURES.API_DISABLED]: false,
[LICENSE_FEATURES.EXTERNAL_SECRETS]: false,
[LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false,
[LICENSE_FEATURES.WORKFLOW_HISTORY]: false,
[LICENSE_FEATURES.DEBUG_IN_EDITOR]: false,
[LICENSE_FEATURES.BINARY_DATA_S3]: false,
[LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false,

View File

@ -16,7 +16,7 @@ export type UserLike = {
email?: string;
firstName?: string;
lastName?: string;
role: {
role?: {
slug: string;
};
};

View File

@ -2,6 +2,7 @@ import type { LicenseProvider } from '@n8n/backend-common';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import {
DEFAULT_WORKFLOW_HISTORY_PRUNE_LIMIT,
LICENSE_FEATURES,
LICENSE_QUOTAS,
Time,
@ -300,11 +301,6 @@ export class License implements LicenseProvider {
return this.isLicensed(LICENSE_FEATURES.EXTERNAL_SECRETS);
}
/** @deprecated Use `LicenseState.isWorkflowHistoryLicensed` instead. */
isWorkflowHistoryLicensed() {
return this.isLicensed(LICENSE_FEATURES.WORKFLOW_HISTORY);
}
/** @deprecated Use `LicenseState.isAPIDisabled` instead. */
isAPIDisabled() {
return this.isLicensed(LICENSE_FEATURES.API_DISABLED);
@ -403,7 +399,10 @@ export class License implements LicenseProvider {
/** @deprecated Use `LicenseState` instead. */
getWorkflowHistoryPruneLimit() {
return this.getValue(LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
return (
this.getValue(LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT) ??
DEFAULT_WORKFLOW_HISTORY_PRUNE_LIMIT
);
}
/** @deprecated Use `LicenseState` instead. */

View File

@ -38,20 +38,20 @@ describe('List query middleware', () => {
});
test('should parse valid filter', async () => {
mockReq.query = { filter: '{ "name": "My Workflow" }' };
mockReq.query = { filter: '{ "query": "My Workflow" }' };
await filterListQueryMiddleware(...args);
expect(mockReq.listQueryOptions).toEqual({ filter: { name: 'My Workflow' } });
expect(mockReq.listQueryOptions).toEqual({ filter: { query: 'My Workflow' } });
expect(nextFn).toBeCalledTimes(1);
});
test('should ignore invalid filter', async () => {
mockReq.query = { filter: '{ "name": "My Workflow", "foo": "bar" }' };
mockReq.query = { filter: '{ "query": "My Workflow", "foo": "bar" }' };
await filterListQueryMiddleware(...args);
expect(mockReq.listQueryOptions).toEqual({ filter: { name: 'My Workflow' } });
expect(mockReq.listQueryOptions).toEqual({ filter: { query: 'My Workflow' } });
expect(nextFn).toBeCalledTimes(1);
});
@ -64,7 +64,7 @@ describe('List query middleware', () => {
});
test('should throw on valid filter with invalid type', async () => {
mockReq.query = { filter: '{ "name" : 123 }' };
mockReq.query = { filter: '{ "query" : 123 }' };
await filterListQueryMiddleware(...args);
@ -257,27 +257,27 @@ describe('List query middleware', () => {
describe('Combinations', () => {
test('should combine filter with select', async () => {
mockReq.query = { filter: '{ "name": "My Workflow" }', select: '["name", "id"]' };
mockReq.query = { filter: '{ "query": "My Workflow" }', select: '["name", "id"]' };
await filterListQueryMiddleware(...args);
await selectListQueryMiddleware(...args);
expect(mockReq.listQueryOptions).toEqual({
select: { name: true, id: true },
filter: { name: 'My Workflow' },
filter: { query: 'My Workflow' },
});
expect(nextFn).toBeCalledTimes(2);
});
test('should combine filter with pagination options', async () => {
mockReq.query = { filter: '{ "name": "My Workflow" }', skip: '1', take: '2' };
mockReq.query = { filter: '{ "query": "My Workflow" }', skip: '1', take: '2' };
await filterListQueryMiddleware(...args);
await paginationListQueryMiddleware(...args);
expect(mockReq.listQueryOptions).toEqual({
filter: { name: 'My Workflow' },
filter: { query: 'My Workflow' },
skip: 1,
take: 2,
});

View File

@ -7,7 +7,7 @@ export class WorkflowFilter extends BaseFilter {
@IsString()
@IsOptional()
@Expose()
name?: string;
query?: string;
@IsBoolean()
@IsOptional()

View File

@ -14,6 +14,7 @@ export class WorkflowSelect extends BaseSelect {
'parentFolder',
'nodes',
'isArchived',
'description',
]);
}

View File

@ -28,7 +28,7 @@ afterAll(async () => {
describe('InsightsController', () => {
const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository);
let controller: InsightsController;
const sixDaysAgo = DateTime.now().minus({ days: 6 }).toJSDate();
const sevenDaysAgo = DateTime.now().minus({ days: 7 }).toJSDate();
const today = DateTime.now().toJSDate();
const licenseState = mock<LicenseState>();
@ -62,7 +62,7 @@ describe('InsightsController', () => {
const callArgs =
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual({
@ -102,7 +102,7 @@ describe('InsightsController', () => {
const callArgs =
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual({
@ -146,7 +146,7 @@ describe('InsightsController', () => {
const callArgs =
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual({
@ -399,7 +399,7 @@ describe('InsightsController', () => {
});
const callArgs = insightsByPeriodRepository.getInsightsByWorkflow.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual({ count: 0, data: [] });
@ -433,7 +433,7 @@ describe('InsightsController', () => {
});
const callArgs = insightsByPeriodRepository.getInsightsByWorkflow.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual({ count: 3, data: mockRows });
@ -671,7 +671,7 @@ describe('InsightsController', () => {
});
const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual([]);
@ -694,7 +694,7 @@ describe('InsightsController', () => {
});
const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual(expectedResponse);
@ -740,7 +740,7 @@ describe('InsightsController', () => {
});
const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual(expectedResponse);
@ -887,7 +887,7 @@ describe('InsightsController', () => {
});
const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual(expectedResponse);
@ -912,7 +912,7 @@ describe('InsightsController', () => {
});
const callArgs = insightsByPeriodRepository.getInsightsByTime.mock.calls[0][0];
expectDatesClose(callArgs.startDate, sixDaysAgo);
expectDatesClose(callArgs.startDate, sevenDaysAgo);
expectDatesClose(callArgs.endDate, today);
expect(response).toEqual(expectedResponse);

View File

@ -375,7 +375,7 @@ describe('InsightsService (Integration)', () => {
type: 'success',
value: 1,
periodUnit: 'hour',
periodStart: now.minus({ days: 1 }),
periodStart: now.minus({ days: 2 }),
});
// Out of date range insight (should not be included)
@ -553,7 +553,7 @@ describe('InsightsService (Integration)', () => {
type: 'success',
value: 1,
periodUnit: 'hour',
periodStart: now.minus({ days: 14 }).startOf('day'),
periodStart: now.minus({ days: 14 }).endOf('day'),
});
// Out of date range insight (should not be included)
@ -714,7 +714,7 @@ describe('InsightsService (Integration)', () => {
type: workflow === workflow1 ? 'success' : 'failure',
value: 1,
periodUnit: 'hour',
periodStart: now.minus({ days: 14 }).startOf('day'),
periodStart: now.minus({ days: 14 }).endOf('day'),
});
// Out of date range insight (should not be included)
@ -872,7 +872,7 @@ describe('InsightsService (Integration)', () => {
type: workflow === workflow1 ? 'success' : 'failure',
value: 1,
periodUnit: 'hour',
periodStart: now.minus({ days: 14 }).startOf('day'),
periodStart: now.minus({ days: 14 }).endOf('day'),
});
// Out of date range insight (should not be included)

View File

@ -1,76 +1,10 @@
import type { DatabaseConfig } from '@n8n/config';
import { DateTime } from 'luxon';
import { getDateRangesCommonTableExpressionQuery } from '../insights-by-period-query.helper';
function expectLastXDaysDateRangeQuery(params: {
result: string;
dbType: DatabaseConfig['type'];
prevStartDateOffset: number;
startDateOffset: number;
}) {
const { result, dbType, prevStartDateOffset: prev, startDateOffset: start } = params;
if (dbType === 'sqlite') {
expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`);
expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`);
expect(result).toContain("datetime('now') AS end_date");
} else if (dbType === 'postgresdb') {
expect(result).toContain(
`DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`,
);
expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`);
expect(result).toContain('NOW() AS end_date');
} else {
expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`);
expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`);
expect(result).toContain('NOW() AS end_date');
}
}
function expectStartOfDayDateRangeQuery(params: {
result: string;
dbType: DatabaseConfig['type'];
prevStartDateOffset: number;
startDateOffset: number;
endDateOffset: number;
}) {
const {
result,
dbType,
prevStartDateOffset: prev,
startDateOffset: start,
endDateOffset: end,
} = params;
if (dbType === 'sqlite') {
expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`);
expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`);
if (end !== 0) {
expect(result).toContain(`datetime('now', '-${end} days', 'start of day') AS end_date`);
} else {
expect(result).toContain("datetime('now', 'start of day') AS end_date");
}
} else if (dbType === 'postgresdb') {
expect(result).toContain(
`DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`,
);
expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`);
if (end !== 0) {
expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${end} days') AS end_date`);
} else {
expect(result).toContain("DATE_TRUNC('day', NOW()) AS end_date");
}
} else {
expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`);
expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`);
if (end !== 0) {
expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${end} DAY)) AS end_date`);
} else {
expect(result).toContain('DATE(NOW()) AS end_date');
}
}
}
import {
getDateRangesCommonTableExpressionQuery,
getDateRangesSelectQuery,
} from '../insights-by-period-query.helper';
describe('getDateRangesCommonTableExpressionQuery', () => {
const now = DateTime.utc(2025, 10, 8, 8, 51, 27);
@ -92,195 +26,273 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
])('%s', (dbType: DatabaseConfig['type']) => {
describe('hour periodicity (1 day - startDate == endDate)', () => {
test('last 24 hours (endDate is today)', () => {
const startDate = now.startOf('day').toJSDate();
const startDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
});
if (dbType === 'sqlite') {
expect(result).toContain("datetime('now', '-2 days')"); // prev_start_date
expect(result).toContain("datetime('now', '-1 days')"); // start_date
expect(result).toContain("datetime('now')"); // end_date
} else if (dbType === 'postgresdb') {
expect(result).toContain("NOW() - INTERVAL '2 days'"); // prev_start_date
expect(result).toContain("NOW() - INTERVAL '1 days'"); // start_date
expect(result).toContain('NOW()'); // end_date
} else {
expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY)'); // prev_start_date
expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY)'); // start_date
expect(result).toContain('NOW()'); // end_date
}
// endDate is today but different day from startDate, so dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 2 }).startOf('day'),
startDateTime: now.minus({ days: 1 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('yesterday (specific day)', () => {
const startDate = now.minus({ days: 1 }).startOf('day').toJSDate();
test('day before yesterday (specific day)', () => {
const startDate = now.minus({ days: 2 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 2,
startDateOffset: 1,
endDateOffset: 0, // the end of the range is the start of the next day
});
// Past range: end+1 day startOf('day')
// Duration = 2 days, so prev starts 2 days before start
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 4 }).startOf('day'),
startDateTime: now.minus({ days: 2 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('7 days ago (specific day)', () => {
const startDate = now.minus({ days: 7 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 7 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 6 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 8,
startDateOffset: 7,
endDateOffset: 6, // the end of the range is the start of the next day
});
// Past range: end+1 day startOf('day')
// Duration = 2 days, so prev starts 2 days before start
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 9 }).startOf('day'),
startDateTime: now.minus({ days: 7 }).startOf('day'),
endDateTime: now.minus({ days: 5 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('14 days ago (specific day)', () => {
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 13 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 15,
startDateOffset: 14,
endDateOffset: 13, // the end of the range is the start of the next day
});
// Past range: end+1 day startOf('day')
// Duration = 2 days, so prev starts 2 days before start
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 16 }).startOf('day'),
startDateTime: now.minus({ days: 14 }).startOf('day'),
endDateTime: now.minus({ days: 12 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('X days ago (specific day far in the past)', () => {
// 109 days ago (2025-06-21)
const startDate = now.minus({ days: 109 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 109 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 108 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 110,
startDateOffset: 109,
endDateOffset: 108, // the end of the range is the start of the next day
});
// Past range: end+1 day startOf('day')
// Duration = 2 days, so prev starts 2 days before start
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 111 }).startOf('day'),
startDateTime: now.minus({ days: 109 }).startOf('day'),
endDateTime: now.minus({ days: 107 }).startOf('day'),
});
expect(result).toBe(expected);
});
});
describe('day periodicity (2-30 days)', () => {
describe('last X days (endDate is today)', () => {
test('last 7 days', () => {
const startDate = now.minus({ days: 6 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 7 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 13, // 6 + 7
startDateOffset: 6, // today - 6 days ago = 7 days range
});
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 14 }).startOf('day'),
startDateTime: now.minus({ days: 7 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('last 14 days', () => {
const startDate = now.minus({ days: 13 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 27, // 13 + 14
startDateOffset: 13, // today - 13 days ago = 14 days range
});
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 28 }).startOf('day'),
startDateTime: now.minus({ days: 14 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('last 30 days', () => {
const startDate = now.minus({ days: 29 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 30 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 59, // 29 + 30
startDateOffset: 29, // today - 29 days ago = 30 days range
});
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 60 }).startOf('day'),
startDateTime: now.minus({ days: 30 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
});
describe('specific historical range', () => {
test('2 day range', () => {
const startDate = now.minus({ days: 2 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 3 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 4, // 2 + 2
startDateOffset: 2,
endDateOffset: 0, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 3 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 6 }).startOf('day'),
startDateTime: now.minus({ days: 3 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('5 days range', () => {
const startDate = now.minus({ days: 10 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 6 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 5 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 15, // 10 + 5
startDateOffset: 10,
endDateOffset: 5, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 6 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 16 }).startOf('day'),
startDateTime: now.minus({ days: 10 }).startOf('day'),
endDateTime: now.minus({ days: 4 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('7 days range', () => {
const startDate = now.minus({ days: 12 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 6 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 7 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 19, // 12 + 7
startDateOffset: 12,
endDateOffset: 5, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 8 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 22 }).startOf('day'),
startDateTime: now.minus({ days: 14 }).startOf('day'),
endDateTime: now.minus({ days: 6 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('14 days range', () => {
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 15 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 28, // 14 + 14
startDateOffset: 14,
endDateOffset: 0, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 15 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 30 }).startOf('day'),
startDateTime: now.minus({ days: 15 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('30 days range', () => {
const startDate = now.minus({ days: 52 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 53 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 23 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 82, // 52 + 30
startDateOffset: 52,
endDateOffset: 22, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 31 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 84 }).startOf('day'),
startDateTime: now.minus({ days: 53 }).startOf('day'),
endDateTime: now.minus({ days: 22 }).startOf('day'),
});
expect(result).toBe(expected);
});
});
});
@ -288,137 +300,232 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
describe('week periodicity (31+ days)', () => {
describe('last X days (endDate is today)', () => {
test('last 90 days', () => {
const startDate = now.minus({ days: 89 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 90 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 179, // 89 + 90
startDateOffset: 89,
});
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 180 }).startOf('day'),
startDateTime: now.minus({ days: 90 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('last 6 months', () => {
const startDate = now.minus({ months: 6 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days);
const prevDaysBack = daysBack * 2 + 1;
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: prevDaysBack,
startDateOffset: daysBack,
});
const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day');
const endDateTime = now.startOf('day');
const duration = endDateTime.diff(startDateTime);
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
test('last year', () => {
const startDate = now.minus({ years: 1 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days);
const prevDaysBack = daysBack * 2 + 1;
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: prevDaysBack,
startDateOffset: daysBack,
});
const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day');
const endDateTime = now.startOf('day');
const duration = endDateTime.diff(startDateTime);
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
});
describe('specific historical range', () => {
test('31 days range (specific historical range)', () => {
const startDate = now.minus({ days: 31 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 32 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 62, // 31 + 31
startDateOffset: 31,
endDateOffset: 0, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 32 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 64 }).startOf('day'),
startDateTime: now.minus({ days: 32 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('90 days range (specific historical range)', () => {
const startDate = now.minus({ days: 97 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 98 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 8 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 187, // 97 + 90
startDateOffset: 97,
endDateOffset: 7, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 91 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 189 }).startOf('day'),
startDateTime: now.minus({ days: 98 }).startOf('day'),
endDateTime: now.minus({ days: 7 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('180 days range (specific historical range)', () => {
const startDate = now.minus({ days: 180 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 181 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 360, // 180 + 180
startDateOffset: 180,
endDateOffset: 0, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 181 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 362 }).startOf('day'),
startDateTime: now.minus({ days: 181 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('360 days range (specific historical range)', () => {
const startDate = now.minus({ days: 360 }).startOf('day').toJSDate();
const startDate = now.minus({ days: 361 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 720, // 360 + 360
startDateOffset: 360,
endDateOffset: 0, // the end of the range is the start of the next day
});
// Past range: end+1 day, duration = 361 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 722 }).startOf('day'),
startDateTime: now.minus({ days: 361 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
});
});
describe('edge cases', () => {
test('handles date with time component correctly', () => {
// Date with time should be treated as start of day
// Oct 6 14:30 to Oct 7 18:45
// Now is Oct 8 8:51:27, so Oct 7 18:45 is in the past
const startDate = DateTime.utc(2025, 10, 6, 14, 30, 0).toJSDate();
const endDate = DateTime.utc(2025, 10, 7, 18, 45, 30).toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
// 2-day range
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 4, // 2 + 2
startDateOffset: 2,
endDateOffset: 0, // the end of the range is the start of the next day
});
// Past range, take full days
const startDateTime = DateTime.utc(2025, 10, 6, 14, 30, 0).startOf('day');
const endDateTime = DateTime.utc(2025, 10, 7, 18, 45, 30).plus({ days: 1 }).startOf('day');
const duration = endDateTime.diff(startDateTime);
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
test('handles same day with different times correctly (hour periodicity)', () => {
// Oct 7 9:00 to Oct 7 17:00 (same day)
// Now is Oct 8 8:51:27
const startDate = DateTime.utc(2025, 10, 7, 9, 0, 0).toJSDate();
const endDate = DateTime.utc(2025, 10, 7, 17, 0, 0).toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 2, // 1 + 1
startDateOffset: 1,
endDateOffset: 0, // the end of the range is the start of the next day
});
// Past range, take full days
const startDateTime = DateTime.utc(2025, 10, 7, 9, 0, 0).startOf('day');
const endDateTime = DateTime.utc(2025, 10, 7, 17, 0, 0).plus({ days: 1 }).startOf('day');
const duration = endDateTime.diff(startDateTime);
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
test('handle current day as both start and end date', () => {
const startDate = now.toJSDate();
const endDate = now.toJSDate();
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
});
// startDate and endDate are today, so start is startOf('day'), end is now
const startDateTime = now.startOf('day');
const endDateTime = now;
const duration = endDateTime.diff(startDateTime);
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
});
});

View File

@ -2,132 +2,83 @@ import type { DatabaseConfig } from '@n8n/config';
import { sql } from '@n8n/db';
import { DateTime } from 'luxon';
/**
* Generates database-specific SQL for a datetime value relative to now
* @param dbType - The database type
* @param daysFromToday - Number of days back from today (0 = now)
* @param useStartOfDay - Whether to truncate to start of day (00:00:00)
*/
const getDatetimeSql = ({
dbType,
daysFromToday,
useStartOfDay = false,
}: {
dbType: DatabaseConfig['type'];
daysFromToday: number;
useStartOfDay?: boolean;
}): string => {
// Handle "now" case
if (daysFromToday === 0 && !useStartOfDay) {
return dbType === 'sqlite' ? "datetime('now')" : 'NOW()';
}
// SQLite
if (dbType === 'sqlite') {
if (daysFromToday === 0 && useStartOfDay) {
return "datetime('now', 'start of day')";
}
if (useStartOfDay) {
return `datetime('now', '-${daysFromToday} days', 'start of day')`;
}
return `datetime('now', '-${daysFromToday} days')`;
}
// PostgreSQL
if (dbType === 'postgresdb') {
if (daysFromToday === 0 && useStartOfDay) {
return "DATE_TRUNC('day', NOW())";
}
if (useStartOfDay) {
return `DATE_TRUNC('day', NOW() - INTERVAL '${daysFromToday} days')`;
}
return `NOW() - INTERVAL '${daysFromToday} days'`;
}
// MySQL/MariaDB
if (daysFromToday === 0 && useStartOfDay) {
return 'DATE(NOW())';
}
if (useStartOfDay) {
return `DATE(DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY))`;
}
return `DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY)`;
};
/**
* Generates a SQL Common Table Expression (CTE) query that provides three date boundaries for insights queries
*
* Behavior:
* - If startDate and endDate are the same and today
* - returns the last 24 hours: prev_start_date (2 days ago), start_date (1 day ago), end_date (now).
* - Otherwise:
* - prev_start_date: start of the day before the range
* - start_date: start of the current range
* - end_date: "now" if endDate is today, else start of the day after endDate
* - If the end date is today and the start date is also today, start date is set to the start of the day to take today's data.
* - If the end date is in the past, both start and end dates are set to the start of their respective days, to take full days.
*
* The SQL CTE can be joined with the insights table for filtering/aggregation.
*
* @param dbType - The database type ('sqlite', 'postgresdb', 'mysqldb', 'mariadb')
* @param startDate - The start date of the range (inclusive)
* @param endDate - The end date of the range (inclusive, or "now" if today)
* @param dbType - The database type (postgresdb, mysqldb, mariadb, or sqlite)
* @returns SQL CTE query with `prev_start_date`, `start_date`, and `end_date` columns
* - `prev_start_date`: The start of the previous period (used for comparison)
* - `start_date`: The start of the current period (inclusive)
* - `end_date`: The end of the current period (exclusive)
*/
export const getDateRangesCommonTableExpressionQuery = ({
dbType,
startDate,
endDate,
dbType,
}: {
dbType: DatabaseConfig['type'];
startDate: Date;
endDate: Date;
dbType: DatabaseConfig['type'];
}) => {
const today = DateTime.now().startOf('day');
const startDateStartOfDay = DateTime.fromJSDate(startDate).startOf('day');
const endDateStartOfDay = DateTime.fromJSDate(endDate).startOf('day');
let startDateTime = DateTime.fromJSDate(startDate).toUTC();
let endDateTime = DateTime.fromJSDate(endDate).toUTC();
const daysFromEndDateToToday = Math.floor(today.diff(endDateStartOfDay, 'days').days);
const daysDiff = Math.floor(endDateStartOfDay.diff(startDateStartOfDay, 'days').days);
const today = DateTime.now().toUTC();
const isEndDateToday = endDateTime.hasSame(today, 'day');
const isEndDateToday = daysFromEndDateToToday === 0;
// Past range, take full days
if (!isEndDateToday) {
startDateTime = startDateTime.startOf('day');
endDateTime = endDateTime.plus({ days: 1 }).startOf('day');
}
let prevStartDateSql: string;
let startDateSql: string;
let endDateSql: string;
// Today range, take all day data starting from the beginning of the day
if (isEndDateToday && startDateTime.hasSame(endDateTime, 'day')) {
startDateTime = startDateTime.startOf('day');
}
if (daysDiff === 0 && isEndDateToday) {
// Last 24 hours
prevStartDateSql = getDatetimeSql({ dbType, daysFromToday: 2, useStartOfDay: false });
startDateSql = getDatetimeSql({ dbType, daysFromToday: 1, useStartOfDay: false });
endDateSql = getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false });
} else {
const dateRangeInDays = daysDiff + 1;
const prevStartDateTime = startDateTime.minus(endDateTime.diff(startDateTime));
const daysFromStartDateToToday = Math.floor(today.diff(startDateStartOfDay, 'days').days);
const prevStartDaysFromToday = daysFromStartDateToToday + dateRangeInDays;
return getDateRangesSelectQuery({ dbType, prevStartDateTime, startDateTime, endDateTime });
};
prevStartDateSql = getDatetimeSql({
dbType,
daysFromToday: prevStartDaysFromToday,
useStartOfDay: true,
});
export function getDateRangesSelectQuery({
dbType,
prevStartDateTime,
startDateTime,
endDateTime,
}: {
dbType: DatabaseConfig['type'];
prevStartDateTime: DateTime;
startDateTime: DateTime;
endDateTime: DateTime;
}) {
const prevStartStr = prevStartDateTime.toSQL({ includeZone: false, includeOffset: false });
const startStr = startDateTime.toSQL({ includeZone: false, includeOffset: false });
const endStr = endDateTime.toSQL({ includeZone: false, includeOffset: false });
startDateSql = getDatetimeSql({
dbType,
daysFromToday: daysFromStartDateToToday,
useStartOfDay: true,
});
endDateSql = isEndDateToday
? getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false })
: getDatetimeSql({ dbType, daysFromToday: daysFromEndDateToToday - 1, useStartOfDay: true });
// Database-specific timestamp casting
// PostgreSQL requires explicit CAST or :: syntax for timestamp comparisons
// SQLite and MySQL/MariaDB can work with string literals in comparisons
if (dbType === 'postgresdb') {
return sql`SELECT
CAST('${prevStartStr}' AS TIMESTAMP) AS prev_start_date,
CAST('${startStr}' AS TIMESTAMP) AS start_date,
CAST('${endStr}' AS TIMESTAMP) AS end_date
`;
}
return sql`SELECT
${prevStartDateSql} AS prev_start_date,
${startDateSql} AS start_date,
${endDateSql} AS end_date
'${prevStartStr}' AS prev_start_date,
'${startStr}' AS start_date,
'${endStr}' AS end_date
`;
};
}

View File

@ -152,7 +152,7 @@ export class InsightsController {
if (!query.startDate) {
return {
startDate: DateTime.now().minus({ days: 6 }).toJSDate(),
startDate: DateTime.now().minus({ days: 7 }).toJSDate(),
endDate: today,
};
}

View File

@ -0,0 +1,230 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { mock } from 'jest-mock-extended';
import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity';
import { AuthorizationCodeRepository } from '../database/repositories/oauth-authorization-code.repository';
import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service';
let authorizationCodeRepository: jest.Mocked<AuthorizationCodeRepository>;
let service: McpOAuthAuthorizationCodeService;
describe('McpOAuthAuthorizationCodeService', () => {
beforeAll(() => {
authorizationCodeRepository = mockInstance(AuthorizationCodeRepository);
service = new McpOAuthAuthorizationCodeService(authorizationCodeRepository);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('createAuthorizationCode', () => {
it('should generate and save authorization code with all parameters', async () => {
const clientId = 'client-123';
const userId = 'user-456';
const redirectUri = 'https://example.com/callback';
const codeChallenge = 'challenge-abc';
const state = 'state-xyz';
authorizationCodeRepository.insert.mockResolvedValue(mock());
const result = await service.createAuthorizationCode(
clientId,
userId,
redirectUri,
codeChallenge,
state,
);
expect(result).toHaveLength(64); // 32 bytes hex = 64 characters
expect(authorizationCodeRepository.insert).toHaveBeenCalledWith({
code: result,
clientId,
userId,
redirectUri,
codeChallenge,
codeChallengeMethod: 'S256',
state,
expiresAt: expect.any(Number),
used: false,
});
});
it('should handle null state', async () => {
authorizationCodeRepository.insert.mockResolvedValue(mock());
await service.createAuthorizationCode(
'client-123',
'user-456',
'https://example.com',
'challenge',
null,
);
expect(authorizationCodeRepository.insert).toHaveBeenCalledWith(
expect.objectContaining({
state: null,
}),
);
});
});
describe('findAndValidateAuthorizationCode', () => {
it('should return auth record when valid', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000, // Future expiry
used: false,
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
const result = await service.findAndValidateAuthorizationCode('code-123', 'client-123');
expect(result).toEqual(authRecord);
expect(authorizationCodeRepository.findOne).toHaveBeenCalledWith({
where: {
code: 'code-123',
clientId: 'client-123',
},
});
});
it('should throw error when authorization code not found', async () => {
authorizationCodeRepository.findOne.mockResolvedValue(null);
await expect(
service.findAndValidateAuthorizationCode('invalid-code', 'client-123'),
).rejects.toThrow('Invalid authorization code');
});
it('should throw error and remove when authorization code expired', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() - 1000, // Expired
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
authorizationCodeRepository.remove.mockResolvedValue(authRecord);
await expect(
service.findAndValidateAuthorizationCode('code-123', 'client-123'),
).rejects.toThrow('Authorization code expired');
expect(authorizationCodeRepository.remove).toHaveBeenCalledWith(authRecord);
});
});
describe('validateAndConsumeAuthorizationCode', () => {
it('should mark code as used and return auth record', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
used: false,
redirectUri: 'https://example.com/callback',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
authorizationCodeRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.validateAndConsumeAuthorizationCode(
'code-123',
'client-123',
'https://example.com/callback',
);
expect(result).toEqual(authRecord);
expect(authRecord.used).toBe(true);
expect(authorizationCodeRepository.update).toHaveBeenCalledWith(
{ code: 'code-123', used: false },
{ used: true },
);
});
it('should throw error when code already used (atomic update fails)', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
used: false,
redirectUri: 'https://example.com/callback',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
authorizationCodeRepository.update.mockResolvedValue({ affected: 0 } as any);
await expect(
service.validateAndConsumeAuthorizationCode('code-123', 'client-123'),
).rejects.toThrow('Authorization code already used');
});
it('should throw error when redirect URI mismatch', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
used: false,
redirectUri: 'https://example.com/callback',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
await expect(
service.validateAndConsumeAuthorizationCode(
'code-123',
'client-123',
'https://evil.com/callback',
),
).rejects.toThrow('Redirect URI mismatch');
});
it('should allow missing redirect URI parameter', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
used: false,
redirectUri: 'https://example.com/callback',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
authorizationCodeRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.validateAndConsumeAuthorizationCode('code-123', 'client-123');
expect(result).toEqual(authRecord);
expect(authorizationCodeRepository.update).toHaveBeenCalledWith(
{ code: 'code-123', used: false },
{ used: true },
);
});
});
describe('getCodeChallenge', () => {
it('should return code challenge from valid auth record', async () => {
const authRecord = mock<AuthorizationCode>({
code: 'code-123',
clientId: 'client-123',
expiresAt: Date.now() + 10000,
codeChallenge: 'challenge-abc',
});
authorizationCodeRepository.findOne.mockResolvedValue(authRecord);
const result = await service.getCodeChallenge('code-123', 'client-123');
expect(result).toBe('challenge-abc');
});
it('should throw error when code invalid', async () => {
authorizationCodeRepository.findOne.mockResolvedValue(null);
await expect(service.getCodeChallenge('invalid-code', 'client-123')).rejects.toThrow(
'Invalid authorization code',
);
});
});
});

View File

@ -0,0 +1,246 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { Logger } from '@n8n/backend-common';
import type { OAuthClient } from '../database/entities/oauth-client.entity';
import { mock } from 'jest-mock-extended';
import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service';
import { McpOAuthConsentService } from '../mcp-oauth-consent.service';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import { OAuthSessionService } from '../oauth-session.service';
import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository';
let logger: jest.Mocked<Logger>;
let oauthSessionService: jest.Mocked<OAuthSessionService>;
let oauthClientRepository: jest.Mocked<OAuthClientRepository>;
let userConsentRepository: jest.Mocked<UserConsentRepository>;
let authorizationCodeService: jest.Mocked<McpOAuthAuthorizationCodeService>;
let service: McpOAuthConsentService;
describe('McpOAuthConsentService', () => {
beforeAll(() => {
logger = mockInstance(Logger);
oauthSessionService = mockInstance(OAuthSessionService) as jest.Mocked<OAuthSessionService>;
oauthClientRepository = mockInstance(
OAuthClientRepository,
) as jest.Mocked<OAuthClientRepository>;
userConsentRepository = mockInstance(
UserConsentRepository,
) as jest.Mocked<UserConsentRepository>;
authorizationCodeService = mockInstance(McpOAuthAuthorizationCodeService);
service = new McpOAuthConsentService(
logger,
oauthSessionService,
oauthClientRepository,
userConsentRepository,
authorizationCodeService,
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('getConsentDetails', () => {
it('should return client details from valid session token', async () => {
const sessionToken = 'valid-session-token';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: 'state',
};
const client = mock<OAuthClient>({
id: 'client-123',
name: 'Test Client',
});
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({
clientName: 'Test Client',
clientId: 'client-123',
});
expect(oauthSessionService.verifySession).toHaveBeenCalledWith(sessionToken);
expect(oauthClientRepository.findOne).toHaveBeenCalledWith({
where: { id: 'client-123' },
});
});
it('should return null when client not found', async () => {
const sessionToken = 'valid-session-token';
const sessionPayload = {
clientId: 'nonexistent-client',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: null,
};
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(null);
const result = await service.getConsentDetails(sessionToken);
expect(result).toBeNull();
});
it('should return null and log error when session verification fails', async () => {
const sessionToken = 'invalid-session-token';
oauthSessionService.verifySession.mockImplementation(() => {
throw new Error('Invalid session');
});
const result = await service.getConsentDetails(sessionToken);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith('Error getting consent details', {
error: expect.any(Error),
});
});
it('should return client details', async () => {
const sessionToken = 'valid-session-token';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: null,
};
const client = mock<OAuthClient>({
id: 'client-123',
name: 'Test Client',
});
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
const result = await service.getConsentDetails(sessionToken);
expect(result).toEqual({
clientName: 'Test Client',
clientId: 'client-123',
});
});
});
describe('handleConsentDecision', () => {
it('should handle user denial', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: 'state-xyz',
};
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
const result = await service.handleConsentDecision(sessionToken, userId, false);
expect(result.redirectUrl).toContain('error=access_denied');
expect(result.redirectUrl).toContain(
'error_description=User+denied+the+authorization+request',
);
expect(result.redirectUrl).toContain('state=state-xyz');
expect(logger.info).toHaveBeenCalledWith('Consent denied', {
clientId: 'client-123',
userId: 'user-123',
});
expect(userConsentRepository.insert).not.toHaveBeenCalled();
});
it('should handle user approval and generate authorization code', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-abc',
state: 'state-xyz',
};
const authCode = 'generated-auth-code';
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
userConsentRepository.insert.mockResolvedValue(mock());
authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode);
const result = await service.handleConsentDecision(sessionToken, userId, true);
expect(result.redirectUrl).toContain('code=generated-auth-code');
expect(result.redirectUrl).toContain('state=state-xyz');
expect(userConsentRepository.insert).toHaveBeenCalledWith({
userId: 'user-123',
clientId: 'client-123',
grantedAt: expect.any(Number),
});
expect(authorizationCodeService.createAuthorizationCode).toHaveBeenCalledWith(
'client-123',
'user-123',
'https://example.com/callback',
'challenge-abc',
'state-xyz',
);
expect(logger.info).toHaveBeenCalledWith('Consent approved', {
clientId: 'client-123',
userId: 'user-123',
});
});
it('should handle approval without state parameter', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-abc',
state: null,
};
const authCode = 'generated-auth-code';
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
userConsentRepository.insert.mockResolvedValue(mock());
authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode);
const result = await service.handleConsentDecision(sessionToken, userId, true);
expect(result.redirectUrl).toContain('code=generated-auth-code');
expect(result.redirectUrl).not.toContain('state=');
});
it('should throw error when session verification fails', async () => {
const sessionToken = 'invalid-session-token';
const userId = 'user-123';
oauthSessionService.verifySession.mockImplementation(() => {
throw new Error('Invalid session');
});
await expect(service.handleConsentDecision(sessionToken, userId, true)).rejects.toThrow(
'Invalid or expired session',
);
});
it('should handle denial without state parameter', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge',
state: null,
};
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
const result = await service.handleConsentDecision(sessionToken, userId, false);
expect(result.redirectUrl).toContain('error=access_denied');
expect(result.redirectUrl).not.toContain('state=');
});
});
});

View File

@ -0,0 +1,551 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity';
import type { OAuthClient } from '../database/entities/oauth-client.entity';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository';
import { McpOAuthAuthorizationCodeService } from '../mcp-oauth-authorization-code.service';
import { McpOAuthService, SUPPORTED_SCOPES } from '../mcp-oauth-service';
import { McpOAuthTokenService } from '../mcp-oauth-token.service';
import { OAuthSessionService } from '../oauth-session.service';
let logger: jest.Mocked<Logger>;
let oauthSessionService: jest.Mocked<OAuthSessionService>;
let oauthClientRepository: jest.Mocked<OAuthClientRepository>;
let tokenService: jest.Mocked<McpOAuthTokenService>;
let authorizationCodeService: jest.Mocked<McpOAuthAuthorizationCodeService>;
let service: McpOAuthService;
let userConsentRepository: jest.Mocked<UserConsentRepository>;
describe('McpOAuthService', () => {
beforeAll(() => {
logger = mockInstance(Logger);
oauthSessionService = mockInstance(OAuthSessionService);
oauthClientRepository = mockInstance(OAuthClientRepository);
tokenService = mockInstance(McpOAuthTokenService);
authorizationCodeService = mockInstance(McpOAuthAuthorizationCodeService);
userConsentRepository = mockInstance(UserConsentRepository);
service = new McpOAuthService(
logger,
oauthSessionService,
oauthClientRepository,
tokenService,
authorizationCodeService,
userConsentRepository,
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('clientsStore', () => {
describe('getClient', () => {
it('should return client information when client exists', async () => {
const client = {
id: 'client-123',
name: 'Test Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethod: 'none',
clientSecret: null,
clientSecretExpiresAt: null,
} as OAuthClient;
oauthClientRepository.findOneBy.mockResolvedValue(client);
const result = await service.clientsStore.getClient('client-123');
expect(result).toEqual({
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: SUPPORTED_SCOPES.join(' '),
});
});
it('should include client secret when present', async () => {
const client = {
id: 'client-123',
name: 'Test Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'client_secret_post',
clientSecret: 'secret-value',
clientSecretExpiresAt: 1234567890,
} as OAuthClient;
oauthClientRepository.findOneBy.mockResolvedValue(client);
const result = await service.clientsStore.getClient('client-123');
expect(result).toMatchObject({
client_secret: 'secret-value',
client_secret_expires_at: 1234567890,
});
});
it('should return undefined when client not found', async () => {
oauthClientRepository.findOneBy.mockResolvedValue(null);
const result = await service.clientsStore.getClient('nonexistent');
expect(result).toBeUndefined();
});
});
describe('registerClient', () => {
it('should save client with all required fields', async () => {
const clientInfo = {
client_id: 'new-client-123',
client_name: 'New Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read write',
};
oauthClientRepository.insert.mockResolvedValue({} as any);
const result = await service.clientsStore.registerClient!(clientInfo);
expect(oauthClientRepository.insert).toHaveBeenCalledWith({
id: 'new-client-123',
name: 'New Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code', 'refresh_token'],
clientSecret: null,
clientSecretExpiresAt: null,
tokenEndpointAuthMethod: 'none',
});
expect(result).toEqual(clientInfo);
});
it('should save client with client secret', async () => {
const clientInfo = {
client_id: 'new-client-123',
client_name: 'New Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'client_secret_post',
client_secret: 'secret-123',
client_secret_expires_at: 1234567890,
response_types: ['code'],
scope: 'read',
};
oauthClientRepository.insert.mockResolvedValue({} as any);
await service.clientsStore.registerClient!(clientInfo);
expect(oauthClientRepository.insert).toHaveBeenCalledWith({
id: 'new-client-123',
name: 'New Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
clientSecret: 'secret-123',
clientSecretExpiresAt: 1234567890,
tokenEndpointAuthMethod: 'client_secret_post',
});
});
it('should handle save errors gracefully', async () => {
const clientInfo = {
client_id: 'new-client-123',
client_name: 'New Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const error = new Error('Database error');
oauthClientRepository.insert.mockRejectedValue(error);
const result = await service.clientsStore.registerClient!(clientInfo);
expect(logger.error).toHaveBeenCalledWith('Error registering OAuth client', {
error,
clientId: 'new-client-123',
});
expect(result).toEqual(clientInfo);
});
});
});
describe('authorize', () => {
it('should create session and redirect to consent page', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read write',
};
const params = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
state: 'state-xyz',
};
const res = mock<Response>();
await service.authorize(client, params, res);
expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
state: 'state-xyz',
});
expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
});
it('should handle null state parameter', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const params = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
};
const res = mock<Response>();
await service.authorize(client, params, res);
expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
state: null,
});
});
it('should handle errors and clear session', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const params = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
};
const res = mock<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
const error = new Error('Session creation failed');
oauthSessionService.createSession.mockImplementation(() => {
throw error;
});
await service.authorize(client, params, res);
expect(logger.error).toHaveBeenCalledWith('Error in authorize method', {
error,
clientId: 'client-123',
});
expect(oauthSessionService.clearSession).toHaveBeenCalledWith(res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'server_error',
error_description: 'Internal server error',
});
});
});
describe('challengeForAuthorizationCode', () => {
it('should return code challenge from authorization code service', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
authorizationCodeService.getCodeChallenge.mockResolvedValue('challenge-123');
const result = await service.challengeForAuthorizationCode(client, 'auth-code-123');
expect(authorizationCodeService.getCodeChallenge).toHaveBeenCalledWith(
'auth-code-123',
'client-123',
);
expect(result).toBe('challenge-123');
});
});
describe('exchangeAuthorizationCode', () => {
it('should validate code and return token pair', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const authRecord = {
userId: 'user-456',
clientId: 'client-123',
} as AuthorizationCode;
authorizationCodeService.validateAndConsumeAuthorizationCode.mockResolvedValue(authRecord);
tokenService.generateTokenPair.mockReturnValue({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
});
tokenService.saveTokenPair.mockResolvedValue();
const result = await service.exchangeAuthorizationCode(
client,
'auth-code-123',
'verifier-123',
'https://example.com/callback',
);
expect(authorizationCodeService.validateAndConsumeAuthorizationCode).toHaveBeenCalledWith(
'auth-code-123',
'client-123',
'https://example.com/callback',
);
expect(tokenService.generateTokenPair).toHaveBeenCalledWith('user-456', 'client-123');
expect(tokenService.saveTokenPair).toHaveBeenCalledWith(
'access-token-123',
'refresh-token-456',
'client-123',
'user-456',
);
expect(result).toEqual({
access_token: 'access-token-123',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'refresh-token-456',
});
});
it('should handle authorization code exchange without redirect URI', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const authRecord = {
userId: 'user-456',
clientId: 'client-123',
} as AuthorizationCode;
authorizationCodeService.validateAndConsumeAuthorizationCode.mockResolvedValue(authRecord);
tokenService.generateTokenPair.mockReturnValue({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
});
await service.exchangeAuthorizationCode(client, 'auth-code-123', 'verifier-123');
expect(authorizationCodeService.validateAndConsumeAuthorizationCode).toHaveBeenCalledWith(
'auth-code-123',
'client-123',
undefined,
);
});
});
describe('exchangeRefreshToken', () => {
it('should validate and rotate refresh token', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
const newTokens = {
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token',
};
tokenService.validateAndRotateRefreshToken.mockResolvedValue(newTokens);
const result = await service.exchangeRefreshToken(client, 'old-refresh-token', ['read']);
expect(tokenService.validateAndRotateRefreshToken).toHaveBeenCalledWith(
'old-refresh-token',
'client-123',
);
expect(result).toEqual(newTokens);
});
});
describe('verifyAccessToken', () => {
it('should verify access token and return auth info', async () => {
const authInfo = {
token: 'access-token-123',
userId: 'user-123',
clientId: 'client-456',
scopes: ['read', 'write'],
};
tokenService.verifyAccessToken.mockResolvedValue(authInfo);
const result = await service.verifyAccessToken('access-token-123');
expect(tokenService.verifyAccessToken).toHaveBeenCalledWith('access-token-123');
expect(result).toEqual(authInfo);
});
});
describe('revokeToken', () => {
it('should revoke access token when type hint is access_token', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeAccessToken.mockResolvedValue(true);
await service.revokeToken(client, {
token: 'token-123',
token_type_hint: 'access_token',
});
expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
expect(tokenService.revokeRefreshToken).not.toHaveBeenCalled();
});
it('should revoke refresh token when type hint is refresh_token', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeRefreshToken.mockResolvedValue(true);
await service.revokeToken(client, {
token: 'token-123',
token_type_hint: 'refresh_token',
});
expect(tokenService.revokeAccessToken).not.toHaveBeenCalled();
expect(tokenService.revokeRefreshToken).toHaveBeenCalledWith('token-123', 'client-123');
});
it('should try access token first when no type hint provided', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeAccessToken.mockResolvedValue(true);
await service.revokeToken(client, {
token: 'token-123',
});
expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
expect(tokenService.revokeRefreshToken).not.toHaveBeenCalled();
});
it('should try refresh token if access token revocation fails', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeAccessToken.mockResolvedValue(false);
tokenService.revokeRefreshToken.mockResolvedValue(true);
await service.revokeToken(client, {
token: 'token-123',
});
expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
expect(tokenService.revokeRefreshToken).toHaveBeenCalledWith('token-123', 'client-123');
});
it('should silently succeed when token not found', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
scope: 'read',
};
tokenService.revokeAccessToken.mockResolvedValue(false);
tokenService.revokeRefreshToken.mockResolvedValue(false);
await service.revokeToken(client, {
token: 'unknown-token',
});
expect(logger.debug).toHaveBeenCalledWith('Token revocation requested for unknown token', {
clientId: 'client-123',
});
});
});
});

View File

@ -0,0 +1,344 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { JwtService } from '@/services/jwt.service';
import type { AccessToken } from '../database/entities/oauth-access-token.entity';
import type { RefreshToken } from '../database/entities/oauth-refresh-token.entity';
import { AccessTokenRepository } from '../database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from '../database/repositories/oauth-refresh-token.repository';
import { McpOAuthTokenService } from '../mcp-oauth-token.service';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
let logger: jest.Mocked<Logger>;
let userRepository: jest.Mocked<UserRepository>;
let accessTokenRepository: jest.Mocked<AccessTokenRepository>;
let refreshTokenRepository: jest.Mocked<RefreshTokenRepository>;
let service: McpOAuthTokenService;
let mockTransactionManager: any;
describe('McpOAuthTokenService', () => {
beforeAll(() => {
logger = mockInstance(Logger);
userRepository = mockInstance(UserRepository);
accessTokenRepository = mockInstance(
AccessTokenRepository,
) as jest.Mocked<AccessTokenRepository>;
refreshTokenRepository = mockInstance(
RefreshTokenRepository,
) as jest.Mocked<RefreshTokenRepository>;
mockTransactionManager = {
insert: jest.fn().mockResolvedValue(mock()),
remove: jest.fn().mockResolvedValue(mock()),
findOne: jest.fn(),
delete: jest.fn(),
};
const mockManager: any = {
transaction: jest.fn(async (cb: any) => await cb(mockTransactionManager)),
};
(accessTokenRepository as any).manager = mockManager;
(accessTokenRepository as any).target = 'AccessToken';
(refreshTokenRepository as any).manager = mockManager;
(refreshTokenRepository as any).target = 'RefreshToken';
service = new McpOAuthTokenService(
logger,
jwtService,
userRepository,
accessTokenRepository,
refreshTokenRepository,
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('generateTokenPair', () => {
it('should generate JWT access token and opaque refresh token', () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken, refreshToken } = service.generateTokenPair(userId, clientId);
expect(accessToken).toMatch(/^[\w-]+\.[\w-]+\.[\w-]+$/); // JWT format
const decoded = jwtService.decode(accessToken);
expect(decoded.sub).toBe(userId);
expect(decoded.aud).toBe('mcp-server-api');
expect(decoded.client_id).toBe(clientId);
expect(decoded.meta.isOAuth).toBe(true);
expect(decoded.jti).toBeDefined();
expect(decoded.iat).toBeDefined();
expect(decoded.exp).toBeDefined();
expect(refreshToken).toHaveLength(64); // 32 bytes hex = 64 characters
expect(refreshToken).toMatch(/^[a-f0-9]{64}$/);
});
it('should generate different tokens on each call', () => {
const userId = 'user-123';
const clientId = 'client-456';
const pair1 = service.generateTokenPair(userId, clientId);
const pair2 = service.generateTokenPair(userId, clientId);
expect(pair1.accessToken).not.toBe(pair2.accessToken);
expect(pair1.refreshToken).not.toBe(pair2.refreshToken);
});
});
describe('saveTokenPair', () => {
it('should save both tokens in a transaction', async () => {
const accessToken = 'jwt-access-token';
const refreshToken = 'opaque-refresh-token';
const clientId = 'client-123';
const userId = 'user-456';
await service.saveTokenPair(accessToken, refreshToken, clientId, userId);
const mockManager = accessTokenRepository.manager as any;
expect(mockManager.transaction).toHaveBeenCalled();
expect(mockTransactionManager.insert).toHaveBeenCalledTimes(2);
expect(mockTransactionManager.insert).toHaveBeenCalledWith('AccessToken', {
token: accessToken,
clientId,
userId,
});
expect(mockTransactionManager.insert).toHaveBeenCalledWith('RefreshToken', {
token: refreshToken,
clientId,
userId,
expiresAt: expect.any(Number),
});
});
});
describe('validateAndRotateRefreshToken', () => {
it('should rotate refresh token and return new token pair in a transaction', async () => {
const refreshToken = 'old-refresh-token';
const clientId = 'client-123';
const refreshTokenRecord = mock<RefreshToken>({
token: refreshToken,
clientId,
userId: 'user-456',
expiresAt: Date.now() + 1000000, // Valid
});
mockTransactionManager.findOne.mockResolvedValue(refreshTokenRecord);
mockTransactionManager.delete.mockResolvedValue({ affected: 1 });
const result = await service.validateAndRotateRefreshToken(refreshToken, clientId);
expect(result).toEqual({
access_token: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
token_type: 'Bearer',
expires_in: 3600,
refresh_token: expect.stringMatching(/^[a-f0-9]{64}$/),
});
// Verify transaction was used
const mockManager = refreshTokenRepository.manager as any;
expect(mockManager.transaction).toHaveBeenCalled();
// Verify all operations happened inside the transaction
expect(mockTransactionManager.findOne).toHaveBeenCalled();
expect(mockTransactionManager.delete).toHaveBeenCalled();
expect(mockTransactionManager.insert).toHaveBeenCalledTimes(2);
});
it('should throw error when refresh token not found', async () => {
mockTransactionManager.findOne.mockResolvedValue(null);
await expect(
service.validateAndRotateRefreshToken('invalid-token', 'client-123'),
).rejects.toThrow('Invalid refresh token');
});
it('should throw error when refresh token expired (atomic delete fails)', async () => {
const refreshTokenRecord = mock<RefreshToken>({
token: 'expired-token',
clientId: 'client-123',
userId: 'user-456',
expiresAt: Date.now() - 1000, // Expired
});
mockTransactionManager.findOne.mockResolvedValue(refreshTokenRecord);
mockTransactionManager.delete.mockResolvedValue({ affected: 0 }); // Atomic delete fails due to expiry
await expect(
service.validateAndRotateRefreshToken('expired-token', 'client-123'),
).rejects.toThrow('Invalid refresh token');
});
});
describe('verifyAccessToken', () => {
it('should verify valid access token and return auth info', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken } = service.generateTokenPair(userId, clientId);
const accessTokenRecord = mock<AccessToken>({
token: accessToken,
clientId,
userId,
});
accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord);
const result = await service.verifyAccessToken(accessToken);
expect(result).toEqual({
token: accessToken,
clientId,
scopes: [],
extra: {
userId,
},
});
});
it('should throw error for invalid JWT signature', async () => {
const invalidToken = 'invalid.jwt.token';
await expect(service.verifyAccessToken(invalidToken)).rejects.toThrow(
'Invalid access token: JWT verification failed',
);
});
it('should throw error for wrong audience', async () => {
const wrongAudienceToken = jwtService.sign({
sub: 'user-123',
aud: 'wrong-audience', // Not 'mcp-server-api'
client_id: 'client-456',
});
await expect(service.verifyAccessToken(wrongAudienceToken)).rejects.toThrow(
'Invalid access token: JWT verification failed',
);
});
it('should throw error when token not found in database', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken } = service.generateTokenPair(userId, clientId);
accessTokenRepository.findOne.mockResolvedValue(null);
await expect(service.verifyAccessToken(accessToken)).rejects.toThrow(
'Invalid access token: not found in database',
);
});
});
describe('verifyOAuthAccessToken', () => {
it('should verify token and return user', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken } = service.generateTokenPair(userId, clientId);
const accessTokenRecord = mock<AccessToken>({
token: accessToken,
clientId,
userId,
});
const user = mock<User>({ id: userId });
accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord);
userRepository.findOne.mockResolvedValue(user);
const result = await service.verifyOAuthAccessToken(accessToken);
expect(result).toEqual(user);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: userId },
relations: ['role'],
});
});
it('should return null for invalid token', async () => {
const invalidToken = 'invalid.jwt.token';
const result = await service.verifyOAuthAccessToken(invalidToken);
expect(result).toBeNull();
});
it('should return null when user not found', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const { accessToken } = service.generateTokenPair(userId, clientId);
const accessTokenRecord = mock<AccessToken>({
token: accessToken,
clientId,
userId,
});
accessTokenRepository.findOne.mockResolvedValue(accessTokenRecord);
userRepository.findOne.mockResolvedValue(null);
const result = await service.verifyOAuthAccessToken(accessToken);
expect(result).toBeNull();
});
});
describe('revokeAccessToken', () => {
it('should delete access token', async () => {
const token = 'access-token-123';
const clientId = 'client-456';
accessTokenRepository.delete.mockResolvedValue({ affected: 1 } as any);
const result = await service.revokeAccessToken(token, clientId);
expect(result).toBe(true);
expect(accessTokenRepository.delete).toHaveBeenCalledWith({ token, clientId });
expect(logger.info).toHaveBeenCalledWith('Access token revoked', { clientId });
});
it('should return false when token not found', async () => {
accessTokenRepository.delete.mockResolvedValue({ affected: 0 } as any);
const result = await service.revokeAccessToken('nonexistent-token', 'client-456');
expect(result).toBe(false);
});
});
describe('revokeRefreshToken', () => {
it('should delete refresh token', async () => {
const token = 'refresh-token-123';
const clientId = 'client-456';
refreshTokenRepository.delete.mockResolvedValue({ affected: 1 } as any);
const result = await service.revokeRefreshToken(token, clientId);
expect(result).toBe(true);
expect(refreshTokenRepository.delete).toHaveBeenCalledWith({ token, clientId });
expect(logger.info).toHaveBeenCalledWith('Refresh token revoked', { clientId });
});
it('should return false when token not found', async () => {
refreshTokenRepository.delete.mockResolvedValue({ affected: 0 } as any);
const result = await service.revokeRefreshToken('nonexistent-token', 'client-456');
expect(result).toBe(false);
});
});
});

View File

@ -0,0 +1,151 @@
import { McpOAuthHelpers } from '../mcp-oauth.helpers';
describe('McpOAuthHelpers', () => {
describe('buildSuccessRedirectUrl', () => {
it('should build redirect URL with authorization code', () => {
const redirectUri = 'https://example.com/callback';
const code = 'auth-code-123';
const state = null;
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toBe('https://example.com/callback?code=auth-code-123');
});
it('should include state parameter when provided', () => {
const redirectUri = 'https://example.com/callback';
const code = 'auth-code-123';
const state = 'state-xyz';
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toBe('https://example.com/callback?code=auth-code-123&state=state-xyz');
});
it('should preserve existing query parameters', () => {
const redirectUri = 'https://example.com/callback?foo=bar';
const code = 'auth-code-123';
const state = 'state-xyz';
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toContain('foo=bar');
expect(result).toContain('code=auth-code-123');
expect(result).toContain('state=state-xyz');
});
it('should handle redirect URI with port', () => {
const redirectUri = 'http://localhost:3000/callback';
const code = 'auth-code-123';
const state = null;
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toBe('http://localhost:3000/callback?code=auth-code-123');
});
it('should URL-encode special characters in code', () => {
const redirectUri = 'https://example.com/callback';
const code = 'code+with/special=chars';
const state = null;
const result = McpOAuthHelpers.buildSuccessRedirectUrl(redirectUri, code, state);
expect(result).toContain('code=code%2Bwith%2Fspecial%3Dchars');
});
});
describe('buildErrorRedirectUrl', () => {
it('should build redirect URL with error parameters', () => {
const redirectUri = 'https://example.com/callback';
const error = 'access_denied';
const errorDescription = 'User denied the authorization request';
const state = null;
const result = McpOAuthHelpers.buildErrorRedirectUrl(
redirectUri,
error,
errorDescription,
state,
);
expect(result).toContain('error=access_denied');
expect(result).toContain('error_description=User+denied+the+authorization+request');
});
it('should include state parameter when provided', () => {
const redirectUri = 'https://example.com/callback';
const error = 'invalid_request';
const errorDescription = 'Missing required parameter';
const state = 'state-xyz';
const result = McpOAuthHelpers.buildErrorRedirectUrl(
redirectUri,
error,
errorDescription,
state,
);
expect(result).toContain('error=invalid_request');
expect(result).toContain('state=state-xyz');
});
it('should preserve existing query parameters', () => {
const redirectUri = 'https://example.com/callback?foo=bar';
const error = 'server_error';
const errorDescription = 'Internal server error';
const state = 'state-xyz';
const result = McpOAuthHelpers.buildErrorRedirectUrl(
redirectUri,
error,
errorDescription,
state,
);
expect(result).toContain('foo=bar');
expect(result).toContain('error=server_error');
expect(result).toContain('state=state-xyz');
});
it('should handle common OAuth error codes', () => {
const testCases = [
{ error: 'access_denied', description: 'User denied' },
{ error: 'invalid_request', description: 'Bad request' },
{ error: 'unauthorized_client', description: 'Client not authorized' },
{ error: 'invalid_scope', description: 'Invalid scope' },
{ error: 'server_error', description: 'Server error' },
{ error: 'temporarily_unavailable', description: 'Service unavailable' },
];
testCases.forEach(({ error, description }) => {
const result = McpOAuthHelpers.buildErrorRedirectUrl(
'https://example.com/callback',
error,
description,
null,
);
expect(result).toContain(`error=${error}`);
expect(result).toContain('error_description=');
});
});
it('should URL-encode special characters in error description', () => {
const redirectUri = 'https://example.com/callback';
const error = 'access_denied';
const errorDescription = 'User said "no thanks!"';
const state = null;
const result = McpOAuthHelpers.buildErrorRedirectUrl(
redirectUri,
error,
errorDescription,
state,
);
expect(result).toContain('error_description=');
expect(result).not.toContain('"'); // Should be encoded
});
});
});

View File

@ -1,423 +0,0 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { ApiKeyRepository, UserRepository } from '@n8n/db';
import { randomUUID } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
import { mock, mockDeep } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { McpServerApiKeyService } from '../mcp-api-key.service';
const mockReqWith = (authHeader: string | undefined) => {
const req = mockDeep<Request>();
req.header.mockImplementation((name: string) => {
if (name === 'authorization') return authHeader;
return undefined;
});
return req;
};
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
let userRepository: jest.Mocked<UserRepository>;
let apiKeyRepository: jest.Mocked<ApiKeyRepository>;
let telemetry: jest.Mocked<Telemetry>;
let mcpServerApiKeyService: McpServerApiKeyService;
describe('McpServerApiKeyService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
beforeAll(() => {
userRepository = mockInstance(UserRepository);
apiKeyRepository = mockInstance(ApiKeyRepository);
telemetry = mockInstance(Telemetry);
mcpServerApiKeyService = new McpServerApiKeyService(
apiKeyRepository,
jwtService,
userRepository,
telemetry,
);
});
describe('getAuthMiddleware', () => {
it('should return 401 if authorization header is missing', async () => {
// Arrange
const req = mockReqWith(undefined);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should throw error if authorization header does not start with Bearer', async () => {
// Arrange
const req = mockReqWith('Basic sometoken');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act & Assert
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should throw error if authorization header has invalid Bearer format', async () => {
// Arrange
const req = mockReqWith('Bearer');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act & Assert
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if API key is not found in database', async () => {
// Arrange
const apiKey = jwtService.sign({
sub: randomUUID(),
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(null);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if JWT verification fails (invalid signature)', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const wrongJwtService = new JwtService(
mock<InstanceSettings>({ encryptionKey: 'wrong-key' }),
mock(),
);
const apiKey = wrongJwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should authenticate successfully with valid API key', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.send).not.toHaveBeenCalled();
// @ts-ignore
expect(req.user).toBeDefined();
// @ts-ignore
expect(req.user.id).toBe(userId);
});
it('should attach user with role information to request', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
// @ts-ignore
expect(req.user).toBeDefined();
// @ts-ignore
expect(req.user.role).toBeDefined();
});
it('should handle Bearer token with exact case matching', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should throw error with non-standard Bearer casing', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`BEARER ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act & Assert
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if user is not found for valid JWT', async () => {
// Arrange
const apiKey = jwtService.sign({
sub: randomUUID(),
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(null);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should return 401 for malformed JWT', async () => {
// Arrange
userRepository.findOne.mockResolvedValue(null);
const req = mockReqWith('Bearer malformed.jwt.token');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should handle Bearer token with extra whitespace', async () => {
// Arrange
const userId = randomUUID();
const mockUser = mockDeep<User>();
mockUser.id = userId;
const apiKey = jwtService.sign({
sub: userId,
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(mockUser);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should return 401 if API key exists but user is deleted', async () => {
// Arrange
const apiKey = jwtService.sign({
sub: randomUUID(),
iss: 'n8n',
aud: 'mcp-server-api',
jti: randomUUID(),
});
userRepository.findOne.mockResolvedValue(null);
const req = mockReqWith(`Bearer ${apiKey}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act
await middleware(req, res, next);
// Assert
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should throw error with empty Bearer token', async () => {
// Arrange
const req = mockReqWith('Bearer ');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = mcpServerApiKeyService.getAuthMiddleware();
// Act & Assert
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,313 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import type { Request, Response, NextFunction } from 'express';
import { mock, mockDeep } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { McpServerApiKeyService } from '../mcp-api-key.service';
import { McpOAuthTokenService } from '../mcp-oauth-token.service';
import { McpServerMiddlewareService } from '../mcp-server-middleware.service';
const mockReqWith = (authHeader: string | undefined, body?: any) => {
const req = mockDeep<Request>();
req.header.mockImplementation((name: string) => {
if (name === 'authorization') return authHeader;
return undefined;
});
req.body = body || {};
return req;
};
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
let mcpServerApiKeyService: jest.Mocked<McpServerApiKeyService>;
let oauthTokenService: jest.Mocked<McpOAuthTokenService>;
let telemetry: jest.Mocked<Telemetry>;
let service: McpServerMiddlewareService;
describe('McpServerMiddlewareService', () => {
beforeAll(() => {
mcpServerApiKeyService = mockInstance(
McpServerApiKeyService,
) as jest.Mocked<McpServerApiKeyService>;
oauthTokenService = mockInstance(McpOAuthTokenService) as jest.Mocked<McpOAuthTokenService>;
telemetry = mockInstance(Telemetry);
service = new McpServerMiddlewareService(
mcpServerApiKeyService,
oauthTokenService,
jwtService,
telemetry,
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('getUserForToken', () => {
it('should return user for valid OAuth token (meta.isOAuth = true)', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user);
const result = await service.getUserForToken(oauthToken);
expect(result).toEqual(user);
expect(oauthTokenService.verifyOAuthAccessToken).toHaveBeenCalledWith(oauthToken);
expect(mcpServerApiKeyService.verifyApiKey).not.toHaveBeenCalled();
});
it('should return user for valid API key (no meta.isOAuth)', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
const result = await service.getUserForToken(apiKeyToken);
expect(result).toEqual(user);
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
it('should return user for valid API key (meta.isOAuth = false)', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: false },
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
const result = await service.getUserForToken(apiKeyToken);
expect(result).toEqual(user);
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
it('should return null for invalid JWT format', async () => {
const invalidToken = 'not-a-jwt-token';
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
const result = await service.getUserForToken(invalidToken);
expect(result).toBeNull();
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
it('should return null when OAuth token verification fails', async () => {
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null);
const result = await service.getUserForToken(oauthToken);
expect(result).toBeNull();
});
it('should return null when API key verification fails', async () => {
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
const result = await service.getUserForToken(apiKeyToken);
expect(result).toBeNull();
});
});
describe('getAuthMiddleware', () => {
it('should return 401 with WWW-Authenticate header when authorization header is missing', async () => {
const req = mockReqWith(undefined);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
res.header.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
mcp_connection_status: 'error',
error: 'Unauthorized',
client_name: undefined,
client_version: undefined,
});
});
it('should throw error when authorization header does not start with Bearer', async () => {
const req = mockReqWith('Basic sometoken');
const res = mockDeep<Response>();
res.status.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = service.getAuthMiddleware();
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should throw error when Bearer token is malformed', async () => {
const req = mockReqWith('Bearer');
const res = mockDeep<Response>();
res.status.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = service.getAuthMiddleware();
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
expect(next).not.toHaveBeenCalled();
});
it('should authenticate with valid OAuth token and call next', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const req = mockReqWith(`Bearer ${oauthToken}`);
const res = mockDeep<Response>();
const next = jest.fn() as NextFunction;
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user);
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect((req as any).user).toEqual(user);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should authenticate with valid API key and call next', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
const req = mockReqWith(`Bearer ${apiKeyToken}`);
const res = mockDeep<Response>();
const next = jest.fn() as NextFunction;
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect((req as any).user).toEqual(user);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should return 401 with WWW-Authenticate header when token validation fails', async () => {
const invalidToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const req = mockReqWith(`Bearer ${invalidToken}`);
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
res.header.mockReturnThis();
const next = jest.fn() as NextFunction;
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null);
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
it('should track telemetry with client info from request body', async () => {
const req = mockReqWith(undefined, {
params: {
clientInfo: {
name: 'test-client',
version: '1.0.0',
},
},
});
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
res.header.mockReturnThis();
const next = jest.fn() as NextFunction;
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
mcp_connection_status: 'error',
error: 'Unauthorized',
client_name: 'test-client',
client_version: '1.0.0',
});
});
it('should handle invalid token format gracefully with WWW-Authenticate header', async () => {
const req = mockReqWith('Bearer invalid-token-format');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.send.mockReturnThis();
res.header.mockReturnThis();
const next = jest.fn() as NextFunction;
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
const middleware = service.getAuthMiddleware();
await middleware(req, res, next);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(next).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,433 @@
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { JwtService } from '@/services/jwt.service';
import { createOwner, createMember } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import type { OAuthClient } from '../database/entities/oauth-client.entity';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import type { OAuthSessionPayload } from '../oauth-session.service';
const testServer = setupTestServer({ endpointGroups: ['mcp'], modules: ['mcp'] });
let owner: User;
let member: User;
let jwtService: JwtService;
const createSessionToken = (payload: OAuthSessionPayload): string => {
return jwtService.sign(payload, { expiresIn: '10m' });
};
let oauthClientRepository: OAuthClientRepository;
beforeAll(async () => {
owner = await createOwner();
member = await createMember();
jwtService = Container.get(JwtService);
oauthClientRepository = Container.get(OAuthClientRepository);
});
afterEach(async () => {
await testDb.truncate(['OAuthClient', 'AuthorizationCode', 'UserConsent']);
});
describe('GET /rest/consent/details', () => {
test('should return consent details for valid session', async () => {
const client = await oauthClientRepository.save({
id: 'test-client-id',
name: 'Test OAuth Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionPayload = {
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
};
const sessionToken = createSessionToken(sessionPayload);
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({
clientName: 'Test OAuth Client',
clientId: 'test-client-id',
});
});
test('should return 400 when session cookie is missing', async () => {
const response = await testServer.authAgentFor(owner).get('/consent/details');
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toEqual({
status: 'error',
message: 'Invalid or expired authorization session',
});
});
test('should return 400 when session token is invalid', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', 'n8n-oauth-session=invalid-token');
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toEqual({
status: 'error',
message: 'Invalid or expired authorization session',
});
});
test('should return 400 when client does not exist', async () => {
const sessionPayload = {
clientId: 'non-existent-client',
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
};
const sessionToken = createSessionToken(sessionPayload);
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toEqual({
status: 'error',
message: 'Invalid or expired authorization session',
});
});
test('should clear session cookie on invalid token', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', 'n8n-oauth-session=invalid-token');
expect(response.statusCode).toBeGreaterThanOrEqual(400);
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toContain('n8n-oauth-session=');
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
test('should require authentication', async () => {
const response = await testServer.authlessAgent.get('/consent/details');
expect(response.statusCode).toBe(401);
});
test('should work for different users', async () => {
const client = await oauthClientRepository.save({
id: 'test-client-id-2',
name: 'Test Client 2',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionPayload = {
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
};
const sessionToken = createSessionToken(sessionPayload);
const ownerResponse = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(ownerResponse.statusCode).toBe(200);
expect(ownerResponse.body.data.clientName).toBe('Test Client 2');
const memberResponse = await testServer
.authAgentFor(member)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(memberResponse.statusCode).toBe(200);
expect(memberResponse.body.data.clientName).toBe('Test Client 2');
});
});
describe('POST /rest/consent/approve', () => {
let client: OAuthClient;
let sessionToken: string;
beforeEach(async () => {
client = await oauthClientRepository.save({
id: `test-client-${Date.now()}`,
name: 'Test OAuth Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionPayload = {
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge-string-that-is-long-enough',
state: 'test-state',
};
sessionToken = createSessionToken(sessionPayload);
});
test('should handle consent approval and return redirect URL with authorization code', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({
status: 'success',
redirectUrl: expect.stringContaining('https://example.com/callback?code='),
});
const redirectUrl = new URL(response.body.data.redirectUrl);
expect(redirectUrl.searchParams.get('code')).toBeTruthy();
expect(redirectUrl.searchParams.get('code')?.length).toBeGreaterThan(32);
expect(redirectUrl.searchParams.get('state')).toBe('test-state');
});
test('should handle consent denial and return error redirect URL', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: false });
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({
status: 'success',
redirectUrl: expect.stringContaining('https://example.com/callback?error=access_denied'),
});
const redirectUrl = new URL(response.body.data.redirectUrl);
expect(redirectUrl.searchParams.get('error')).toBe('access_denied');
expect(redirectUrl.searchParams.get('error_description')).toBeTruthy();
expect(redirectUrl.searchParams.get('state')).toBe('test-state');
});
test('should clear session cookie after processing consent', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(response.statusCode).toBe(200);
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toContain('n8n-oauth-session=');
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
test('should return 400 when approved field is missing', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({});
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toMatchObject({
code: 'invalid_type',
expected: 'boolean',
path: ['approved'],
});
});
test('should return 400 when approved field is not boolean', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: 'yes' });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toMatchObject({
code: 'invalid_type',
expected: 'boolean',
received: 'string',
path: ['approved'],
});
});
test('should return 400 when session cookie is missing', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.send({ approved: true });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body).toEqual({
status: 'error',
message: 'Invalid or expired authorization session',
});
});
test('should return 400 when session token is invalid', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', 'n8n-oauth-session=invalid-token')
.send({ approved: true });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body.status).toBe('error');
});
test('should clear session cookie even on error', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', 'n8n-oauth-session=invalid-token')
.send({ approved: true });
const setCookieHeader = response.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toContain('n8n-oauth-session=');
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
test('should require authentication', async () => {
const response = await testServer.authlessAgent
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(response.statusCode).toBe(401);
});
test('should create user consent record on approval', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(response.statusCode).toBe(200);
const { UserConsentRepository } = await import(
'../database/repositories/oauth-user-consent.repository'
);
const userConsentRepository = Container.get(UserConsentRepository);
const consent = await userConsentRepository.findOne({
where: { userId: owner.id, clientId: client.id },
});
expect(consent).toBeDefined();
expect(consent?.userId).toBe(owner.id);
expect(consent?.clientId).toBe(client.id);
expect(consent?.grantedAt).toBeDefined();
});
test('should not create user consent record on denial', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: false });
expect(response.statusCode).toBe(200);
const { UserConsentRepository } = await import(
'../database/repositories/oauth-user-consent.repository'
);
const userConsentRepository = Container.get(UserConsentRepository);
const consent = await userConsentRepository.findOne({
where: { userId: owner.id, clientId: client.id },
});
expect(consent).toBeNull();
});
test('should handle consent from different users', async () => {
const ownerResponse = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(ownerResponse.statusCode).toBe(200);
expect(ownerResponse.body.data.redirectUrl).toContain('code=');
const newSessionToken = createSessionToken({
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge-string-that-is-long-enough',
state: 'test-state-2',
});
const memberResponse = await testServer
.authAgentFor(member)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${newSessionToken}`)
.send({ approved: false });
expect(memberResponse.statusCode).toBe(200);
expect(memberResponse.body.data.redirectUrl).toContain('error=access_denied');
});
});
describe('Consent Flow - End-to-End', () => {
test('should complete full consent flow from details to approval', async () => {
const client = await oauthClientRepository.save({
id: 'e2e-test-client',
name: 'End-to-End Test Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const sessionPayload = {
clientId: client.id,
redirectUri: 'https://example.com/callback',
codeChallenge: 'e2e-test-challenge-string-that-is-long-enough',
state: 'e2e-state',
};
const sessionToken = createSessionToken(sessionPayload);
const detailsResponse = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(detailsResponse.statusCode).toBe(200);
expect(detailsResponse.body.data.clientName).toBe('End-to-End Test Client');
const approvalResponse = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true });
expect(approvalResponse.statusCode).toBe(200);
expect(approvalResponse.body.data.status).toBe('success');
expect(approvalResponse.body.data.redirectUrl).toContain('code=');
expect(approvalResponse.body.data.redirectUrl).toContain('state=e2e-state');
const setCookieHeader = approvalResponse.headers['set-cookie'];
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader[0]).toContain('n8n-oauth-session=');
expect(setCookieHeader[0]).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
});

View File

@ -1,19 +1,20 @@
import { Logger } from '@n8n/backend-common';
import { type AuthenticatedRequest } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Request } from 'express';
import { mock, mockDeep } from 'jest-mock-extended';
// eslint-disable-next-line import-x/order
import { McpServerApiKeyService } from '../mcp-api-key.service';
import { McpServerMiddlewareService } from '../mcp-server-middleware.service';
const mockAuthMiddleware = jest.fn().mockImplementation(async (_req, _res, next) => {
next();
});
const mcpServerApiKeyService = mockDeep<McpServerApiKeyService>();
mcpServerApiKeyService.getAuthMiddleware.mockReturnValue(mockAuthMiddleware);
const mcpServerMiddlewareService = mockDeep<McpServerMiddlewareService>();
mcpServerMiddlewareService.getAuthMiddleware.mockReturnValue(mockAuthMiddleware);
// We need to mock the service before importing the controller as it's used in the middleware
Container.set(McpServerApiKeyService, mcpServerApiKeyService);
Container.set(McpServerMiddlewareService, mcpServerMiddlewareService);
import { McpController, type FlushableResponse } from '../mcp.controller';
import { McpService } from '../mcp.service';
@ -72,4 +73,17 @@ describe('McpController', () => {
await controller.build(createReq(), res);
expect(mcpService.getServer as unknown as jest.Mock).toHaveBeenCalled();
});
test('HEAD /http returns 401 with WWW-Authenticate header for auth scheme discovery', async () => {
const req = {} as Request;
const res = createRes();
res.header = jest.fn().mockReturnThis();
res.end = jest.fn().mockReturnThis();
await controller.discoverAuthSchemeHead(req, res);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.end).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,335 @@
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { createOwner } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { SUPPORTED_SCOPES } from '../mcp-oauth-service';
const testServer = setupTestServer({ modules: ['mcp'], endpointGroups: ['mcp'] });
let owner: User;
beforeAll(async () => {
owner = await createOwner();
});
afterEach(async () => {
await testDb.truncate(['OAuthClient', 'AuthorizationCode', 'AccessToken', 'RefreshToken']);
});
describe('GET /.well-known/oauth-authorization-server', () => {
test('should return OAuth authorization server metadata', async () => {
const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server');
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
issuer: expect.any(String),
authorization_endpoint: expect.stringContaining('/mcp-oauth/authorize'),
token_endpoint: expect.stringContaining('/mcp-oauth/token'),
registration_endpoint: expect.stringContaining('/mcp-oauth/register'),
revocation_endpoint: expect.stringContaining('/mcp-oauth/revoke'),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: SUPPORTED_SCOPES,
});
});
test('should return metadata with correct base URL', async () => {
const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server');
expect(response.statusCode).toBe(200);
const {
issuer,
authorization_endpoint,
token_endpoint,
registration_endpoint,
revocation_endpoint,
} = response.body;
expect(issuer).toMatch(/^https?:\/\//);
expect(authorization_endpoint).toBe(`${issuer}/mcp-oauth/authorize`);
expect(token_endpoint).toBe(`${issuer}/mcp-oauth/token`);
expect(registration_endpoint).toBe(`${issuer}/mcp-oauth/register`);
expect(revocation_endpoint).toBe(`${issuer}/mcp-oauth/revoke`);
});
test('should include all required OAuth 2.1 fields', async () => {
const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server');
expect(response.statusCode).toBe(200);
const metadata = response.body;
expect(metadata.issuer).toBeDefined();
expect(metadata.authorization_endpoint).toBeDefined();
expect(metadata.token_endpoint).toBeDefined();
expect(metadata.response_types_supported).toBeDefined();
expect(metadata.grant_types_supported).toBeDefined();
expect(metadata.code_challenge_methods_supported).toContain('S256');
});
test('should be accessible without authentication', async () => {
const response = await testServer.restlessAgent.get('/.well-known/oauth-authorization-server');
expect(response.statusCode).toBe(200);
});
});
describe('GET /.well-known/oauth-protected-resource/mcp-server/http', () => {
test('should return protected resource metadata', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
resource: expect.stringContaining('/mcp-server/http'),
bearer_methods_supported: ['header'],
authorization_servers: [expect.any(String)],
scopes_supported: SUPPORTED_SCOPES,
});
});
test('should return metadata with correct resource URL', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
const { resource, authorization_servers } = response.body;
expect(resource).toMatch(/^https?:\/\//);
expect(resource).toContain('/mcp-server/http');
expect(authorization_servers).toHaveLength(1);
expect(authorization_servers[0]).toMatch(/^https?:\/\//);
});
test('should indicate Bearer token authentication via header', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
expect(response.body.bearer_methods_supported).toEqual(['header']);
});
test('should list supported scopes', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
expect(response.body.scopes_supported).toEqual(SUPPORTED_SCOPES);
expect(response.body.scopes_supported.length).toBeGreaterThan(0);
});
test('should be accessible without authentication', async () => {
const response = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(response.statusCode).toBe(200);
});
});
describe('POST /mcp-oauth/register', () => {
test('should register a new OAuth client with dynamic registration', async () => {
const clientData = {
client_name: 'Test MCP Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
};
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData);
expect(response.statusCode).toBe(201);
expect(response.body.client_id).toBeDefined();
expect(response.body.client_name).toBe('Test MCP Client');
expect(response.body.redirect_uris).toEqual(['https://example.com/callback']);
expect(response.body.grant_types).toEqual(['authorization_code', 'refresh_token']);
expect(response.body.token_endpoint_auth_method).toBe('none');
});
test('should generate unique client IDs for each registration', async () => {
const clientData = {
client_name: 'Test Client 1',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
};
const response1 = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData);
const response2 = await testServer.restlessAgent
.post('/mcp-oauth/register')
.send({ ...clientData, client_name: 'Test Client 2' });
expect(response1.statusCode).toBe(201);
expect(response2.statusCode).toBe(201);
expect(response1.body.client_id).toBeDefined();
expect(response2.body.client_id).toBeDefined();
expect(response1.body.client_id).not.toBe(response2.body.client_id);
});
test('should accept client registration without authentication', async () => {
const clientData = {
client_name: 'Public Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
};
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send(clientData);
expect(response.statusCode).toBe(201);
});
test('should validate required fields in client registration', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send({});
expect(response.statusCode).toBeGreaterThanOrEqual(400);
});
});
describe('GET /mcp-oauth/authorize', () => {
test('should require authentication for authorization endpoint', async () => {
const response = await testServer.restlessAgent.get('/mcp-oauth/authorize').query({
client_id: 'test-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge',
code_challenge_method: 'S256',
});
expect([302, 400, 401, 403]).toContain(response.statusCode);
});
test('should accept valid authorization request parameters', async () => {
const registerResponse = await testServer.restlessAgent.post('/mcp-oauth/register').send({
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
});
const clientId = registerResponse.body.client_id;
const response = await testServer.authAgentFor(owner).get('/mcp-oauth/authorize').query({
client_id: clientId,
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'test-challenge-string-must-be-long-enough-for-validation',
code_challenge_method: 'S256',
state: 'random-state',
});
expect(response.statusCode).toBeGreaterThanOrEqual(200);
});
});
describe('POST /mcp-oauth/token', () => {
test('should be accessible without authentication', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({
grant_type: 'authorization_code',
code: 'invalid-code',
client_id: 'test-client',
});
expect(response.statusCode).not.toBe(401);
});
test('should return error for invalid authorization code', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({
grant_type: 'authorization_code',
code: 'invalid-authorization-code',
client_id: 'test-client',
redirect_uri: 'https://example.com/callback',
code_verifier: 'test-verifier',
});
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body.error).toBeDefined();
});
test('should validate grant_type parameter', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/token').send({
grant_type: 'invalid_grant_type',
code: 'test-code',
client_id: 'test-client',
});
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.body.error).toBeDefined();
});
});
describe('POST /mcp-oauth/revoke', () => {
test('should be accessible without authentication', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({
token: 'test-token',
client_id: 'test-client',
});
expect(response.statusCode).not.toBe(401);
});
test('should accept token revocation request', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({
token: 'some-token-to-revoke',
client_id: 'test-client',
token_type_hint: 'access_token',
});
expect([200, 204, 400]).toContain(response.statusCode);
});
test('should handle revocation without token_type_hint', async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/revoke').send({
token: 'some-token',
client_id: 'test-client',
});
expect(response.statusCode).toBeGreaterThanOrEqual(200);
});
});
describe('OAuth Discovery - Cross-validation', () => {
test('should have consistent URLs between authorization server and protected resource metadata', async () => {
const authServerResponse = await testServer.restlessAgent.get(
'/.well-known/oauth-authorization-server',
);
const protectedResourceResponse = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(authServerResponse.statusCode).toBe(200);
expect(protectedResourceResponse.statusCode).toBe(200);
const authServer = authServerResponse.body;
const protectedResource = protectedResourceResponse.body;
expect(protectedResource.authorization_servers).toContain(authServer.issuer);
});
test('should have consistent scopes between authorization server and protected resource', async () => {
const authServerResponse = await testServer.restlessAgent.get(
'/.well-known/oauth-authorization-server',
);
const protectedResourceResponse = await testServer.restlessAgent.get(
'/.well-known/oauth-protected-resource/mcp-server/http',
);
expect(authServerResponse.statusCode).toBe(200);
expect(protectedResourceResponse.statusCode).toBe(200);
const authServerScopes = authServerResponse.body.scopes_supported;
const protectedResourceScopes = protectedResourceResponse.body.scopes_supported;
expect(authServerScopes).toEqual(protectedResourceScopes);
});
});

View File

@ -97,18 +97,16 @@ describe('search-workflows MCP tool', () => {
});
await searchWorkflows(user, workflowService as unknown as WorkflowService, {
limit: 500,
active: true,
name: 'foo',
query: 'foo',
projectId: 'proj-1',
});
const [_userArg, optionsArg] = (workflowService.getMany as jest.Mock).mock.calls[0];
expect(optionsArg.take).toBe(200); // clamped to MAX_RESULTS
expect(optionsArg.take).toBe(200);
expect(optionsArg.filter).toMatchObject({
isArchived: false,
availableInMCP: true,
active: true,
name: 'foo',
query: 'foo',
projectId: 'proj-1',
});
});

View File

@ -0,0 +1,28 @@
import { User } from '@n8n/db';
import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm';
import { OAuthClient } from './oauth-client.entity';
@Entity('oauth_access_tokens')
export class AccessToken {
@Column({ type: 'varchar', primary: true })
token: string;
@ManyToOne(
() => OAuthClient,
(client) => client.accessTokens,
{ onDelete: 'CASCADE' },
)
client: OAuthClient;
@Index()
@Column({ type: String })
clientId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Index()
@Column({ type: String })
userId: string;
}

View File

@ -0,0 +1,47 @@
import { User, WithTimestamps } from '@n8n/db';
import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm';
import { OAuthClient } from './oauth-client.entity';
@Entity('oauth_authorization_codes')
export class AuthorizationCode extends WithTimestamps {
@Column({ type: 'varchar', primary: true })
code: string;
@ManyToOne(
() => OAuthClient,
(client) => client.authorizationCodes,
{ onDelete: 'CASCADE' },
)
client: OAuthClient;
@Index()
@Column({ type: String })
clientId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Index()
@Column({ type: String })
userId: string;
@Column({ type: String })
redirectUri: string;
@Column({ type: String })
codeChallenge: string;
@Column({ type: String })
codeChallengeMethod: string;
@Column({ type: String, nullable: true })
state: string | null;
@Index()
@Column({ type: 'int' })
expiresAt: number;
@Column({ type: Boolean, default: false })
used: boolean;
}

View File

@ -0,0 +1,43 @@
import { JsonColumn, WithTimestamps } from '@n8n/db';
import { Column, Entity, OneToMany } from '@n8n/typeorm';
import type { AccessToken } from './oauth-access-token.entity';
import type { AuthorizationCode } from './oauth-authorization-code.entity';
import type { RefreshToken } from './oauth-refresh-token.entity';
import type { UserConsent } from './oauth-user-consent.entity';
@Entity('oauth_clients')
export class OAuthClient extends WithTimestamps {
@Column({ type: 'varchar', primary: true })
id: string;
@Column({ type: String })
name: string;
@JsonColumn()
redirectUris: string[];
@JsonColumn()
grantTypes: string[];
@Column({ type: String, default: 'none' })
tokenEndpointAuthMethod: string;
@OneToMany('AuthorizationCode', 'client')
authorizationCodes: AuthorizationCode[];
@OneToMany('AccessToken', 'client')
accessTokens: AccessToken[];
@OneToMany('RefreshToken', 'client')
refreshTokens: RefreshToken[];
@OneToMany('UserConsent', 'client')
userConsents: UserConsent[];
@Column({ type: String, nullable: true })
clientSecret: string | null;
@Column({ type: 'int', nullable: true })
clientSecretExpiresAt: number | null;
}

View File

@ -0,0 +1,32 @@
import { User, WithTimestamps } from '@n8n/db';
import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm';
import { OAuthClient } from './oauth-client.entity';
@Entity('oauth_refresh_tokens')
export class RefreshToken extends WithTimestamps {
@Column({ type: 'varchar', primary: true })
token: string;
@ManyToOne(
() => OAuthClient,
(client) => client.refreshTokens,
{ onDelete: 'CASCADE' },
)
client: OAuthClient;
@Index()
@Column({ type: String })
clientId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Index()
@Column({ type: String })
userId: string;
@Index()
@Column({ type: 'int' })
expiresAt: number;
}

View File

@ -0,0 +1,32 @@
import { User } from '@n8n/db';
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, Unique } from '@n8n/typeorm';
import { OAuthClient } from './oauth-client.entity';
@Entity('oauth_user_consents')
@Unique(['userId', 'clientId'])
export class UserConsent {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;
@Index()
@Column({ type: String })
userId: string;
@ManyToOne(
() => OAuthClient,
(client) => client.userConsents,
{ onDelete: 'CASCADE' },
)
client: OAuthClient;
@Index()
@Column({ type: String })
clientId: string;
@Column({ type: 'bigint' })
grantedAt: number;
}

View File

@ -0,0 +1,11 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { AccessToken } from '../entities/oauth-access-token.entity';
@Service()
export class AccessTokenRepository extends Repository<AccessToken> {
constructor(dataSource: DataSource) {
super(AccessToken, dataSource.manager);
}
}

View File

@ -0,0 +1,11 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { AuthorizationCode } from '../entities/oauth-authorization-code.entity';
@Service()
export class AuthorizationCodeRepository extends Repository<AuthorizationCode> {
constructor(dataSource: DataSource) {
super(AuthorizationCode, dataSource.manager);
}
}

View File

@ -0,0 +1,11 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { OAuthClient } from '../entities/oauth-client.entity';
@Service()
export class OAuthClientRepository extends Repository<OAuthClient> {
constructor(dataSource: DataSource) {
super(OAuthClient, dataSource.manager);
}
}

View File

@ -0,0 +1,11 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { RefreshToken } from '../entities/oauth-refresh-token.entity';
@Service()
export class RefreshTokenRepository extends Repository<RefreshToken> {
constructor(dataSource: DataSource) {
super(RefreshToken, dataSource.manager);
}
}

View File

@ -0,0 +1,22 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { UserConsent } from '../entities/oauth-user-consent.entity';
@Service()
export class UserConsentRepository extends Repository<UserConsent> {
constructor(dataSource: DataSource) {
super(UserConsent, dataSource.manager);
}
/**
* Find all consents for a user with client information
*/
async findByUserWithClient(userId: string): Promise<UserConsent[]> {
return await this.find({
where: { userId },
relations: ['client'],
order: { grantedAt: 'DESC' },
});
}
}

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ApproveConsentRequestDto extends Z.class({
approved: z.boolean(),
}) {}

View File

@ -1,16 +1,12 @@
import { ApiKey, ApiKeyRepository, AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import { ApiKey, ApiKeyRepository, User, UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { EntityManager } from '@n8n/typeorm';
import { randomUUID } from 'crypto';
import { NextFunction, Response, Request } from 'express';
import { ApiKeyAudience } from 'n8n-workflow';
import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants';
import { getClientInfo } from './mcp.utils';
import { AuthError } from '@/errors/response-errors/auth.error';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
const API_KEY_AUDIENCE: ApiKeyAudience = 'mcp-server-api';
const API_KEY_ISSUER = 'n8n';
@ -28,7 +24,7 @@ export class McpServerApiKeyService {
private readonly apiKeyRepository: ApiKeyRepository,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
private readonly telemetry: Telemetry,
private readonly accessTokenRepository: AccessTokenRepository,
) {}
async createMcpServerApiKey(user: User, trx?: EntityManager) {
@ -69,7 +65,7 @@ export class McpServerApiKeyService {
return apiKey;
}
private async getUserForApiKey(apiKey: string) {
async getUserForApiKey(apiKey: string) {
return await this.userRepository.findOne({
where: {
apiKeys: {
@ -81,6 +77,38 @@ export class McpServerApiKeyService {
});
}
async verifyApiKey(apiKey: string): Promise<User | null> {
try {
this.jwtService.verify(apiKey, {
issuer: API_KEY_ISSUER,
audience: API_KEY_AUDIENCE,
});
return await this.getUserForApiKey(apiKey);
} catch (error) {
return null;
}
}
async getUserForAccessToken(token: string) {
const accessToken = await this.accessTokenRepository.findOne({
where: {
token,
},
});
if (!accessToken) {
return null;
}
return await this.userRepository.findOne({
where: {
id: accessToken.userId,
},
relations: ['role'],
});
}
async deleteAllMcpApiKeysForUser(user: User, trx?: EntityManager) {
const manager = trx ?? this.apiKeyRepository.manager;
@ -103,71 +131,6 @@ export class McpServerApiKeyService {
return redactedPart + visiblePart;
}
private extractAPIKeyFromHeader(headerValue: string) {
if (!headerValue.startsWith('Bearer')) {
throw new AuthError('Invalid authorization header format');
}
const apiKeyMatch = headerValue.match(/^Bearer\s+(.+)$/i);
if (apiKeyMatch) {
return apiKeyMatch[1];
}
throw new AuthError('Invalid authorization header format');
}
getAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authorizationHeader = req.header('authorization');
if (!authorizationHeader) {
this.responseWithUnauthorized(res, req);
return;
}
const apiKey = this.extractAPIKeyFromHeader(authorizationHeader);
if (!apiKey) {
this.responseWithUnauthorized(res, req);
return;
}
const user = await this.getUserForApiKey(apiKey);
if (!user) {
this.responseWithUnauthorized(res, req);
return;
}
try {
this.jwtService.verify(apiKey, {
issuer: API_KEY_ISSUER,
audience: API_KEY_AUDIENCE,
});
} catch (e) {
this.responseWithUnauthorized(res, req);
return;
}
(req as AuthenticatedRequest).user = user;
next();
};
}
private responseWithUnauthorized(res: Response, req: Request) {
this.trackUnauthorizedEvent(req);
res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE });
}
private trackUnauthorizedEvent(req: Request) {
const clientInfo = getClientInfo(req);
this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, {
mcp_connection_status: 'error',
error: UNAUTHORIZED_ERROR_MESSAGE,
client_name: clientInfo?.name,
client_version: clientInfo?.version,
});
}
async getOrCreateApiKey(user: User) {
const apiKey = await this.apiKeyRepository.findOne({
where: {

View File

@ -0,0 +1,110 @@
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { randomBytes } from 'node:crypto';
import type { AuthorizationCode } from './database/entities/oauth-authorization-code.entity';
import { AuthorizationCodeRepository } from './database/repositories/oauth-authorization-code.repository';
/**
* Handles OAuth 2.1 authorization code lifecycle for MCP server
* Generates, validates, and consumes authorization codes with PKCE support
*/
@Service()
export class McpOAuthAuthorizationCodeService {
private readonly AUTHORIZATION_CODE_EXPIRY_MS = 10 * Time.minutes.toMilliseconds;
constructor(private readonly authorizationCodeRepository: AuthorizationCodeRepository) {}
/**
* Generate and save authorization code
* Returns the generated code string
*/
async createAuthorizationCode(
clientId: string,
userId: string,
redirectUri: string,
codeChallenge: string,
state: string | null,
): Promise<string> {
const code = randomBytes(32).toString('hex');
await this.authorizationCodeRepository.insert({
code,
clientId,
userId,
redirectUri,
codeChallenge,
codeChallengeMethod: 'S256',
state,
expiresAt: Date.now() + this.AUTHORIZATION_CODE_EXPIRY_MS,
used: false,
});
return code;
}
/**
* Find and validate authorization code (without consuming)
* Returns the auth record if valid, throws if invalid/expired
*/
async findAndValidateAuthorizationCode(
authorizationCode: string,
clientId: string,
): Promise<AuthorizationCode> {
const authRecord = await this.authorizationCodeRepository.findOne({
where: {
code: authorizationCode,
clientId,
},
});
if (!authRecord) {
throw new Error('Invalid authorization code');
}
if (authRecord.expiresAt < Date.now()) {
await this.authorizationCodeRepository.remove(authRecord);
throw new Error('Authorization code expired');
}
return authRecord;
}
/**
* Validate and consume authorization code
* Returns the auth record if valid, throws if invalid/expired/used
*/
async validateAndConsumeAuthorizationCode(
authorizationCode: string,
clientId: string,
redirectUri?: string,
): Promise<AuthorizationCode> {
const authRecord = await this.findAndValidateAuthorizationCode(authorizationCode, clientId);
if (redirectUri && authRecord.redirectUri !== redirectUri) {
throw new Error('Redirect URI mismatch');
}
const result = await this.authorizationCodeRepository.update(
{ code: authorizationCode, used: false },
{ used: true },
);
const numAffected = result.affected ?? 0;
if (numAffected < 1) {
throw new Error('Authorization code already used');
}
authRecord.used = true;
return authRecord;
}
/**
* Get PKCE code challenge for authorization code
* Used by MCP SDK for PKCE verification
*/
async getCodeChallenge(authorizationCode: string, clientId: string): Promise<string> {
const authRecord = await this.findAndValidateAuthorizationCode(authorizationCode, clientId);
return authRecord.codeChallenge;
}
}

View File

@ -0,0 +1,113 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { OAuthClientRepository } from './database/repositories/oauth-client.repository';
import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository';
import { McpOAuthAuthorizationCodeService } from './mcp-oauth-authorization-code.service';
import { McpOAuthHelpers } from './mcp-oauth.helpers';
import { OAuthSessionService, type OAuthSessionPayload } from './oauth-session.service';
/**
* Manages OAuth consent flow for MCP server
* Handles user authorization decisions and generates authorization codes
*/
@Service()
export class McpOAuthConsentService {
constructor(
private readonly logger: Logger,
private readonly oauthSessionService: OAuthSessionService,
private readonly oauthClientRepository: OAuthClientRepository,
private readonly userConsentRepository: UserConsentRepository,
private readonly authorizationCodeService: McpOAuthAuthorizationCodeService,
) {}
/**
* Get consent details from session cookie
* Verifies JWT session token and returns client information
*/
async getConsentDetails(sessionToken: string): Promise<{
clientName: string;
clientId: string;
} | null> {
try {
const sessionPayload = this.oauthSessionService.verifySession(sessionToken);
const client = await this.oauthClientRepository.findOne({
where: { id: sessionPayload.clientId },
});
if (!client) {
return null;
}
return {
clientName: client.name,
clientId: client.id,
};
} catch (error) {
this.logger.error('Error getting consent details', { error });
return null;
}
}
/**
* Handle consent approval/denial
* Uses JWT session token instead of database lookup
*/
async handleConsentDecision(
sessionToken: string,
userId: string,
approved: boolean,
): Promise<{ redirectUrl: string }> {
let sessionPayload: OAuthSessionPayload;
try {
sessionPayload = this.oauthSessionService.verifySession(sessionToken);
} catch (error) {
throw new UserError('Invalid or expired session');
}
if (!approved) {
const redirectUrl = McpOAuthHelpers.buildErrorRedirectUrl(
sessionPayload.redirectUri,
'access_denied',
'User denied the authorization request',
sessionPayload.state,
);
this.logger.info('Consent denied', {
clientId: sessionPayload.clientId,
userId,
});
return { redirectUrl };
}
await this.userConsentRepository.insert({
userId,
clientId: sessionPayload.clientId,
grantedAt: Date.now(),
});
const code = await this.authorizationCodeService.createAuthorizationCode(
sessionPayload.clientId,
userId,
sessionPayload.redirectUri,
sessionPayload.codeChallenge,
sessionPayload.state,
);
const successRedirectUrl = McpOAuthHelpers.buildSuccessRedirectUrl(
sessionPayload.redirectUri,
code,
sessionPayload.state,
);
this.logger.info('Consent approved', {
clientId: sessionPayload.clientId,
userId,
});
return { redirectUrl: successRedirectUrl };
}
}

View File

@ -0,0 +1,232 @@
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients';
import type {
AuthorizationParams,
OAuthServerProvider,
} from '@modelcontextprotocol/sdk/server/auth/provider';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import type {
OAuthClientInformationFull,
OAuthTokens,
OAuthTokenRevocationRequest,
} from '@modelcontextprotocol/sdk/shared/auth';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import type { Response } from 'express';
import { OAuthClient } from './database/entities/oauth-client.entity';
import { OAuthClientRepository } from './database/repositories/oauth-client.repository';
import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository';
import { McpOAuthAuthorizationCodeService } from './mcp-oauth-authorization-code.service';
import { McpOAuthTokenService } from './mcp-oauth-token.service';
import { OAuthSessionService } from './oauth-session.service';
export const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails'];
/**
* OAuth 2.1 server implementation for MCP
* Implements MCP SDK OAuthServerProvider interface for client registration, authorization, and token management
*/
@Service()
export class McpOAuthService implements OAuthServerProvider {
constructor(
private readonly logger: Logger,
private readonly oauthSessionService: OAuthSessionService,
private readonly oauthClientRepository: OAuthClientRepository,
private readonly tokenService: McpOAuthTokenService,
private readonly authorizationCodeService: McpOAuthAuthorizationCodeService,
private readonly userConsentRepository: UserConsentRepository,
) {}
get clientsStore(): OAuthRegisteredClientsStore {
return {
getClient: async (clientId: string): Promise<OAuthClientInformationFull | undefined> => {
const client = await this.oauthClientRepository.findOneBy({ id: clientId });
if (!client) {
return undefined;
}
return {
client_id: client.id,
client_name: client.name,
redirect_uris: client.redirectUris,
grant_types: client.grantTypes,
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
...(client.clientSecret && { client_secret: client.clientSecret }),
...(client.clientSecretExpiresAt && {
client_secret_expires_at: client.clientSecretExpiresAt,
}),
response_types: ['code'],
scope: SUPPORTED_SCOPES.join(' '),
};
},
registerClient: async (
client: OAuthClientInformationFull,
): Promise<OAuthClientInformationFull> => {
try {
await this.oauthClientRepository.insert({
id: client.client_id,
name: client.client_name,
redirectUris: client.redirect_uris,
grantTypes: client.grant_types,
clientSecret: client.client_secret ?? null,
clientSecretExpiresAt: client.client_secret_expires_at ?? null,
tokenEndpointAuthMethod: client.token_endpoint_auth_method ?? 'none',
});
} catch (error) {
this.logger.error('Error registering OAuth client', {
error,
clientId: client.client_id,
});
}
return client;
},
};
}
async authorize(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response,
): Promise<void> {
this.logger.debug('Starting OAuth authorization', { clientId: client.client_id });
try {
this.oauthSessionService.createSession(res, {
clientId: client.client_id,
redirectUri: params.redirectUri,
codeChallenge: params.codeChallenge,
state: params.state ?? null,
});
res.redirect('/oauth/consent');
} catch (error) {
this.logger.error('Error in authorize method', { error, clientId: client.client_id });
this.oauthSessionService.clearSession(res);
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
}
}
async challengeForAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
): Promise<string> {
return await this.authorizationCodeService.getCodeChallenge(
authorizationCode,
client.client_id,
);
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
_codeVerifier?: string,
redirectUri?: string,
): Promise<OAuthTokens> {
const authRecord = await this.authorizationCodeService.validateAndConsumeAuthorizationCode(
authorizationCode,
client.client_id,
redirectUri,
);
const { accessToken, refreshToken } = this.tokenService.generateTokenPair(
authRecord.userId,
client.client_id,
);
await this.tokenService.saveTokenPair(
accessToken,
refreshToken,
client.client_id,
authRecord.userId,
);
this.logger.info('Authorization code exchanged for tokens', {
clientId: client.client_id,
userId: authRecord.userId,
});
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
};
}
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
_scopes?: string[],
): Promise<OAuthTokens> {
return await this.tokenService.validateAndRotateRefreshToken(refreshToken, client.client_id);
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
return await this.tokenService.verifyAccessToken(token);
}
async revokeToken(
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest,
): Promise<void> {
const { token, token_type_hint } = request;
if (!token_type_hint || token_type_hint === 'access_token') {
const revoked = await this.tokenService.revokeAccessToken(token, client.client_id);
if (revoked) {
return;
}
}
if (!token_type_hint || token_type_hint === 'refresh_token') {
const revoked = await this.tokenService.revokeRefreshToken(token, client.client_id);
if (revoked) {
return;
}
}
this.logger.debug('Token revocation requested for unknown token', {
clientId: client.client_id,
});
}
/**
* Get all OAuth clients for a specific user (excluding sensitive data)
*/
async getAllClients(
userId: string,
): Promise<Array<Omit<OAuthClient, 'clientSecret' | 'clientSecretExpiresAt' | 'setUpdateDate'>>> {
// Get all consents for the user with client information
const userConsents = await this.userConsentRepository.findByUserWithClient(userId);
// Extract and sanitize the client information
return userConsents.map((consent) => {
const { clientSecret, clientSecretExpiresAt, ...sanitizedClient } = consent.client;
return sanitizedClient;
});
}
/**
* Delete an OAuth client and all related data
*/
async deleteClient(clientId: string): Promise<void> {
// First check if the client exists
const client = await this.oauthClientRepository.findOne({
where: { id: clientId },
});
if (!client) {
throw new Error(`OAuth client with ID ${clientId} not found`);
}
this.logger.info('Deleting OAuth client and related data', { clientId });
await this.oauthClientRepository.delete({ id: clientId });
this.logger.info('OAuth client deleted successfully', {
clientId,
clientName: client.name,
});
}
}

View File

@ -0,0 +1,215 @@
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import { User, UserRepository, withTransaction } from '@n8n/db';
import { Service } from '@n8n/di';
import { MoreThanOrEqual } from '@n8n/typeorm';
import { randomBytes, randomUUID } from 'node:crypto';
import { JwtService } from '@/services/jwt.service';
import { AccessToken } from './database/entities/oauth-access-token.entity';
import { RefreshToken } from './database/entities/oauth-refresh-token.entity';
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
/**
* Manages OAuth 2.1 token lifecycle for MCP server
* Generates, validates, rotates, and revokes access and refresh tokens
*/
@Service()
export class McpOAuthTokenService {
private readonly MCP_AUDIENCE = 'mcp-server-api';
private readonly ACCESS_TOKEN_EXPIRY_SECONDS = 1 * Time.hours.toSeconds;
private readonly REFRESH_TOKEN_EXPIRY_MS = 30 * Time.days.toMilliseconds;
constructor(
private readonly logger: Logger,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
private readonly accessTokenRepository: AccessTokenRepository,
private readonly refreshTokenRepository: RefreshTokenRepository,
) {}
generateTokenPair(
userId: string,
clientId: string,
): { accessToken: string; refreshToken: string } {
const accessToken = this.jwtService.sign({
sub: userId,
aud: this.MCP_AUDIENCE,
client_id: clientId,
jti: randomUUID(),
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + this.ACCESS_TOKEN_EXPIRY_SECONDS,
meta: {
isOAuth: true,
},
});
const refreshToken = randomBytes(32).toString('hex');
return { accessToken, refreshToken };
}
async saveTokenPair(
accessToken: string,
refreshToken: string,
clientId: string,
userId: string,
): Promise<void> {
await this.accessTokenRepository.manager.transaction(async (transactionManager) => {
await transactionManager.insert(this.accessTokenRepository.target, {
token: accessToken,
clientId,
userId,
});
await transactionManager.insert(this.refreshTokenRepository.target, {
token: refreshToken,
clientId,
userId,
expiresAt: Date.now() + this.REFRESH_TOKEN_EXPIRY_MS,
});
});
}
async validateAndRotateRefreshToken(
refreshToken: string,
clientId: string,
): Promise<OAuthTokens> {
return await withTransaction(this.refreshTokenRepository.manager, undefined, async (trx) => {
const now = Date.now();
const refreshTokenRecord = await trx.findOne(RefreshToken, {
where: {
token: refreshToken,
clientId,
},
});
if (!refreshTokenRecord) {
throw new Error('Invalid refresh token');
}
const result = await trx.delete(RefreshToken, {
token: refreshToken,
clientId,
expiresAt: MoreThanOrEqual(now),
});
const numAffected = result.affected ?? 0;
if (numAffected < 1) {
throw new Error('Invalid refresh token');
}
const { accessToken, refreshToken: newRefreshToken } = this.generateTokenPair(
refreshTokenRecord.userId,
clientId,
);
await trx.insert(AccessToken, {
token: accessToken,
clientId,
userId: refreshTokenRecord.userId,
});
await trx.insert(RefreshToken, {
token: newRefreshToken,
clientId,
userId: refreshTokenRecord.userId,
expiresAt: now + this.REFRESH_TOKEN_EXPIRY_MS,
});
this.logger.info('Refresh token rotated and new access token issued', {
clientId,
userId: refreshTokenRecord.userId,
});
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: this.ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token: newRefreshToken,
};
});
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
let decoded;
try {
decoded = this.jwtService.verify(token, { audience: this.MCP_AUDIENCE });
} catch (error) {
throw new Error('Invalid access token: JWT verification failed');
}
const accessTokenRecord = await this.accessTokenRepository.findOne({
where: { token },
});
if (!accessTokenRecord) {
throw new Error('Invalid access token: not found in database');
}
return {
token,
clientId: decoded.client_id,
scopes: [],
extra: {
userId: decoded.sub,
},
};
}
async verifyOAuthAccessToken(token: string): Promise<User | null> {
try {
const authInfo = await this.verifyAccessToken(token);
const userId = authInfo.extra?.userId as string;
if (!userId) {
return null;
}
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['role'],
});
return user;
} catch (error) {
return null;
}
}
async revokeAccessToken(token: string, clientId: string): Promise<boolean> {
const result = await this.accessTokenRepository.delete({
token,
clientId,
});
const revoked = (result.affected ?? 0) > 0;
if (revoked) {
this.logger.info('Access token revoked', { clientId });
}
return revoked;
}
async revokeRefreshToken(token: string, clientId: string): Promise<boolean> {
const result = await this.refreshTokenRepository.delete({
token,
clientId,
});
const revoked = (result.affected ?? 0) > 0;
if (revoked) {
this.logger.info('Refresh token revoked', { clientId });
}
return revoked;
}
}

View File

@ -0,0 +1,36 @@
/**
* Static utility functions for OAuth URL building
*/
export class McpOAuthHelpers {
/**
* Build success redirect URL with authorization code
* Used when user approves consent
*/
static buildSuccessRedirectUrl(redirectUri: string, code: string, state: string | null): string {
const targetUrl = new URL(redirectUri);
targetUrl.searchParams.set('code', code);
if (state) {
targetUrl.searchParams.set('state', state);
}
return targetUrl.toString();
}
/**
* Build error redirect URL
* Used when user denies consent or errors occur
*/
static buildErrorRedirectUrl(
redirectUri: string,
error: string,
errorDescription: string,
state: string | null,
): string {
const targetUrl = new URL(redirectUri);
targetUrl.searchParams.set('error', error);
targetUrl.searchParams.set('error_description', errorDescription);
if (state) {
targetUrl.searchParams.set('state', state);
}
return targetUrl.toString();
}
}

View File

@ -0,0 +1,109 @@
import { AuthenticatedRequest, User } from '@n8n/db';
import { Service } from '@n8n/di';
import { NextFunction, Response, Request } from 'express';
import { AuthError } from '@/errors/response-errors/auth.error';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { McpServerApiKeyService } from './mcp-api-key.service';
import { McpOAuthTokenService } from './mcp-oauth-token.service';
import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants';
import { getClientInfo } from './mcp.utils';
/**
* MCP Server Middleware Service
* Centralizes authentication for MCP server endpoints
* Supports both API key and OAuth token validation
*/
@Service()
export class McpServerMiddlewareService {
constructor(
private readonly mcpServerApiKeyService: McpServerApiKeyService,
private readonly mcpAuthTokenService: McpOAuthTokenService,
private readonly jwtService: JwtService,
private readonly telemetry: Telemetry,
) {}
/**
* Get user for a given token (API key or OAuth access token)
* Uses JWT metadata to determine token type and route to correct validation
*/
async getUserForToken(token: string): Promise<User | null> {
let decoded: { meta?: { isOAuth?: boolean } };
try {
decoded = this.jwtService.decode<{ meta?: { isOAuth?: boolean } }>(token);
} catch (error) {
return null;
}
if (decoded?.meta?.isOAuth === true) {
return await this.mcpAuthTokenService.verifyOAuthAccessToken(token);
}
return await this.mcpServerApiKeyService.verifyApiKey(token);
}
/**
* Express middleware for MCP server authentication
* Validates Bearer token (OAuth or API key) and attaches user to request
*/
getAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authorizationHeader = req.header('authorization');
if (!authorizationHeader) {
this.responseWithUnauthorized(res, req);
return;
}
const token = this.extractBearerToken(authorizationHeader);
if (!token) {
this.responseWithUnauthorized(res, req);
return;
}
const user = await this.getUserForToken(token);
if (!user) {
this.responseWithUnauthorized(res, req);
return;
}
(req as AuthenticatedRequest).user = user;
next();
};
}
private extractBearerToken(headerValue: string): string | null {
if (!headerValue.startsWith('Bearer')) {
throw new AuthError('Invalid authorization header format');
}
const tokenMatch = headerValue.match(/^Bearer\s+(.+)$/i);
if (tokenMatch) {
return tokenMatch[1];
}
throw new AuthError('Invalid authorization header format');
}
private responseWithUnauthorized(res: Response, req: Request) {
this.trackUnauthorizedEvent(req);
// RFC 6750 Section 3: Include WWW-Authenticate header for 401 responses
res.header('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE });
}
private trackUnauthorizedEvent(req: Request) {
const clientInfo = getClientInfo(req);
this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, {
mcp_connection_status: 'error',
error: UNAUTHORIZED_ERROR_MESSAGE,
client_name: clientInfo?.name,
client_version: clientInfo?.version,
});
}
}

Some files were not shown because too many files have changed in this diff Show More