first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
import { DYNAMIC_CRED_ENDPOINT_TOKEN } from '../../../services/dynamic-credential-api-helper';
|
||||
|
||||
/**
|
||||
* E2E tests for the dynamic credentials feature.
|
||||
*
|
||||
* Requires:
|
||||
* - capability: 'dynamic-credentials' (Keycloak container + env vars)
|
||||
* - api.enableFeature('dynamicCredentials') (license feature)
|
||||
*/
|
||||
test.use({
|
||||
capability: 'dynamic-credentials',
|
||||
ignoreHTTPSErrors: true, // Keycloak uses a self-signed certificate
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for the execution-status endpoint: external (marketplace) users
|
||||
* checking whether their credentials are configured for a given workflow.
|
||||
*
|
||||
* Architecture under test:
|
||||
* External user → GET /rest/workflows/:id/execution-status
|
||||
* → X-Authorization authenticates the request to n8n
|
||||
* → Bearer token extracted from Authorization header for credential context
|
||||
* → Token validated against Keycloak (userinfo endpoint)
|
||||
* → Credential status returned (missing / configured)
|
||||
*/
|
||||
test.describe(
|
||||
'Dynamic Credentials: execution-status @capability:dynamic-credentials',
|
||||
{
|
||||
annotation: [{ type: 'owner', description: 'Identity & Access' }],
|
||||
},
|
||||
() => {
|
||||
/**
|
||||
* Happy path: external user calls execution-status with a valid Keycloak bearer token.
|
||||
* The credential is not yet authorized for that user → status should be "missing"
|
||||
* and an authorizationUrl should be provided to start the OAuth2 flow.
|
||||
*/
|
||||
test('should report credentials as missing for a new external user @auth:owner', async ({
|
||||
api,
|
||||
services,
|
||||
}) => {
|
||||
const keycloak = services.keycloak;
|
||||
|
||||
// Create an OAuth2 resolver that validates tokens via Keycloak's userinfo endpoint.
|
||||
// Uses the internal URL so the n8n container can reach Keycloak directly.
|
||||
const resolver = await api.dynamicCredentials.createResolver({
|
||||
name: `Keycloak Resolver ${nanoid()}`,
|
||||
type: 'credential-resolver.oauth2-1.0',
|
||||
config: {
|
||||
metadataUri: keycloak.internalDiscoveryUrl,
|
||||
validation: 'oauth2-userinfo',
|
||||
},
|
||||
});
|
||||
|
||||
// Create an OAuth2 credential flagged as resolvable (no static data needed)
|
||||
const credential = await api.credentials.createCredential({
|
||||
name: `Resolvable OAuth2 Credential ${nanoid()}`,
|
||||
type: 'oAuth2Api',
|
||||
data: { grantType: 'authorizationCode' },
|
||||
isResolvable: true,
|
||||
});
|
||||
|
||||
// Create a workflow that uses that credential, with the resolver as workflow-level fallback
|
||||
const workflow = await api.workflows.createWorkflow({
|
||||
name: `Dynamic Credential Workflow ${nanoid()}`,
|
||||
nodes: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
credentials: {
|
||||
oAuth2Api: { id: credential.id, name: credential.name },
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
settings: {
|
||||
// Workflow-level resolver used as fallback for all resolvable credentials
|
||||
credentialResolverId: resolver.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Obtain a real access token for the Keycloak test user via ROPC (no browser needed)
|
||||
const accessToken = await keycloak.getAccessToken(
|
||||
keycloak.testUser.email,
|
||||
keycloak.testUser.password,
|
||||
);
|
||||
|
||||
// External (unauthenticated) call:
|
||||
// - X-Authorization authenticates the request to n8n
|
||||
// - Authorization: Bearer provides the user identity for credential resolution
|
||||
const status = await api.dynamicCredentials.getExecutionStatus(workflow.id, {
|
||||
bearerToken: accessToken,
|
||||
endpointToken: DYNAMIC_CRED_ENDPOINT_TOKEN,
|
||||
});
|
||||
|
||||
expect(status.workflowId).toBe(workflow.id);
|
||||
expect(status.readyToExecute).toBe(false);
|
||||
expect(status.credentials).toHaveLength(1);
|
||||
|
||||
const credentialStatus = status.credentials![0];
|
||||
expect(credentialStatus.credentialId).toBe(credential.id);
|
||||
expect(credentialStatus.credentialStatus).toBe('missing');
|
||||
expect(credentialStatus.credentialType).toBe('oAuth2Api');
|
||||
|
||||
// authorizationUrl must be present so the user can start the OAuth2 authorization flow
|
||||
expect(credentialStatus.authorizationUrl).toBeTruthy();
|
||||
expect(credentialStatus.authorizationUrl).toContain(credential.id);
|
||||
expect(credentialStatus.authorizationUrl).toContain('authorize');
|
||||
|
||||
// revokeUrl must also be present
|
||||
expect(credentialStatus.revokeUrl).toBeTruthy();
|
||||
expect(credentialStatus.revokeUrl).toContain(credential.id);
|
||||
expect(credentialStatus.revokeUrl).toContain('revoke');
|
||||
});
|
||||
|
||||
/**
|
||||
* Happy path: external user has already completed the OAuth2 authorization flow.
|
||||
* The credential is stored in dynamic_credential_entry for this user →
|
||||
* readyToExecute should be true and credentialStatus should be "configured".
|
||||
*/
|
||||
test('should report ready when workflow has resolvable credentials with existing entries for user @auth:owner', async ({
|
||||
api,
|
||||
services,
|
||||
}) => {
|
||||
const keycloak = services.keycloak;
|
||||
|
||||
const externalBase = keycloak.discoveryUrl.replace('/.well-known/openid-configuration', '');
|
||||
const internalBase = keycloak.internalDiscoveryUrl.replace(
|
||||
'/.well-known/openid-configuration',
|
||||
'',
|
||||
);
|
||||
|
||||
// Obtain a Keycloak access token for the test user (ROPC — no browser needed)
|
||||
const accessToken = await keycloak.getAccessToken(
|
||||
keycloak.testUser.email,
|
||||
keycloak.testUser.password,
|
||||
);
|
||||
|
||||
// Create an OAuth2 resolver that validates tokens via Keycloak's userinfo endpoint
|
||||
const resolver = await api.dynamicCredentials.createResolver({
|
||||
name: `Keycloak Resolver ${nanoid()}`,
|
||||
type: 'credential-resolver.oauth2-1.0',
|
||||
config: {
|
||||
metadataUri: keycloak.internalDiscoveryUrl,
|
||||
validation: 'oauth2-userinfo',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a properly-configured oAuth2Api credential pointing at Keycloak.
|
||||
// The credential is resolvable — its tokens are stored per-user by the resolver.
|
||||
const credential = await api.credentials.createCredential({
|
||||
name: `Keycloak OAuth2 Credential ${nanoid()}`,
|
||||
type: 'oAuth2Api',
|
||||
data: {
|
||||
grantType: 'authorizationCode',
|
||||
authUrl: `${externalBase}/protocol/openid-connect/auth`,
|
||||
accessTokenUrl: `${internalBase}/protocol/openid-connect/token`,
|
||||
clientId: keycloak.clientId,
|
||||
clientSecret: keycloak.clientSecret,
|
||||
scope: 'openid',
|
||||
ignoreSSLIssues: true,
|
||||
},
|
||||
isResolvable: true,
|
||||
});
|
||||
|
||||
// Create a workflow that uses that credential
|
||||
const workflow = await api.workflows.createWorkflow({
|
||||
name: `Configured Credential Workflow ${nanoid()}`,
|
||||
nodes: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
credentials: {
|
||||
oAuth2Api: { id: credential.id, name: credential.name },
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
settings: {
|
||||
credentialResolverId: resolver.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Complete the OAuth2 authorization code flow for the test user.
|
||||
// This stores the user's Keycloak tokens in the dynamic_credential_entry table.
|
||||
const keycloakAuthUrl = await api.dynamicCredentials.getAuthorizationUrl(
|
||||
credential.id,
|
||||
resolver.id,
|
||||
accessToken,
|
||||
);
|
||||
const n8nCallbackUrl = await keycloak.completeAuthorizationCodeFlow(keycloakAuthUrl);
|
||||
// GET the n8n callback with the owner session: n8n exchanges the code and stores tokens
|
||||
await api.request.get(n8nCallbackUrl);
|
||||
|
||||
// Credential is now configured for this user → readyToExecute should be true
|
||||
const status = await api.dynamicCredentials.getExecutionStatus(workflow.id, {
|
||||
bearerToken: accessToken,
|
||||
endpointToken: DYNAMIC_CRED_ENDPOINT_TOKEN,
|
||||
});
|
||||
|
||||
expect(status.workflowId).toBe(workflow.id);
|
||||
expect(status.readyToExecute).toBe(true);
|
||||
expect(status.credentials).toHaveLength(1);
|
||||
expect(status.credentials![0].credentialStatus).toBe('configured');
|
||||
});
|
||||
},
|
||||
);
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
import { DYNAMIC_CRED_ENDPOINT_TOKEN } from '../../../services/dynamic-credential-api-helper';
|
||||
|
||||
/**
|
||||
* E2E tests for the dynamic credentials feature.
|
||||
*
|
||||
* Requires:
|
||||
* - capability: 'dynamic-credentials' (Keycloak container + env vars)
|
||||
* - api.enableFeature('dynamicCredentials') (license feature)
|
||||
*/
|
||||
test.use({
|
||||
capability: 'dynamic-credentials',
|
||||
ignoreHTTPSErrors: true, // Keycloak uses a self-signed certificate
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration test: external user triggers a workflow via a production webhook.
|
||||
* The resolvable oAuth2Api credential is pre-authorized via the Keycloak authorization
|
||||
* code flow, then the HTTP Request node uses it to call the Keycloak userinfo endpoint.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Create OAuth2 resolver + resolvable oAuth2Api credential (configured for Keycloak)
|
||||
* 2. Build the workflow (webhook + HTTP Request using the credential) — not yet active
|
||||
* 3. Get Keycloak access token (ROPC — identifies the external user)
|
||||
* 4. Call execution-status → credential reports "missing" → extract authorizationUrl
|
||||
* 5. POST to authorizationUrl → Keycloak login page → complete authorization code flow
|
||||
* 6. n8n callback stores user's tokens in dynamic_credential_entry
|
||||
* 7. Verify execution-status now reports credential as "configured"
|
||||
* 8. Activate the workflow (webhook + HTTP Request node using the credential)
|
||||
* 9. Trigger the production webhook with the bearer token
|
||||
* 10. Wait for execution and assert success (HTTP node resolved credential + called userinfo)
|
||||
*/
|
||||
test.describe(
|
||||
'Dynamic Credentials: webhook execution @capability:dynamic-credentials',
|
||||
{
|
||||
annotation: [{ type: 'owner', description: 'Identity & Access' }],
|
||||
},
|
||||
() => {
|
||||
test('should execute HTTP node with resolvable OAuth2 credential via production webhook @auth:owner', async ({
|
||||
api,
|
||||
services,
|
||||
}) => {
|
||||
const keycloak = services.keycloak;
|
||||
|
||||
// Derive Keycloak endpoint URLs from the discovery URL.
|
||||
// authUrl: EXTERNAL URL — the test machine visits this for the authorization redirect.
|
||||
// accessTokenUrl: INTERNAL URL — n8n exchanges the auth code server-to-server.
|
||||
const externalBase = keycloak.discoveryUrl.replace('/.well-known/openid-configuration', '');
|
||||
const internalBase = keycloak.internalDiscoveryUrl.replace(
|
||||
'/.well-known/openid-configuration',
|
||||
'',
|
||||
);
|
||||
|
||||
// Create an OAuth2 resolver that validates tokens via Keycloak's userinfo endpoint
|
||||
const resolver = await api.dynamicCredentials.createResolver({
|
||||
name: `Keycloak OAuth2 Resolver ${nanoid()}`,
|
||||
type: 'credential-resolver.oauth2-1.0',
|
||||
config: {
|
||||
metadataUri: keycloak.internalDiscoveryUrl,
|
||||
validation: 'oauth2-userinfo',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a properly-configured oAuth2Api credential pointing at Keycloak.
|
||||
// The credential is resolvable — its tokens are stored per-user by the resolver.
|
||||
const credential = await api.credentials.createCredential({
|
||||
name: `Keycloak OAuth2 Credential ${nanoid()}`,
|
||||
type: 'oAuth2Api',
|
||||
data: {
|
||||
grantType: 'authorizationCode',
|
||||
authUrl: `${externalBase}/protocol/openid-connect/auth`,
|
||||
accessTokenUrl: `${internalBase}/protocol/openid-connect/token`,
|
||||
clientId: keycloak.clientId,
|
||||
clientSecret: keycloak.clientSecret,
|
||||
scope: 'openid',
|
||||
ignoreSSLIssues: true,
|
||||
},
|
||||
isResolvable: true,
|
||||
});
|
||||
|
||||
// Build a workflow: webhook trigger → HTTP Request (calls Keycloak userinfo with credential)
|
||||
// The workflow is created BEFORE authorization so we can obtain the authorizationUrl
|
||||
// from the execution-status endpoint (the real flow a marketplace user would follow).
|
||||
const { workflowId, webhookPath, createdWorkflow } =
|
||||
await api.workflows.createWorkflowFromDefinition({
|
||||
name: `Dynamic Credential HTTP Webhook Workflow ${nanoid()}`,
|
||||
nodes: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'placeholder',
|
||||
responseMode: 'onReceived', // Respond immediately; execution runs async
|
||||
// Configure the execution context hook to extract the bearer token
|
||||
// from the Authorization header. Without this, the dynamic credential
|
||||
// resolver can't identify the user during execution.
|
||||
executionsHooksVersion: 1,
|
||||
contextEstablishmentHooks: {
|
||||
hooks: [
|
||||
{
|
||||
hookName: 'BearerTokenExtractor',
|
||||
isAllowedToFail: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {
|
||||
// Keycloak userinfo endpoint — accepts Bearer tokens and returns user info (200)
|
||||
url: `${internalBase}/protocol/openid-connect/userinfo`,
|
||||
authentication: 'predefinedCredentialType',
|
||||
nodeCredentialType: 'oAuth2Api',
|
||||
},
|
||||
credentials: {
|
||||
oAuth2Api: { id: credential.id, name: credential.name },
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
credentialResolverId: resolver.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Obtain a Keycloak access token for the test user (ROPC — no browser needed).
|
||||
// This token is used as the user identity throughout the flow.
|
||||
const accessToken = await keycloak.getAccessToken(
|
||||
keycloak.testUser.email,
|
||||
keycloak.testUser.password,
|
||||
);
|
||||
|
||||
// Step 1: Check execution-status before authorization.
|
||||
// The credential is not yet configured → status is "missing".
|
||||
// The response includes an authorizationUrl pointing to the n8n authorize endpoint.
|
||||
const initialStatus = await api.dynamicCredentials.getExecutionStatus(workflowId, {
|
||||
bearerToken: accessToken,
|
||||
endpointToken: DYNAMIC_CRED_ENDPOINT_TOKEN,
|
||||
});
|
||||
expect(initialStatus.credentials).toHaveLength(1);
|
||||
expect(initialStatus.credentials![0].credentialStatus).toBe('missing');
|
||||
|
||||
// Step 2: Use the authorizationUrl from execution-status to start the OAuth2 flow.
|
||||
// This is the URL a real marketplace user would follow after seeing "missing" status.
|
||||
const n8nAuthorizeUrl = initialStatus.credentials![0].authorizationUrl!;
|
||||
expect(n8nAuthorizeUrl).toBeTruthy();
|
||||
|
||||
// POST to the n8n authorize endpoint → returns the Keycloak authorization page URL
|
||||
const keycloakAuthUrl = await api.dynamicCredentials.startAuthorizationFromStatusUrl(
|
||||
n8nAuthorizeUrl,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
// Step 3: Complete the Keycloak authorization code flow for the test user.
|
||||
// Navigates Keycloak's login form and returns the n8n callback URL (with code + state).
|
||||
const n8nCallbackUrl = await keycloak.completeAuthorizationCodeFlow(keycloakAuthUrl);
|
||||
// GET the n8n callback with the owner session: n8n exchanges the code and stores tokens
|
||||
await api.request.get(n8nCallbackUrl);
|
||||
|
||||
// Activate the workflow to register the production webhook URL
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId as string);
|
||||
|
||||
try {
|
||||
// Verify the credential is now "configured" for this user before triggering
|
||||
const status = await api.dynamicCredentials.getExecutionStatus(workflowId, {
|
||||
bearerToken: accessToken,
|
||||
endpointToken: DYNAMIC_CRED_ENDPOINT_TOKEN,
|
||||
});
|
||||
expect(status.credentials).toHaveLength(1);
|
||||
expect(status.credentials![0].credentialStatus).toBe('configured');
|
||||
|
||||
// Trigger the production webhook with the bearer token.
|
||||
// n8n extracts the token from the Authorization header for credential resolution.
|
||||
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath!}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
maxNotFoundRetries: 5,
|
||||
});
|
||||
expect(webhookResponse.status()).toBe(200);
|
||||
|
||||
// Wait for the async execution to complete.
|
||||
// The HTTP Request node resolves the credential → injects Bearer token → calls Keycloak userinfo → 200
|
||||
const execution = await api.workflows.waitForExecution(workflowId, 15000);
|
||||
expect((execution as unknown as { status: string }).status).toBe('success');
|
||||
} finally {
|
||||
// Deactivate to prevent orphaned active webhooks after the test
|
||||
await api.workflows.deactivate(workflowId);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user