mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 07:17:04 +02:00
218 lines
7.7 KiB
TypeScript
218 lines
7.7 KiB
TypeScript
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 @licensed',
|
|
{
|
|
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');
|
|
});
|
|
},
|
|
);
|