mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(core): Verify the trigger identity once and bind it to the execution (#36599)
This commit is contained in:
@@ -50,6 +50,7 @@ export interface ICredentialResolver {
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<ICredentialDataDecryptedObject>;
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,7 @@ export interface ICredentialResolver {
|
||||
context: ICredentialContext,
|
||||
data: ICredentialDataDecryptedObject,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -72,6 +74,7 @@ export interface ICredentialResolver {
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -94,7 +97,11 @@ export interface ICredentialResolver {
|
||||
* @param context - The identity of the entity to validate access for
|
||||
* @throws {CredentialResolverAccessValidationError} When access is invalid
|
||||
*/
|
||||
validateIdentity?(context: ICredentialContext, handle: CredentialResolverHandle): Promise<void>;
|
||||
validateIdentity?(
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns the n8n user id the resolved credentials belong to, when this
|
||||
@@ -109,6 +116,7 @@ export interface ICredentialResolver {
|
||||
resolveOwningUserId?(
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -250,7 +250,11 @@ export class McpTrigger extends Node {
|
||||
if (authResult === 'handled') {
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
await context.establishTriggerIdentity(authResult.token, authResult.resource);
|
||||
await context.establishTriggerIdentity(
|
||||
authResult.token,
|
||||
authResult.resource,
|
||||
authResult.user.id,
|
||||
);
|
||||
authedUser = authResult.user;
|
||||
} else {
|
||||
try {
|
||||
|
||||
@@ -1298,6 +1298,7 @@ describe('CredentialsHelper', () => {
|
||||
{ apiKey: 'static-key' },
|
||||
mockAdditionalData.executionContext,
|
||||
mockAdditionalData.workflowSettings,
|
||||
undefined, // executeData
|
||||
);
|
||||
expect(result).toEqual(resolvedData);
|
||||
});
|
||||
|
||||
@@ -562,6 +562,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||
decryptedDataOriginal,
|
||||
additionalData.executionContext,
|
||||
additionalData.workflowSettings,
|
||||
additionalData.executionId,
|
||||
);
|
||||
decryptedDataOriginal = resolveResult.data;
|
||||
if (resolveResult.isDynamic) {
|
||||
|
||||
@@ -89,6 +89,7 @@ describe('DynamicCredentialsProxy', () => {
|
||||
staticData,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -120,6 +121,7 @@ describe('DynamicCredentialsProxy', () => {
|
||||
staticData,
|
||||
executionContext,
|
||||
workflowSettings,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ICredentialResolutionProvider {
|
||||
staticData: ICredentialDataDecryptedObject,
|
||||
executionContext?: IExecutionContext,
|
||||
workflowSettings?: IWorkflowSettings,
|
||||
executionId?: string,
|
||||
): Promise<CredentialResolutionResult>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,7 @@ export class DynamicCredentialsProxy
|
||||
staticData: ICredentialDataDecryptedObject,
|
||||
executionContext?: IExecutionContext,
|
||||
workflowSettings?: IWorkflowSettings,
|
||||
executionId?: string,
|
||||
): Promise<CredentialResolutionResult> {
|
||||
if (!this.resolvingProvider) {
|
||||
if (credentialsResolveMetadata.isResolvable) {
|
||||
@@ -81,6 +82,7 @@ export class DynamicCredentialsProxy
|
||||
staticData,
|
||||
executionContext,
|
||||
workflowSettings,
|
||||
executionId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -126,7 +126,7 @@ describe('N8NCredentialResolver', () => {
|
||||
await resolver.getSecret(credentialId, context, handle);
|
||||
|
||||
// Verify N8NIdentifier was called with correct context
|
||||
expect(mockIdentifier.resolve).toHaveBeenCalledWith(context, {});
|
||||
expect(mockIdentifier.resolve).toHaveBeenCalledWith(context, {}, undefined);
|
||||
// Verify storage was queried with resolved user ID
|
||||
expect(mockStorage.getCredentialData).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
@@ -188,7 +188,7 @@ describe('N8NCredentialResolver', () => {
|
||||
await resolver.setSecret(credentialId, context, data, handle);
|
||||
|
||||
// Verify user ID was resolved first
|
||||
expect(mockIdentifier.resolve).toHaveBeenCalledWith(context, {});
|
||||
expect(mockIdentifier.resolve).toHaveBeenCalledWith(context, {}, undefined);
|
||||
// Verify storage uses resolved user ID
|
||||
expect(mockStorage.setCredentialData).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
@@ -210,7 +210,7 @@ describe('N8NCredentialResolver', () => {
|
||||
|
||||
await resolver.deleteSecret(credentialId, context, handle);
|
||||
|
||||
expect(mockIdentifier.resolve).toHaveBeenCalledWith(context, {});
|
||||
expect(mockIdentifier.resolve).toHaveBeenCalledWith(context, {}, undefined);
|
||||
expect(mockStorage.deleteCredentialData).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
'user-to-delete-456',
|
||||
|
||||
+123
-2
@@ -1,5 +1,5 @@
|
||||
import type { Mocked } from 'vitest';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { User, UserRepository } from '@n8n/db';
|
||||
import { CredentialResolverError } from '@n8n/decorators';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
@@ -18,14 +18,18 @@ describe('N8NIdentifier', () => {
|
||||
let identifier: N8NIdentifier;
|
||||
let mockAuthService: Mocked<AuthService>;
|
||||
let mockOAuthVerifier: Mocked<OAuthTokenVerifierProxy>;
|
||||
let mockUserRepository: Mocked<UserRepository>;
|
||||
|
||||
const mockUser = mock<User>({ id: 'user-123' });
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthService = mock<AuthService>();
|
||||
mockOAuthVerifier = mock<OAuthTokenVerifierProxy>();
|
||||
mockOAuthVerifier.authorizeSealedGrant.mockResolvedValue(true);
|
||||
mockUserRepository = mock<UserRepository>();
|
||||
mockUserRepository.findOneBy.mockResolvedValue(mock<User>({ id: 'user-123', disabled: false }));
|
||||
|
||||
identifier = new N8NIdentifier(mockAuthService, mockOAuthVerifier);
|
||||
identifier = new N8NIdentifier(mockAuthService, mockOAuthVerifier, mockUserRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -415,6 +419,123 @@ describe('N8NIdentifier', () => {
|
||||
await expect(identifier.resolve(context, {})).rejects.toThrow(CredentialResolverError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('n8n-oauth branch — sealed (subject present)', () => {
|
||||
const sealedContext = (metaOverrides: Record<string, unknown> = {}) => ({
|
||||
identity: 'oauth-access-token',
|
||||
version: 1 as const,
|
||||
metadata: {
|
||||
source: 'n8n-oauth' as const,
|
||||
resource: 'https://host/mcp/wf',
|
||||
subject: 'user-123',
|
||||
establishedAt: 1,
|
||||
executionPath: ['exec-root'],
|
||||
...metaOverrides,
|
||||
},
|
||||
});
|
||||
|
||||
it('returns the subject when the execution is in the path, without verifying the token', async () => {
|
||||
const result = await identifier.resolve(sealedContext(), {}, 'exec-root');
|
||||
|
||||
expect(result).toBe('user-123');
|
||||
expect(mockOAuthVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a sub-workflow execution present in the inherited path', async () => {
|
||||
const result = await identifier.resolve(
|
||||
sealedContext({ executionPath: ['exec-root', 'exec-child'] }),
|
||||
{},
|
||||
'exec-child',
|
||||
);
|
||||
|
||||
expect(result).toBe('user-123');
|
||||
});
|
||||
|
||||
it('rejects a seal used in a different execution of the same workflow', async () => {
|
||||
await expect(identifier.resolve(sealedContext(), {}, 'exec-other')).rejects.toThrow(
|
||||
CredentialResolverError,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when a bound seal reaches resolution with no execution id', async () => {
|
||||
await expect(identifier.resolve(sealedContext(), {}, undefined)).rejects.toThrow(
|
||||
CredentialResolverError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when the sealed principal is disabled', async () => {
|
||||
mockUserRepository.findOneBy.mockResolvedValue(
|
||||
mock<User>({ id: 'user-123', disabled: true }),
|
||||
);
|
||||
|
||||
await expect(identifier.resolve(sealedContext(), {}, 'exec-root')).rejects.toThrow(
|
||||
CredentialResolverError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when the sealed principal no longer exists', async () => {
|
||||
mockUserRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
await expect(identifier.resolve(sealedContext(), {}, 'exec-root')).rejects.toThrow(
|
||||
CredentialResolverError,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves however long after establishment (no token, no TTL)', async () => {
|
||||
const result = await identifier.resolve(
|
||||
sealedContext({ establishedAt: 0 }),
|
||||
{},
|
||||
'exec-root',
|
||||
);
|
||||
|
||||
expect(result).toBe('user-123');
|
||||
expect(mockOAuthVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the subject ungated for an unbound seal (empty path, pre-execution probe)', async () => {
|
||||
const result = await identifier.resolve(
|
||||
sealedContext({ executionPath: [] }),
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result).toBe('user-123');
|
||||
expect(mockOAuthVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unbound seal (empty path) when resolving inside an execution', async () => {
|
||||
await expect(
|
||||
identifier.resolve(sealedContext({ executionPath: [] }), {}, 'exec-root'),
|
||||
).rejects.toThrow(CredentialResolverError);
|
||||
});
|
||||
|
||||
it('re-takes the sealed grant and returns the subject when still authorized', async () => {
|
||||
const grant = { audiences: ['https://host/mcp/wf'], executeAccessWorkflowId: 'wf' };
|
||||
|
||||
const result = await identifier.resolve(sealedContext({ grant }), {}, 'exec-root');
|
||||
|
||||
expect(result).toBe('user-123');
|
||||
expect(mockOAuthVerifier.authorizeSealedGrant).toHaveBeenCalledWith('user-123', grant);
|
||||
expect(mockOAuthVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when the sealed grant is no longer authorized', async () => {
|
||||
mockOAuthVerifier.authorizeSealedGrant.mockResolvedValue(false);
|
||||
const grant = { audiences: ['https://host/mcp/wf'], executeAccessWorkflowId: 'wf' };
|
||||
|
||||
await expect(identifier.resolve(sealedContext({ grant }), {}, 'exec-root')).rejects.toThrow(
|
||||
CredentialResolverError,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the grant re-take for a grant-less seal and checks the principal locally', async () => {
|
||||
const result = await identifier.resolve(sealedContext(), {}, 'exec-root');
|
||||
|
||||
expect(result).toBe('user-123');
|
||||
expect(mockOAuthVerifier.authorizeSealedGrant).not.toHaveBeenCalled();
|
||||
expect(mockUserRepository.findOneBy).toHaveBeenCalledWith({ id: 'user-123' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+6
-1
@@ -22,10 +22,15 @@ export interface ITokenIdentifier {
|
||||
*
|
||||
* @param context - Credential context with execution details
|
||||
* @param identifierOptions - Implementation-specific options
|
||||
* @param executionId - Optional execution ID for context
|
||||
* @returns Unique identifier string
|
||||
* @throws {IdentifierValidationError} When validation or resolution fails
|
||||
*/
|
||||
resolve(context: ICredentialContext, identifierOptions: Record<string, unknown>): Promise<string>;
|
||||
resolve(
|
||||
context: ICredentialContext,
|
||||
identifierOptions: Record<string, unknown>,
|
||||
executionId?: string,
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* Validates identifier options before use
|
||||
|
||||
+63
-3
@@ -5,6 +5,7 @@ import { AuthService } from '@/auth/auth.service';
|
||||
import { z } from 'zod';
|
||||
import { CredentialResolverError } from '@n8n/decorators';
|
||||
import { OAuthTokenVerifierProxy } from '@/services/oauth-token-verifier-proxy.service';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
|
||||
/**
|
||||
* The `source` values this identifier accepts, declared once so the schemas below and
|
||||
@@ -40,6 +41,14 @@ const N8nOAuthMetadataSchema = z.object({
|
||||
resource: z.string(),
|
||||
/** Absent for contexts sealed before grants existed, and for long-lived resources. */
|
||||
grant: OAuthResourceGrantSchema.optional(),
|
||||
/**
|
||||
* The resolved n8n user, sealed at establishment. When present, resolution trusts it
|
||||
* (bound to `executionPath`, principal re-checked) instead of re-verifying the token.
|
||||
* Absent on legacy / grant-only carriers, which fall back to token verification.
|
||||
*/
|
||||
subject: z.string().optional(),
|
||||
establishedAt: z.number().optional(),
|
||||
executionPath: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/** Exported for the drift test that keeps {@link N8N_IDENTITY_SOURCES} in step with it. */
|
||||
@@ -94,13 +103,18 @@ export class N8NIdentifier implements ITokenIdentifier {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly oauthTokenVerifierProxy: OAuthTokenVerifierProxy,
|
||||
private readonly userRepository: UserRepository,
|
||||
) {}
|
||||
|
||||
async validateOptions(_: Record<string, unknown>): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async resolve(context: ICredentialContext, _: Record<string, unknown>): Promise<string> {
|
||||
async resolve(
|
||||
context: ICredentialContext,
|
||||
_: Record<string, unknown>,
|
||||
executionId?: string,
|
||||
): Promise<string> {
|
||||
const metadataResult = N8NIdentifierMetadataSchema.safeParse(context.metadata);
|
||||
if (!metadataResult.success) {
|
||||
throw new CredentialResolverError(
|
||||
@@ -115,8 +129,54 @@ export class N8NIdentifier implements ITokenIdentifier {
|
||||
}
|
||||
|
||||
if (metadataResult.data.source === 'n8n-oauth') {
|
||||
// Looked up afresh on every access, so by now the resource may be gone — the
|
||||
// sealed grant carries what the gate needs in that case.
|
||||
// Sealed identity: trust the resolved user, bound to its execution, without
|
||||
// re-verifying the token (so it resolves past the token's TTL, e.g. after a Wait).
|
||||
if (metadataResult.data.subject) {
|
||||
const userId = metadataResult.data.subject;
|
||||
|
||||
const executionPath = metadataResult.data.executionPath ?? [];
|
||||
if (executionId) {
|
||||
// Resolving inside an execution: the seal must be bound to it (or a sub-workflow
|
||||
// of it). An unbound seal (empty path) is not valid once an execution exists.
|
||||
if (!executionPath.includes(executionId)) {
|
||||
throw new CredentialResolverError('Sealed identity is not valid for this execution');
|
||||
}
|
||||
} else if (executionPath.length > 0) {
|
||||
// Bound seal but no execution to check it against → fail closed.
|
||||
throw new CredentialResolverError('Sealed identity is not valid for this execution');
|
||||
}
|
||||
|
||||
const grant = metadataResult.data.grant;
|
||||
if (grant) {
|
||||
// Re-take the grant's live workflow:execute decision (token-independent) so a
|
||||
// principal whose access was revoked after sealing stops resolving. This also
|
||||
// covers existence/disabled — a denied user resolves to `false`.
|
||||
const authorized = await this.oauthTokenVerifierProxy.authorizeSealedGrant(userId, grant);
|
||||
if (!authorized) {
|
||||
throw new CredentialResolverError(
|
||||
`Invalid OAuth token for resource ${metadataResult.data.resource}`,
|
||||
);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
// Grant-less sealed carrier (legacy): no execute gate to re-take, so confirm the
|
||||
// principal still exists and is enabled. Keep "missing" and "disabled"
|
||||
// indistinguishable to the caller (no account enumeration) and never surface the
|
||||
// internal user id in the error.
|
||||
const user = await this.userRepository.findOneBy({ id: userId });
|
||||
if (!user || user.disabled) {
|
||||
throw new CredentialResolverError(
|
||||
`Invalid OAuth token for resource ${metadataResult.data.resource}`,
|
||||
);
|
||||
}
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
// No sealed subject: verify the token. The grant is looked up afresh on every
|
||||
// access, so by now the resource may be gone — the sealed grant carries what the
|
||||
// gate needs in that case.
|
||||
const user = await this.oauthTokenVerifierProxy.verifyOAuthAccessToken(
|
||||
context.identity,
|
||||
metadataResult.data.resource,
|
||||
|
||||
+12
-6
@@ -41,8 +41,9 @@ export class N8NCredentialResolver implements ICredentialResolver {
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
const key = await this.resolveIdentifier(context, handle.configuration);
|
||||
const key = await this.resolveIdentifier(context, handle.configuration, executionId);
|
||||
|
||||
const data = await this.storage.getCredentialData(
|
||||
credentialId,
|
||||
@@ -70,8 +71,9 @@ export class N8NCredentialResolver implements ICredentialResolver {
|
||||
context: ICredentialContext,
|
||||
data: ICredentialDataDecryptedObject,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<void> {
|
||||
const key = await this.resolveIdentifier(context, handle.configuration);
|
||||
const key = await this.resolveIdentifier(context, handle.configuration, executionId);
|
||||
|
||||
const encryptedData = await this.cipher.encryptV2(data);
|
||||
|
||||
@@ -89,8 +91,9 @@ export class N8NCredentialResolver implements ICredentialResolver {
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<void> {
|
||||
const key = await this.resolveIdentifier(context, handle.configuration);
|
||||
const key = await this.resolveIdentifier(context, handle.configuration, executionId);
|
||||
await this.storage.deleteCredentialData(
|
||||
credentialId,
|
||||
key,
|
||||
@@ -110,8 +113,9 @@ export class N8NCredentialResolver implements ICredentialResolver {
|
||||
private async resolveIdentifier(
|
||||
context: ICredentialContext,
|
||||
configuration: CredentialResolverConfiguration,
|
||||
executionId?: string,
|
||||
): Promise<string> {
|
||||
return await this.n8nIdentifier.resolve(context, configuration);
|
||||
return await this.n8nIdentifier.resolve(context, configuration, executionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,14 +125,16 @@ export class N8NCredentialResolver implements ICredentialResolver {
|
||||
async resolveOwningUserId(
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<string> {
|
||||
return await this.resolveIdentifier(context, handle.configuration);
|
||||
return await this.resolveIdentifier(context, handle.configuration, executionId);
|
||||
}
|
||||
|
||||
async validateIdentity(
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
executionId?: string,
|
||||
): Promise<void> {
|
||||
await this.resolveIdentifier(context, handle.configuration);
|
||||
await this.resolveIdentifier(context, handle.configuration, executionId);
|
||||
}
|
||||
}
|
||||
|
||||
+70
-39
@@ -693,13 +693,18 @@ describe('DynamicCredentialService', () => {
|
||||
apiKey: 'dynamic-key', // From dynamic (overridden)
|
||||
refreshToken: 'dynamic-refresh-token', // From dynamic (new field)
|
||||
});
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith('cred-123', credentialContext, {
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {
|
||||
prefix: 'test',
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith(
|
||||
'cred-123',
|
||||
credentialContext,
|
||||
{
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {
|
||||
prefix: 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Successfully resolved dynamic credentials',
|
||||
expect.objectContaining({
|
||||
@@ -738,11 +743,16 @@ describe('DynamicCredentialService', () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith('cred-123', credentialContext, {
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: customConfig,
|
||||
});
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith(
|
||||
'cred-123',
|
||||
credentialContext,
|
||||
{
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: customConfig,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('credential context with metadata is properly decrypted', async () => {
|
||||
@@ -777,6 +787,7 @@ describe('DynamicCredentialService', () => {
|
||||
'cred-123',
|
||||
credentialContext,
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Successfully resolved dynamic credentials',
|
||||
@@ -854,11 +865,16 @@ describe('DynamicCredentialService', () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith('cred-123', credentialContext, {
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {},
|
||||
});
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith(
|
||||
'cred-123',
|
||||
credentialContext,
|
||||
{
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1022,14 +1038,19 @@ describe('DynamicCredentialService', () => {
|
||||
);
|
||||
|
||||
// Verify the resolver was called with resolved config
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith('cred-123', credentialContext, {
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {
|
||||
apiKey: 'secret-api-key-123', // $vars expression resolved
|
||||
prefix: 'cred',
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith(
|
||||
'cred-123',
|
||||
credentialContext,
|
||||
{
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {
|
||||
apiKey: 'secret-api-key-123', // $vars expression resolved
|
||||
prefix: 'cred',
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
delete (global as any).testVars;
|
||||
@@ -1115,16 +1136,21 @@ describe('DynamicCredentialService', () => {
|
||||
);
|
||||
|
||||
// Verify only global expressions were resolved, runtime expressions remain as-is
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith('cred-123', credentialContext, {
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {
|
||||
prefix: '={{$execution.id}}', // NOT resolved (runtime data)
|
||||
envValue: 'env-value', // Resolved (global data)
|
||||
mode: '={{$execution.mode}}', // NOT resolved (runtime data)
|
||||
staticValue: 'no-expression',
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith(
|
||||
'cred-123',
|
||||
credentialContext,
|
||||
{
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {
|
||||
prefix: '={{$execution.id}}', // NOT resolved (runtime data)
|
||||
envValue: 'env-value', // Resolved (global data)
|
||||
mode: '={{$execution.mode}}', // NOT resolved (runtime data)
|
||||
staticValue: 'no-expression',
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
delete (global as any).testVars;
|
||||
@@ -1159,14 +1185,19 @@ describe('DynamicCredentialService', () => {
|
||||
);
|
||||
|
||||
// Verify config passed as-is (expression not resolved)
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith('cred-123', credentialContext, {
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {
|
||||
prefix: 'cred',
|
||||
executionId: '={{$execution.id}}', // Expression NOT resolved
|
||||
expect(mockResolver.getSecret).toHaveBeenCalledWith(
|
||||
'cred-123',
|
||||
credentialContext,
|
||||
{
|
||||
resolverId: resolverEntity.id,
|
||||
resolverName: resolverEntity.type,
|
||||
configuration: {
|
||||
prefix: 'cred',
|
||||
executionId: '={{$execution.id}}', // Expression NOT resolved
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+7
-1
@@ -66,6 +66,7 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
staticData: ICredentialDataDecryptedObject,
|
||||
executionContext?: IExecutionContext,
|
||||
workflowSettings?: IWorkflowSettings,
|
||||
executionId?: string,
|
||||
): Promise<CredentialResolutionResult> {
|
||||
// Determine which resolver ID to use: credential's own resolver or workflow's fallback
|
||||
// (explicit workflow override, or the seeded system resolver looked up via the proxy).
|
||||
@@ -144,6 +145,7 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
credentialsResolveMetadata.id,
|
||||
credentialContext,
|
||||
handle,
|
||||
executionId,
|
||||
);
|
||||
|
||||
this.logger.debug('Successfully resolved dynamic credentials', {
|
||||
@@ -166,7 +168,11 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
// execution simply stays unattributed (redacted for everyone).
|
||||
let resolvedUserId: string | undefined;
|
||||
try {
|
||||
resolvedUserId = await resolver.resolveOwningUserId?.(credentialContext, handle);
|
||||
resolvedUserId = await resolver.resolveOwningUserId?.(
|
||||
credentialContext,
|
||||
handle,
|
||||
executionId,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.debug('Could not resolve owning user for dynamic credentials', {
|
||||
credentialId: credentialsResolveMetadata.id,
|
||||
|
||||
@@ -480,6 +480,48 @@ describe('OAuthTokenService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeSealedGrant', () => {
|
||||
const grant = { audiences: ['https://host/mcp/wf'], executeAccessWorkflowId: 'wf' };
|
||||
|
||||
it('returns false when the user no longer exists', async () => {
|
||||
userRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
expect(await service.authorizeSealedGrant('user-123', grant)).toBe(false);
|
||||
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when the user is disabled', async () => {
|
||||
userRepository.findOne.mockResolvedValue(mock<User>({ id: 'user-123', disabled: true }));
|
||||
|
||||
expect(await service.authorizeSealedGrant('user-123', grant)).toBe(false);
|
||||
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grants when the user still holds workflow:execute on the bound workflow', async () => {
|
||||
const user = mock<User>({ id: 'user-123', disabled: false });
|
||||
userRepository.findOne.mockResolvedValue(user);
|
||||
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set(['wf']));
|
||||
|
||||
expect(await service.authorizeSealedGrant('user-123', grant)).toBe(true);
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'user-123' },
|
||||
relations: ['role'],
|
||||
});
|
||||
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).toHaveBeenCalledWith(
|
||||
['wf'],
|
||||
user,
|
||||
['workflow:execute'],
|
||||
);
|
||||
});
|
||||
|
||||
it('denies when the user no longer holds workflow:execute on the bound workflow', async () => {
|
||||
userRepository.findOne.mockResolvedValue(mock<User>({ id: 'user-123', disabled: false }));
|
||||
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set());
|
||||
|
||||
expect(await service.authorizeSealedGrant('user-123', grant)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyOAuthAccessToken audience resolution', () => {
|
||||
it('should deny when a resource-scoped audience cannot be resolved', async () => {
|
||||
// Fail closed: the token carries an audience but no resource resolves for
|
||||
|
||||
@@ -313,6 +313,17 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-take a sealed grant's decision for a user, without the token. The sealed-identity
|
||||
* credential path calls this on every resolve, so a revoked `workflow:execute` stops
|
||||
* resolution mid-run. Loads the user with its role (a bare id lookup carries no scopes).
|
||||
*/
|
||||
async authorizeSealedGrant(userId: string, grant: OAuthResourceGrant): Promise<boolean> {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId }, relations: ['role'] });
|
||||
if (!user || user.disabled) return false;
|
||||
return await authorizeAgainstGrant(this.workflowFinderService, grant, user);
|
||||
}
|
||||
|
||||
/** Deletes every access and refresh token a user holds for a client. */
|
||||
async revokeAllTokensForGrant(clientId: string, userId: string): Promise<void> {
|
||||
await Promise.all([
|
||||
|
||||
@@ -51,4 +51,24 @@ describe('OAuthTokenVerifierProxy', () => {
|
||||
grant,
|
||||
);
|
||||
});
|
||||
|
||||
describe('authorizeSealedGrant', () => {
|
||||
const grant = { audiences: ['https://n8n.example.com/x'], executeAccessWorkflowId: 'wf' };
|
||||
|
||||
it('should fail closed when no provider is registered', async () => {
|
||||
const proxy = new OAuthTokenVerifierProxy();
|
||||
|
||||
expect(await proxy.authorizeSealedGrant('user-1', grant)).toBe(false);
|
||||
});
|
||||
|
||||
it('should delegate to the registered provider', async () => {
|
||||
const proxy = new OAuthTokenVerifierProxy();
|
||||
const provider = mock<OAuthTokenVerifier>();
|
||||
provider.authorizeSealedGrant.mockResolvedValue(true);
|
||||
proxy.registerProvider(provider);
|
||||
|
||||
expect(await proxy.authorizeSealedGrant('user-1', grant)).toBe(true);
|
||||
expect(provider.authorizeSealedGrant).toHaveBeenCalledWith('user-1', grant);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,14 @@ export interface OAuthTokenVerifier {
|
||||
expectedAudience?: string,
|
||||
grant?: OAuthResourceGrant,
|
||||
): Promise<UserWithContext>;
|
||||
|
||||
/**
|
||||
* Re-take a sealed grant's `workflow:execute` decision for `userId`, without the token.
|
||||
* Used by the sealed-identity credential path to keep the check live: a caller whose
|
||||
* execute access was revoked after the identity was sealed must stop resolving. Returns
|
||||
* `false` when the user is gone/disabled or lacks the access the grant requires.
|
||||
*/
|
||||
authorizeSealedGrant(userId: string, grant: OAuthResourceGrant): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,4 +100,12 @@ export class OAuthTokenVerifierProxy implements OAuthTokenVerifier {
|
||||
}
|
||||
return await this.provider.verifyOAuthAccessToken(token, expectedAudience, grant);
|
||||
}
|
||||
|
||||
async authorizeSealedGrant(userId: string, grant: OAuthResourceGrant): Promise<boolean> {
|
||||
// Fail closed: with no verifier registered there is nothing to re-authorize against.
|
||||
if (!this.provider) {
|
||||
return false;
|
||||
}
|
||||
return await this.provider.authorizeSealedGrant(userId, grant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1317,6 +1317,9 @@ describe('executeWebhook establishTriggerIdentity', () => {
|
||||
workflowRunner.run.mockResolvedValue(EXECUTION_ID);
|
||||
activeExecutions.getPostExecutePromise.mockReturnValue(new Promise(() => {}));
|
||||
executionContextService.buildTriggerIdentityCredentials.mockResolvedValue('sealed-context');
|
||||
// `establishExecutionContext` binds the execution id onto the sealed context; with no
|
||||
// execution id yet (or no sealed subject) it hands the context straight back.
|
||||
executionContextService.maybeBindExecutionId.mockImplementation(async (context) => context);
|
||||
// `establishExecutionContext` runs the hook pass over the seeded stack.
|
||||
executionContextService.augmentExecutionContextWithHooks.mockImplementation(
|
||||
async (_workflow, _startItem, context) => ({ context, triggerItems: null }),
|
||||
@@ -1391,6 +1394,7 @@ describe('executeWebhook establishTriggerIdentity', () => {
|
||||
'caller-token',
|
||||
RESOURCE_URL,
|
||||
GRANT,
|
||||
undefined,
|
||||
);
|
||||
expect(additionalData.encryptedRunnerIdentity).toBe('sealed-context');
|
||||
});
|
||||
@@ -1413,6 +1417,7 @@ describe('executeWebhook establishTriggerIdentity', () => {
|
||||
'caller-token',
|
||||
RESOURCE_URL,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1423,6 +1428,7 @@ describe('executeWebhook establishTriggerIdentity', () => {
|
||||
'caller-token',
|
||||
RESOURCE_URL,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -623,7 +623,11 @@ export async function executeWebhook(
|
||||
};
|
||||
};
|
||||
|
||||
additionalData.establishTriggerIdentity = async (token: string, resource: string) => {
|
||||
additionalData.establishTriggerIdentity = async (
|
||||
token: string,
|
||||
resource: string,
|
||||
subject?: string,
|
||||
) => {
|
||||
// The run re-verifies this token after the trigger stops listening, so it carries
|
||||
// the gate with it. Fall back to a lookup for callers that establish an identity
|
||||
// without going through `validateN8nOAuth2Token`.
|
||||
@@ -642,7 +646,7 @@ export async function executeWebhook(
|
||||
|
||||
additionalData.encryptedRunnerIdentity = await Container.get(
|
||||
ExecutionContextService,
|
||||
).buildTriggerIdentityCredentials(token, resource, grant);
|
||||
).buildTriggerIdentityCredentials(token, resource, grant, subject);
|
||||
if (runExecutionData) {
|
||||
await establishExecutionContext(workflow, runExecutionData, additionalData, executionMode);
|
||||
}
|
||||
|
||||
@@ -373,18 +373,26 @@ describe('ExecutionContextService', () => {
|
||||
});
|
||||
|
||||
describe('buildTriggerIdentityCredentials()', () => {
|
||||
it('should encrypt the credential context with the token as identity and resource in metadata', async () => {
|
||||
it('keeps the token as identity and seals the subject + establishedAt in metadata', async () => {
|
||||
mockCipher.encryptV2.mockResolvedValue('encrypted-credential-blob');
|
||||
|
||||
const result = await service.buildTriggerIdentityCredentials(
|
||||
'oauth-token-jwt',
|
||||
'https://api.example.com/resource',
|
||||
undefined,
|
||||
'user-123',
|
||||
);
|
||||
|
||||
expect(mockCipher.encryptV2).toHaveBeenCalledWith({
|
||||
version: 1,
|
||||
identity: 'oauth-token-jwt',
|
||||
metadata: { source: 'n8n-oauth', resource: 'https://api.example.com/resource' },
|
||||
metadata: {
|
||||
source: 'n8n-oauth',
|
||||
subject: 'user-123',
|
||||
resource: 'https://api.example.com/resource',
|
||||
establishedAt: expect.any(Number),
|
||||
executionPath: [],
|
||||
},
|
||||
});
|
||||
expect(result).toBe('encrypted-credential-blob');
|
||||
});
|
||||
@@ -409,17 +417,159 @@ describe('ExecutionContextService', () => {
|
||||
metadata: {
|
||||
source: 'n8n-oauth',
|
||||
resource: 'https://api.example.com/resource',
|
||||
establishedAt: expect.any(Number),
|
||||
executionPath: [],
|
||||
grant,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the subject and grant when neither is provided (legacy token-only carrier)', async () => {
|
||||
mockCipher.encryptV2.mockResolvedValue('encrypted-credential-blob');
|
||||
|
||||
await service.buildTriggerIdentityCredentials('oauth-token-jwt', 'https://api/r');
|
||||
|
||||
expect(mockCipher.encryptV2).toHaveBeenCalledWith({
|
||||
version: 1,
|
||||
identity: 'oauth-token-jwt',
|
||||
metadata: {
|
||||
source: 'n8n-oauth',
|
||||
resource: 'https://api/r',
|
||||
establishedAt: expect.any(Number),
|
||||
executionPath: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate errors raised by the cipher', async () => {
|
||||
mockCipher.encryptV2.mockRejectedValue(new Error('encryption key missing'));
|
||||
|
||||
await expect(service.buildTriggerIdentityCredentials('token', 'resource')).rejects.toThrow(
|
||||
'encryption key missing',
|
||||
await expect(
|
||||
service.buildTriggerIdentityCredentials('token', 'resource', undefined, 'user-123'),
|
||||
).rejects.toThrow('encryption key missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeBindExecutionId()', () => {
|
||||
beforeEach(() => {
|
||||
// Symmetric fakes: decrypt is identity, encrypt is JSON.stringify, so we can
|
||||
// read the re-encrypted credentials back with JSON.parse.
|
||||
mockCipher.decryptV2.mockImplementation(async (data: string) => data);
|
||||
mockCipher.encryptV2.mockImplementation(async (data: unknown) => JSON.stringify(data));
|
||||
toCredentialContext.mockImplementation((data: string) => JSON.parse(data));
|
||||
});
|
||||
|
||||
const contextWith = (metadata: Record<string, unknown>): IExecutionContext => ({
|
||||
version: 1,
|
||||
establishedAt: 1,
|
||||
source: 'webhook',
|
||||
credentials: JSON.stringify({ version: 1, identity: 'oauth-token', metadata }),
|
||||
});
|
||||
|
||||
const pathOf = (context: IExecutionContext) =>
|
||||
JSON.parse(context.credentials as string).metadata.executionPath;
|
||||
|
||||
it('stamps the current execution id onto a freshly sealed carrier', async () => {
|
||||
const bound = await service.maybeBindExecutionId(
|
||||
contextWith({ source: 'n8n-oauth', resource: 'r', subject: 'user-123' }),
|
||||
'exec-root',
|
||||
);
|
||||
|
||||
expect(pathOf(bound)).toEqual(['exec-root']);
|
||||
});
|
||||
|
||||
it('appends a child execution id, preserving the inherited path', async () => {
|
||||
const bound = await service.maybeBindExecutionId(
|
||||
contextWith({
|
||||
source: 'n8n-oauth',
|
||||
resource: 'r',
|
||||
subject: 'user-123',
|
||||
executionPath: ['exec-root'],
|
||||
}),
|
||||
'exec-child',
|
||||
{ allowInherit: true },
|
||||
);
|
||||
|
||||
expect(pathOf(bound)).toEqual(['exec-root', 'exec-child']);
|
||||
});
|
||||
|
||||
it('leaves a populated path untouched for an unrelated execution', async () => {
|
||||
const context = contextWith({
|
||||
source: 'n8n-oauth',
|
||||
resource: 'r',
|
||||
subject: 'user-123',
|
||||
executionPath: ['exec-other'],
|
||||
});
|
||||
|
||||
const bound = await service.maybeBindExecutionId(context, 'exec-current');
|
||||
|
||||
expect(bound).toBe(context);
|
||||
expect(mockCipher.encryptV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is idempotent when the execution id is already in the path (retry/resume)', async () => {
|
||||
const context = contextWith({
|
||||
source: 'n8n-oauth',
|
||||
resource: 'r',
|
||||
subject: 'user-123',
|
||||
executionPath: ['exec-root'],
|
||||
});
|
||||
|
||||
const bound = await service.maybeBindExecutionId(context, 'exec-root');
|
||||
|
||||
expect(bound).toBe(context);
|
||||
expect(mockCipher.encryptV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op for a non-sealed carrier (no subject)', async () => {
|
||||
const context = contextWith({ source: 'n8n-oauth', resource: 'r' });
|
||||
|
||||
const bound = await service.maybeBindExecutionId(context, 'exec-root');
|
||||
|
||||
expect(bound).toBe(context);
|
||||
expect(mockCipher.encryptV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when no execution id is provided', async () => {
|
||||
const context = contextWith({ source: 'n8n-oauth', resource: 'r', subject: 'user-123' });
|
||||
|
||||
const bound = await service.maybeBindExecutionId(context, undefined);
|
||||
|
||||
expect(bound).toBe(context);
|
||||
expect(mockCipher.decryptV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op for a non-n8n-oauth carrier (schema mismatch)', async () => {
|
||||
const context = contextWith({ source: 'manual-execution' });
|
||||
|
||||
const bound = await service.maybeBindExecutionId(context, 'exec-root');
|
||||
|
||||
expect(bound).toBe(context);
|
||||
expect(mockCipher.encryptV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op for a carrier without metadata', async () => {
|
||||
const context: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: 1,
|
||||
source: 'webhook',
|
||||
credentials: JSON.stringify({ version: 1, identity: 'oauth-token' }),
|
||||
};
|
||||
|
||||
const bound = await service.maybeBindExecutionId(context, 'exec-root');
|
||||
|
||||
expect(bound).toBe(context);
|
||||
expect(mockCipher.encryptV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op for a context without credentials', async () => {
|
||||
const context: IExecutionContext = { version: 1, establishedAt: 1, source: 'webhook' };
|
||||
|
||||
const bound = await service.maybeBindExecutionId(context, 'exec-root');
|
||||
|
||||
expect(bound).toBe(context);
|
||||
expect(mockCipher.decryptV2).not.toHaveBeenCalled();
|
||||
expect(mockCipher.encryptV2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import {
|
||||
createEmptyRunExecutionData,
|
||||
createRunExecutionData,
|
||||
UnexpectedError,
|
||||
type IExecutionContext,
|
||||
type IN8NOAuthMetadata,
|
||||
type INode,
|
||||
type IWorkflowExecuteAdditionalData,
|
||||
type RelatedExecution,
|
||||
@@ -12,7 +14,10 @@ import {
|
||||
} from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { Cipher } from '@/encryption';
|
||||
|
||||
import { establishExecutionContext } from '../execution-context';
|
||||
import type { ExecutionContextHookRegistry } from '../execution-context-hook-registry.service';
|
||||
import { ExecutionContextService } from '../execution-context.service';
|
||||
|
||||
describe('establishExecutionContext', () => {
|
||||
@@ -21,6 +26,9 @@ describe('establishExecutionContext', () => {
|
||||
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
|
||||
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
|
||||
encryptedRunnerIdentity: undefined,
|
||||
// No executionId at establishment time; the real (unmocked) maybeBindExecutionId
|
||||
// is then a no-op and never tries to decrypt these tests' fake credentials.
|
||||
executionId: undefined,
|
||||
});
|
||||
const mockMode: WorkflowExecuteMode = 'manual';
|
||||
|
||||
@@ -769,6 +777,11 @@ describe('establishExecutionContext', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecutionContextService = mock<ExecutionContextService>();
|
||||
// maybeBindExecutionId runs on every branch; pass through so this describe only
|
||||
// asserts sub-execution augmentation (binding is covered in its own tests).
|
||||
mockExecutionContextService.maybeBindExecutionId.mockImplementation(
|
||||
async (context) => context,
|
||||
);
|
||||
Container.set(ExecutionContextService, mockExecutionContextService);
|
||||
});
|
||||
|
||||
@@ -1223,6 +1236,11 @@ describe('establishExecutionContext', () => {
|
||||
triggerItems: null,
|
||||
}),
|
||||
);
|
||||
// maybeBindExecutionId runs on every branch; pass through so these tests assert
|
||||
// only the manual-injection branch (binding is covered in its own tests).
|
||||
mockExecutionContextService.maybeBindExecutionId.mockImplementation(
|
||||
async (context) => context,
|
||||
);
|
||||
Container.set(ExecutionContextService, mockExecutionContextService);
|
||||
});
|
||||
|
||||
@@ -1303,4 +1321,178 @@ describe('establishExecutionContext', () => {
|
||||
expect(runExecutionData.executionData!.runtimeData).toEqual(existingContext);
|
||||
});
|
||||
});
|
||||
|
||||
// End-to-end binding through the REAL service (with a symmetric fake cipher so the
|
||||
// carrier round-trips): proves that a sealed carrier established before the real
|
||||
// executionId existed actually gets bound to it, so credential resolution can gate
|
||||
// on executionPath. Without the bind-on-early-return fix, executionPath stays empty.
|
||||
describe('execution id binding (real service)', () => {
|
||||
// Symmetric fake: encrypt is JSON.stringify, decrypt is identity — the real
|
||||
// toCredentialContext then parses/validates the JSON back into a context.
|
||||
const fakeCipher = {
|
||||
encryptV2: async (data: unknown) => JSON.stringify(data),
|
||||
decryptV2: async (data: string) => data,
|
||||
} as unknown as Cipher;
|
||||
|
||||
let service: ExecutionContextService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ExecutionContextService(
|
||||
mock<Logger>(),
|
||||
mock<ExecutionContextHookRegistry>(),
|
||||
fakeCipher,
|
||||
);
|
||||
Container.set(ExecutionContextService, service);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Container.reset();
|
||||
});
|
||||
|
||||
const pathOf = async (context: IExecutionContext) => {
|
||||
const decrypted = await service.decryptExecutionContext(context);
|
||||
return (decrypted.credentials!.metadata as IN8NOAuthMetadata).executionPath;
|
||||
};
|
||||
|
||||
it('binds the real execution id onto a seal established before it existed', async () => {
|
||||
const sealed = await service.buildTriggerIdentityCredentials(
|
||||
'oauth-token',
|
||||
'https://host/mcp/wf',
|
||||
undefined,
|
||||
'user-123',
|
||||
);
|
||||
const runExecutionData = createRunExecutionData({
|
||||
startData: {},
|
||||
resultData: { runData: {} },
|
||||
executionData: {
|
||||
contextData: {},
|
||||
nodeExecutionStack: [],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
});
|
||||
// Pre-established carrier, as the webhook mint leaves it (unbound, path []).
|
||||
runExecutionData.executionData!.runtimeData = {
|
||||
version: 1,
|
||||
establishedAt: 1,
|
||||
source: 'webhook',
|
||||
credentials: sealed,
|
||||
};
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: 'exec-real',
|
||||
encryptedRunnerIdentity: undefined,
|
||||
});
|
||||
|
||||
await establishExecutionContext(mockWorkflow, runExecutionData, additionalData, 'webhook');
|
||||
|
||||
expect(await pathOf(runExecutionData.executionData!.runtimeData)).toEqual(['exec-real']);
|
||||
});
|
||||
|
||||
it('does not extend a populated path when the carrier belongs to another execution', async () => {
|
||||
const sealed = await service.buildTriggerIdentityCredentials(
|
||||
'oauth-token',
|
||||
'https://host/mcp/wf',
|
||||
undefined,
|
||||
'user-123',
|
||||
);
|
||||
const runExecutionData = createRunExecutionData({
|
||||
startData: {},
|
||||
resultData: { runData: {} },
|
||||
executionData: {
|
||||
contextData: {},
|
||||
nodeExecutionStack: [],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
});
|
||||
// Carrier already bound to a different execution, re-attached to this run.
|
||||
const carried = await service.maybeBindExecutionId(
|
||||
{ version: 1, establishedAt: 1, source: 'webhook', credentials: sealed },
|
||||
'exec-other',
|
||||
);
|
||||
runExecutionData.executionData!.runtimeData = carried;
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: 'exec-real',
|
||||
encryptedRunnerIdentity: undefined,
|
||||
});
|
||||
|
||||
await establishExecutionContext(mockWorkflow, runExecutionData, additionalData, 'webhook');
|
||||
|
||||
// Path stays pinned to its own execution, so resolution rejects this run.
|
||||
expect(await pathOf(runExecutionData.executionData!.runtimeData)).toEqual(['exec-other']);
|
||||
});
|
||||
|
||||
it('appends the retry execution id, keeping the carrier resolvable on retry', async () => {
|
||||
const sealed = await service.buildTriggerIdentityCredentials(
|
||||
'oauth-token',
|
||||
'https://host/mcp/wf',
|
||||
undefined,
|
||||
'user-123',
|
||||
);
|
||||
const runExecutionData = createRunExecutionData({
|
||||
startData: {},
|
||||
resultData: { runData: {} },
|
||||
executionData: {
|
||||
contextData: {},
|
||||
nodeExecutionStack: [],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
});
|
||||
// Original run's carrier, bound to its own execution, reloaded verbatim for the retry.
|
||||
const carried = await service.maybeBindExecutionId(
|
||||
{ version: 1, establishedAt: 1, source: 'webhook', credentials: sealed },
|
||||
'exec-root',
|
||||
);
|
||||
runExecutionData.executionData!.runtimeData = carried;
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: 'exec-retry',
|
||||
encryptedRunnerIdentity: undefined,
|
||||
});
|
||||
|
||||
await establishExecutionContext(mockWorkflow, runExecutionData, additionalData, 'retry');
|
||||
|
||||
// Retry id joins the path, so resolution accepts the run.
|
||||
expect(await pathOf(runExecutionData.executionData!.runtimeData)).toEqual([
|
||||
'exec-root',
|
||||
'exec-retry',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a legacy (subject-less) carrier untouched', async () => {
|
||||
const legacy = await service.buildTriggerIdentityCredentials(
|
||||
'oauth-token',
|
||||
'https://host/mcp/wf',
|
||||
);
|
||||
const runExecutionData = createRunExecutionData({
|
||||
startData: {},
|
||||
resultData: { runData: {} },
|
||||
executionData: {
|
||||
contextData: {},
|
||||
nodeExecutionStack: [],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
});
|
||||
runExecutionData.executionData!.runtimeData = {
|
||||
version: 1,
|
||||
establishedAt: 1,
|
||||
source: 'webhook',
|
||||
credentials: legacy,
|
||||
};
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: 'exec-real',
|
||||
encryptedRunnerIdentity: undefined,
|
||||
});
|
||||
|
||||
await establishExecutionContext(mockWorkflow, runExecutionData, additionalData, 'webhook');
|
||||
|
||||
// No subject → not sealed → executionPath stays as minted (empty), never gated.
|
||||
expect(await pathOf(runExecutionData.executionData!.runtimeData)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
-1
@@ -43,7 +43,10 @@ describe('WorkflowExecute node error forwarding to ErrorReporter', () => {
|
||||
let mockErrorReporter: { error: ReturnType<typeof vi.fn> };
|
||||
let mockNodeTypes: Mocked<INodeTypes>;
|
||||
let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
let mockExecutionContextService: { augmentExecutionContextWithHooks: ReturnType<typeof vi.fn> };
|
||||
let mockExecutionContextService: {
|
||||
augmentExecutionContextWithHooks: ReturnType<typeof vi.fn>;
|
||||
maybeBindExecutionId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -59,6 +62,9 @@ describe('WorkflowExecute node error forwarding to ErrorReporter', () => {
|
||||
context: { version: 1, establishedAt: Date.now(), source: 'manual' },
|
||||
triggerItems: undefined,
|
||||
}),
|
||||
// establishExecutionContext also binds the execution id onto an already
|
||||
// established context; pass through so the run isn't aborted.
|
||||
maybeBindExecutionId: vi.fn(async (context) => context),
|
||||
};
|
||||
|
||||
mockContainer.get.mockImplementation((token) => {
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
ICredentialContext,
|
||||
IExecuteData,
|
||||
IExecutionContext,
|
||||
IN8NOAuthMetadata,
|
||||
INodeExecutionData,
|
||||
N8NOAuthMetadataSchema,
|
||||
OAuthResourceGrant,
|
||||
PlaintextExecutionContext,
|
||||
toCredentialContext,
|
||||
@@ -99,20 +101,64 @@ export class ExecutionContextService {
|
||||
);
|
||||
}
|
||||
|
||||
async maybeBindExecutionId(
|
||||
context: IExecutionContext,
|
||||
executionId: string | undefined,
|
||||
{ allowInherit = false }: { allowInherit?: boolean } = {},
|
||||
): Promise<IExecutionContext> {
|
||||
if (!executionId) {
|
||||
return context;
|
||||
}
|
||||
const decryptedContext = await this.decryptExecutionContext(context);
|
||||
if (decryptedContext.credentials) {
|
||||
if (decryptedContext.credentials.metadata) {
|
||||
const metadata = N8NOAuthMetadataSchema.safeParse(decryptedContext.credentials.metadata);
|
||||
// Only sealed carriers (a resolved subject) need execution binding; a
|
||||
// non-sealed n8n-oauth carrier has nothing that reads its executionPath.
|
||||
if (metadata.success && metadata.data.subject) {
|
||||
const executionPath = metadata.data.executionPath ?? [];
|
||||
// Seed an empty path at mint, or extend it only for a legitimate re-run of
|
||||
// this carrier's own execution data — an inherited child (sub-workflow /
|
||||
// error workflow) or a retry. A non-empty path that lacks this id on a
|
||||
// non-inherit bind means the carrier was attached to an execution it was
|
||||
// not minted for — leave it, so credential resolution rejects it.
|
||||
const mayBind =
|
||||
!executionPath.includes(executionId) && (executionPath.length === 0 || allowInherit);
|
||||
if (mayBind) {
|
||||
metadata.data.executionPath = [...executionPath, executionId];
|
||||
decryptedContext.credentials.metadata = metadata.data;
|
||||
return await this.encryptExecutionContext(decryptedContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seals the token a trigger authenticated its caller with, plus the grant it was
|
||||
* accepted under, so the run can re-verify itself for as long as it lasts. See
|
||||
* {@link OAuthResourceGrant}.
|
||||
* Seals the identity a trigger authenticated its caller with. The token stays in
|
||||
* `identity` as evidence; `grant` lets the run re-verify that token after the
|
||||
* protected resource stops resolving (see {@link OAuthResourceGrant}), and `subject`
|
||||
* seals the resolved n8n user so a bound run resolves without re-verifying the token.
|
||||
*/
|
||||
async buildTriggerIdentityCredentials(
|
||||
token: string,
|
||||
resource: string,
|
||||
grant?: OAuthResourceGrant,
|
||||
subject?: string,
|
||||
): Promise<string> {
|
||||
const metadata: IN8NOAuthMetadata = {
|
||||
source: 'n8n-oauth',
|
||||
resource,
|
||||
establishedAt: Date.now(),
|
||||
executionPath: [],
|
||||
...(grant ? { grant } : {}),
|
||||
...(subject ? { subject } : {}),
|
||||
};
|
||||
const payload: ICredentialContext = {
|
||||
version: 1,
|
||||
identity: token,
|
||||
metadata: { source: 'n8n-oauth', resource, ...(grant ? { grant } : {}) },
|
||||
metadata,
|
||||
};
|
||||
return await this.cipher.encryptV2(payload);
|
||||
}
|
||||
|
||||
@@ -111,9 +111,23 @@ export const establishExecutionContext = async (
|
||||
|
||||
const executionData = runExecutionData.executionData;
|
||||
|
||||
// Call the execution context service to augment the context with any hook-based data
|
||||
const executionContextService = Container.get(ExecutionContextService);
|
||||
|
||||
if (executionData.runtimeData) {
|
||||
// Context is already established, no further action needed.
|
||||
// This can happen, when a workflow is resumed from the database.
|
||||
// Context is already established (e.g. established at webhook mint time,
|
||||
// before the real executionId existed, or resumed from the database).
|
||||
// Bind the now-known executionId to any sealed carrier so credential
|
||||
// resolution can gate on executionPath. No-op for legacy (subject-less)
|
||||
// carriers and when executionId is undefined; idempotent on resume.
|
||||
// A retry reloads the original run's data under a NEW executionId, so its
|
||||
// id must join the sealed carrier's path (allowInherit) or resolution would
|
||||
// reject it. Resume keeps the same id, so the bind stays a no-op regardless.
|
||||
executionData.runtimeData = await executionContextService.maybeBindExecutionId(
|
||||
executionData.runtimeData,
|
||||
additionalData?.executionId,
|
||||
{ allowInherit: mode === 'retry' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,6 +142,12 @@ export const establishExecutionContext = async (
|
||||
|
||||
if (additionalData?.encryptedRunnerIdentity) {
|
||||
executionData.runtimeData.credentials = additionalData.encryptedRunnerIdentity;
|
||||
if (executionData.runtimeData.credentials) {
|
||||
executionData.runtimeData = await executionContextService.maybeBindExecutionId(
|
||||
executionData.runtimeData,
|
||||
additionalData.executionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (runExecutionData.parentExecution) {
|
||||
@@ -142,6 +162,12 @@ export const establishExecutionContext = async (
|
||||
parentExecutionId: runExecutionData.parentExecution.executionId,
|
||||
};
|
||||
|
||||
executionData.runtimeData = await executionContextService.maybeBindExecutionId(
|
||||
executionData.runtimeData,
|
||||
additionalData?.executionId,
|
||||
{ allowInherit: true },
|
||||
);
|
||||
|
||||
// The child inherits the parent's context, but its OWN execution record must
|
||||
// still reflect context derived from the child workflow — most importantly
|
||||
// its redaction policy (a policy'd child called by a policy-less parent must
|
||||
@@ -188,12 +214,17 @@ export const establishExecutionContext = async (
|
||||
...executionData.runtimeData,
|
||||
parentExecutionId: startItem.metadata.parentExecution.executionId,
|
||||
};
|
||||
|
||||
// Bind this execution's id to any inherited sealed carrier so it stays
|
||||
// resolvable within its own execution (mirrors the parentExecution branch).
|
||||
executionData.runtimeData = await executionContextService.maybeBindExecutionId(
|
||||
executionData.runtimeData,
|
||||
additionalData?.executionId,
|
||||
{ allowInherit: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the execution context service to augment the context with any hook-based data
|
||||
const executionContextService = Container.get(ExecutionContextService);
|
||||
|
||||
try {
|
||||
const { context, triggerItems } =
|
||||
await executionContextService.augmentExecutionContextWithHooks(
|
||||
|
||||
@@ -209,11 +209,11 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc
|
||||
return await this.additionalData.validateN8nOAuth2Token(token, resourceUrl);
|
||||
}
|
||||
|
||||
async establishTriggerIdentity(token: string, resource: string): Promise<void> {
|
||||
async establishTriggerIdentity(token: string, resource: string, subject?: string): Promise<void> {
|
||||
if (!this.additionalData.establishTriggerIdentity) {
|
||||
throw new UnexpectedError('Trigger identity establishment is not available');
|
||||
}
|
||||
await this.additionalData.establishTriggerIdentity(token, resource);
|
||||
await this.additionalData.establishTriggerIdentity(token, resource, subject);
|
||||
}
|
||||
|
||||
async checkTriggerCredentialStatus(): Promise<CredentialCheckResult | undefined> {
|
||||
|
||||
@@ -253,6 +253,36 @@ export const ExecutionContextSchema = z
|
||||
*/
|
||||
export type IExecutionContext = z.output<typeof ExecutionContextSchema>;
|
||||
|
||||
/**
|
||||
* Metadata shape for the `n8n-oauth` credential-context source.
|
||||
*
|
||||
* `subject` (the resolved n8n user id) and `executionPath` (the execution ids the
|
||||
* seal is valid for) turn the carrier into a verify-once "sealed" identity: when a
|
||||
* subject is present, resolution trusts it and binds to `executionPath` instead of
|
||||
* re-verifying the stored token. Absent `subject` = the legacy token-verify carrier;
|
||||
* `establishedAt`/`executionPath` are optional so those legacy carriers still parse.
|
||||
*
|
||||
* `grant` (see {@link OAuthResourceGrant}) is carried by grant-based triggers so a run
|
||||
* can re-verify its token after the protected resource stops resolving. It is listed
|
||||
* here so `maybeBindExecutionId` preserves it through its parse-and-re-encrypt round-trip;
|
||||
* the identifier validates it against its own local schema.
|
||||
*/
|
||||
export const N8NOAuthMetadataSchema = z.object({
|
||||
source: z.literal('n8n-oauth'),
|
||||
subject: z.string().optional(),
|
||||
resource: z.string(),
|
||||
establishedAt: z.number().optional(),
|
||||
executionPath: z.array(z.string()).optional(),
|
||||
grant: z
|
||||
.object({
|
||||
audiences: z.array(z.string()).min(1),
|
||||
executeAccessWorkflowId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type IN8NOAuthMetadata = z.output<typeof N8NOAuthMetadataSchema>;
|
||||
|
||||
/**
|
||||
* Runtime representation of execution context with decrypted credential data.
|
||||
*
|
||||
|
||||
@@ -1543,7 +1543,7 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod
|
||||
* Call only after the token is validated. The identity persists for the whole
|
||||
* execution (including across a Wait), within the token's validity window.
|
||||
*/
|
||||
establishTriggerIdentity(token: string, resource: string): Promise<void>;
|
||||
establishTriggerIdentity(token: string, resource: string, subject?: string): Promise<void>;
|
||||
/**
|
||||
* Checks the status of the triggering identity's resolvable (end-user) credentials
|
||||
* for this workflow, using the execution context established by
|
||||
@@ -3727,7 +3727,7 @@ export interface IWorkflowExecuteAdditionalData {
|
||||
token: string,
|
||||
resourceUrl: string,
|
||||
) => Promise<N8nOAuth2ValidationResult>;
|
||||
establishTriggerIdentity?(token: string, resource: string): Promise<void>;
|
||||
establishTriggerIdentity?(token: string, resource: string, subject?: string): Promise<void>;
|
||||
checkTriggerCredentialStatus?(): Promise<CredentialCheckResult | undefined>;
|
||||
currentNodeExecutionIndex: number;
|
||||
httpResponse?: express.Response;
|
||||
|
||||
Reference in New Issue
Block a user