chore(core): Credential resolver interface and decorator (#22584)

This commit is contained in:
Andreas Fitzek
2025-12-02 10:22:47 +01:00
committed by GitHub
parent f482809f91
commit 2f7a979fe4
6 changed files with 529 additions and 0 deletions
@@ -0,0 +1,344 @@
import { Container } from '@n8n/di';
import type { ICredentialContext, ICredentialDataDecryptedObject } from 'n8n-workflow';
import type { CredentialResolverConfiguration, ICredentialResolver } from '../credential-resolver';
import {
CredentialResolver,
CredentialResolverEntryMetadata,
} from '../credential-resolver-metadata';
describe('@CredentialResolver decorator', () => {
let resolverMetadata: CredentialResolverEntryMetadata;
beforeEach(() => {
jest.resetAllMocks();
resolverMetadata = new CredentialResolverEntryMetadata();
Container.set(CredentialResolverEntryMetadata, resolverMetadata);
});
it('should register resolver in CredentialResolverEntryMetadata', () => {
@CredentialResolver()
class TestResolver implements ICredentialResolver {
metadata = {
name: 'test.resolver',
description: 'Test resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const registeredResolvers = resolverMetadata.getClasses();
expect(registeredResolvers).toContain(TestResolver);
expect(registeredResolvers).toHaveLength(1);
});
it('should register multiple resolvers', () => {
@CredentialResolver()
class FirstResolver implements ICredentialResolver {
metadata = {
name: 'first.resolver',
description: 'First resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
@CredentialResolver()
class SecondResolver implements ICredentialResolver {
metadata = {
name: 'second.resolver',
description: 'Second resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
@CredentialResolver()
class ThirdResolver implements ICredentialResolver {
metadata = {
name: 'third.resolver',
description: 'Third resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const registeredResolvers = resolverMetadata.getClasses();
expect(registeredResolvers).toContain(FirstResolver);
expect(registeredResolvers).toContain(SecondResolver);
expect(registeredResolvers).toContain(ThirdResolver);
expect(registeredResolvers).toHaveLength(3);
});
it('should apply Service decorator', () => {
@CredentialResolver()
class TestResolver implements ICredentialResolver {
metadata = {
name: 'test.resolver',
description: 'Test resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
expect(Container.has(TestResolver)).toBe(true);
});
it('should allow instantiation of registered resolvers with accessible metadata', () => {
@CredentialResolver()
class TestResolver implements ICredentialResolver {
metadata = {
name: 'oauth.introspection',
description: 'OAuth introspection resolver',
displayName: 'OAuth Introspection',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const resolverInstance = Container.get(TestResolver);
expect(resolverInstance).toBeInstanceOf(TestResolver);
expect(resolverInstance.metadata).toEqual({
name: 'oauth.introspection',
description: 'OAuth introspection resolver',
displayName: 'OAuth Introspection',
});
expect(resolverInstance.metadata.name).toBe('oauth.introspection');
});
it('should register resolvers with different metadata', () => {
@CredentialResolver()
class OAuthResolver implements ICredentialResolver {
metadata = {
name: 'oauth.resolver',
description: 'OAuth-based credential resolver',
displayName: 'OAuth Resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
@CredentialResolver()
class StubResolver implements ICredentialResolver {
metadata = {
name: 'stub.resolver',
description: 'Stub resolver for testing',
displayName: 'Stub Resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const registeredResolvers = resolverMetadata.getClasses();
const oauthResolver = Container.get(OAuthResolver);
const stubResolver = Container.get(StubResolver);
expect(registeredResolvers).toHaveLength(2);
expect(oauthResolver.metadata.name).toBe('oauth.resolver');
expect(oauthResolver.metadata.displayName).toBe('OAuth Resolver');
expect(stubResolver.metadata.name).toBe('stub.resolver');
expect(stubResolver.metadata.displayName).toBe('Stub Resolver');
});
it('should support resolvers with configuration options', () => {
@CredentialResolver()
class ConfigurableResolver implements ICredentialResolver {
metadata = {
name: 'configurable.resolver',
description: 'Resolver with configuration options',
options: [
{
displayName: 'API Endpoint',
name: 'apiEndpoint',
type: 'string' as const,
default: '',
},
],
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const resolverInstance = Container.get(ConfigurableResolver);
expect(resolverInstance.metadata.options).toBeDefined();
expect(resolverInstance.metadata.options).toHaveLength(1);
expect(resolverInstance.metadata.options[0].name).toBe('apiEndpoint');
});
it('should support optional deleteSecret method', () => {
@CredentialResolver()
class ResolverWithDelete implements ICredentialResolver {
metadata = {
name: 'resolver.with.delete',
description: 'Resolver with delete support',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async deleteSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const withDelete = Container.get(ResolverWithDelete);
expect(withDelete.deleteSecret).toBeDefined();
});
});
@@ -0,0 +1,52 @@
import { Container, Service } from '@n8n/di';
import { CredentialResolverClass } from './credential-resolver';
type CredentialResolverEntry = {
class: CredentialResolverClass;
};
/**
* Registry service for credential resolver type discovery and instantiation.
* Resolver classes decorated with @CredentialResolver() are automatically registered.
*/
@Service()
export class CredentialResolverEntryMetadata {
private readonly credentialResolverEntries: Set<CredentialResolverEntry> = new Set();
/** Registers a credential resolver class. Called automatically by @CredentialResolver() decorator. */
register(credentialResolverEntry: CredentialResolverEntry) {
this.credentialResolverEntries.add(credentialResolverEntry);
}
/** Returns all registered resolver entries as [index, entry] tuples. */
getEntries() {
return [...this.credentialResolverEntries.entries()];
}
/** Returns all registered resolver classes. */
getClasses() {
return [...this.credentialResolverEntries.values()].map((entry) => entry.class);
}
}
/**
* Decorator to mark a class as a credential resolver.
* Automatically registers the resolver for discovery and enables dependency injection.
*
* @example
* @CredentialResolver()
* class MyResolver implements ICredentialResolver { ... }
*/
export const CredentialResolver =
<T extends CredentialResolverClass>() =>
(target: T) => {
// Register resolver class for discovery by registry
Container.get(CredentialResolverEntryMetadata).register({
class: target,
});
// Enable dependency injection for the resolver class
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};
@@ -0,0 +1,88 @@
import type { Constructable } from '@n8n/di';
import type {
ICredentialContext,
ICredentialDataDecryptedObject,
INodeProperties,
} from 'n8n-workflow';
/**
* Configuration object passed to resolver methods. Structure is defined by resolver type's metadata.options.
*/
export type CredentialResolverConfiguration = Record<string, unknown>;
/**
* Metadata describing a credential resolver type for UI integration and discovery.
*/
export interface CredentialResolverMetadata {
/** Unique identifier for the resolver type */
name: string;
/** Human-readable description of what this resolver does */
description: string;
/** Optional display name shown in UI. Falls back to name if not provided. */
displayName?: string;
/** Configuration schema using n8n's INodeProperties format for dynamic form rendering */
options?: INodeProperties[];
}
/**
* Core interface for credential resolver implementations.
* Resolvers fetch credential data dynamically based on execution context and configuration.
*/
export interface ICredentialResolver {
/** Metadata for UI integration and resolver discovery */
metadata: CredentialResolverMetadata;
/**
* Retrieves credential data for a specific entity from the resolver's storage.
* @throws {CredentialResolverDataNotFoundError} When no data exists for the given context
* @throws {CredentialResolverError} For other resolver-specific errors
*/
getSecret(
credentialId: string,
context: ICredentialContext,
options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject>;
/**
* Stores credential data for a specific entity in the resolver's storage.
* @throws {CredentialResolverError} When storage operation fails
*/
setSecret(
credentialId: string,
context: ICredentialContext,
data: ICredentialDataDecryptedObject,
options: CredentialResolverConfiguration,
): Promise<void>;
/**
* Deletes credential data for a specific entity from the resolver's storage.
* Optional - not all resolvers support deletion.
* @throws {CredentialResolverError} When deletion operation fails
*/
deleteSecret?(
credentialId: string,
context: ICredentialContext,
options: CredentialResolverConfiguration,
): Promise<void>;
/**
* Validates resolver configuration before saving.
* Should verify connectivity, authentication, and configuration structure.
* @throws {CredentialResolverValidationError} When configuration is invalid
*/
validateOptions(options: CredentialResolverConfiguration): Promise<void>;
/**
* Runs initialization logic for the resolver. This might be called multiple times!
* Optional - not all resolvers require initialization.
*/
init?(): Promise<void>;
}
/**
* Type helper for credential resolver class constructors.
*/
export type CredentialResolverClass = Constructable<ICredentialResolver>;
@@ -0,0 +1,31 @@
/**
* Base error class for all credential resolver errors.
*/
export class CredentialResolverError extends Error {
constructor(message: string) {
super(message);
this.name = 'CredentialResolverError';
}
}
/**
* Thrown when no credential data exists for the requested credential and context combination.
* Indicates the entity has not stored credentials for this credential type.
*/
export class CredentialResolverDataNotFoundError extends CredentialResolverError {
constructor() {
super('No data found available for the requested credential and context combination.');
this.name = 'CredentialResolverDataNotFoundError';
}
}
/**
* Thrown when resolver configuration validation fails.
* Indicates invalid configuration values or unreachable external services.
*/
export class CredentialResolverValidationError extends CredentialResolverError {
constructor(message: string) {
super(`Credential resolver options validation failed: ${message}`);
this.name = 'CredentialResolverValidationError';
}
}
@@ -0,0 +1,13 @@
/**
* Credential Resolver Module
*
* Provides interfaces and infrastructure for dynamic credential resolution based on execution context.
* Resolvers fetch credential data at runtime from external storage based on entity identity.
*/
export {
CredentialResolverEntryMetadata,
CredentialResolver,
} from './credential-resolver-metadata';
export * from './errors';
export type * from './credential-resolver';
+1
View File
@@ -4,6 +4,7 @@ export { Debounce } from './debounce';
export * from './execution-lifecycle';
export { Memoized } from './memoized';
export * from './context-establishment';
export * from './credential-resolver';
export * from './module';
export * from './multi-main';
export * from './pubsub';