feat(core): Add 1Password external secrets provider (#26307)

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Marc Littlemore 2026-03-05 15:08:13 +00:00 committed by GitHub
parent 2e35bb322e
commit 1f1021e707
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 468 additions and 0 deletions

View file

@ -10,6 +10,7 @@ export const secretsProviderTypeSchema = z.enum([
'vault',
'azureKeyVault',
'infisical',
'onePassword',
]);
export type SecretsProviderType = z.infer<typeof secretsProviderTypeSchema>;

View file

@ -97,6 +97,7 @@
"@aws-sdk/client-secrets-manager": "3.808.0",
"@azure/identity": "catalog:",
"@azure/keyvault-secrets": "4.8.0",
"@1password/connect": "1.4.2",
"@google-cloud/secret-manager": "5.6.0",
"@n8n/ai-utilities": "workspace:*",
"@n8n/ai-workflow-builder": "workspace:*",

View file

@ -5,6 +5,7 @@ import { AwsSecretsManager } from './providers/aws-secrets-manager';
import { AzureKeyVault } from './providers/azure-key-vault/azure-key-vault';
import { GcpSecretsManager } from './providers/gcp-secrets-manager/gcp-secrets-manager';
import { InfisicalProvider } from './providers/infisical';
import { OnePasswordProvider } from './providers/one-password';
import { VaultProvider } from './providers/vault';
import type { SecretsProvider } from './types';
@ -16,6 +17,7 @@ export class ExternalSecretsProviders {
vault: VaultProvider,
azureKeyVault: AzureKeyVault,
gcpSecretsManager: GcpSecretsManager,
onePassword: OnePasswordProvider,
};
getProvider(name: string): { new (): SecretsProvider } {

View file

@ -0,0 +1,285 @@
import { UserError } from 'n8n-workflow';
import { mock } from 'jest-mock-extended';
import { OnePasswordProvider } from '../one-password';
import type { OnePasswordContext } from '../one-password';
const mockListVaults = jest.fn();
const mockListItems = jest.fn();
const mockGetItemById = jest.fn();
jest.mock('@1password/connect', () => ({
OnePasswordConnect: jest.fn(() => ({
listVaults: mockListVaults,
listItems: mockListItems,
getItemById: mockGetItemById,
})),
}));
describe('OnePasswordProvider', () => {
const provider = new OnePasswordProvider();
afterEach(() => {
jest.clearAllMocks();
});
describe('init validation', () => {
it('should throw UserError when serverUrl is empty', async () => {
const settings = { serverUrl: '', accessToken: 'test-token' };
await expect(provider.init(mock<OnePasswordContext>({ settings }))).rejects.toThrow(
UserError,
);
});
it('should throw UserError when serverUrl is whitespace', async () => {
const settings = { serverUrl: ' ', accessToken: 'test-token' };
await expect(provider.init(mock<OnePasswordContext>({ settings }))).rejects.toThrow(
UserError,
);
});
it('should throw UserError when accessToken is empty', async () => {
const settings = { serverUrl: 'http://localhost:8080', accessToken: '' };
await expect(provider.init(mock<OnePasswordContext>({ settings }))).rejects.toThrow(
UserError,
);
});
it('should throw UserError when accessToken is whitespace', async () => {
const settings = { serverUrl: 'http://localhost:8080', accessToken: ' ' };
await expect(provider.init(mock<OnePasswordContext>({ settings }))).rejects.toThrow(
UserError,
);
});
it('should succeed with valid settings', async () => {
const settings = { serverUrl: 'http://localhost:8080', accessToken: 'test-token' };
await expect(provider.init(mock<OnePasswordContext>({ settings }))).resolves.not.toThrow();
});
});
describe('connect', () => {
it('should connect successfully when listVaults succeeds', async () => {
await provider.init(
mock<OnePasswordContext>({
settings: { serverUrl: 'http://localhost:8080', accessToken: 'test-token' },
}),
);
mockListVaults.mockResolvedValue([{ id: 'vault-1', name: 'My Vault' }]);
await provider.connect();
expect(provider.state).toBe('connected');
});
it('should transition to error state when connection fails', async () => {
await provider.init(
mock<OnePasswordContext>({
settings: { serverUrl: 'http://localhost:8080', accessToken: 'bad-token' },
}),
);
mockListVaults.mockRejectedValue(new Error('Unauthorized'));
await provider.connect();
expect(provider.state).toBe('error');
});
});
describe('test', () => {
it('should return [true] when listVaults succeeds', async () => {
await provider.init(
mock<OnePasswordContext>({
settings: { serverUrl: 'http://localhost:8080', accessToken: 'test-token' },
}),
);
mockListVaults.mockResolvedValue([]);
await provider.connect();
mockListVaults.mockResolvedValue([{ id: 'vault-1' }]);
const result = await provider.test();
expect(result).toEqual([true]);
});
it('should return [false, "Connection refused"] when listVaults fails', async () => {
await provider.init(
mock<OnePasswordContext>({
settings: { serverUrl: 'http://localhost:8080', accessToken: 'test-token' },
}),
);
mockListVaults.mockResolvedValue([]);
await provider.connect();
mockListVaults.mockRejectedValue(new Error('Connection refused'));
const result = await provider.test();
expect(result).toEqual([false, 'Connection refused']);
});
});
describe('update', () => {
beforeEach(async () => {
await provider.init(
mock<OnePasswordContext>({
settings: { serverUrl: 'http://localhost:8080', accessToken: 'test-token' },
}),
);
mockListVaults.mockResolvedValue([{ id: 'vault-1', name: 'My Vault' }]);
await provider.connect();
});
it('should fetch and cache secrets from all vaults', async () => {
mockListVaults.mockResolvedValue([
{ id: 'vault-1', name: 'Vault One' },
{ id: 'vault-2', name: 'Vault Two' },
]);
mockListItems
.mockResolvedValueOnce([
{ id: 'item-1', title: 'Database Credentials' },
{ id: 'item-2', title: 'API Key' },
])
.mockResolvedValueOnce([{ id: 'item-3', title: 'SSH Key' }]);
mockGetItemById
.mockResolvedValueOnce({
fields: [
{ label: 'username', value: 'admin' },
{ label: 'password', value: 'secret123' },
],
})
.mockResolvedValueOnce({
fields: [{ label: 'key', value: 'sk-abc123' }],
})
.mockResolvedValueOnce({
fields: [{ label: 'private_key', value: 'ssh-rsa AAAA...' }],
});
await provider.update();
expect(mockListItems).toHaveBeenCalledWith('vault-1');
expect(mockListItems).toHaveBeenCalledWith('vault-2');
expect(mockGetItemById).toHaveBeenCalledWith('vault-1', 'item-1');
expect(mockGetItemById).toHaveBeenCalledWith('vault-1', 'item-2');
expect(mockGetItemById).toHaveBeenCalledWith('vault-2', 'item-3');
expect(provider.getSecret('Database Credentials')).toEqual({
username: 'admin',
password: 'secret123',
});
expect(provider.getSecret('API Key')).toEqual({ key: 'sk-abc123' });
expect(provider.getSecret('SSH Key')).toEqual({ private_key: 'ssh-rsa AAAA...' });
expect(provider.getSecretNames()).toHaveLength(3);
});
it('should skip items without fields', async () => {
mockListVaults.mockResolvedValue([{ id: 'vault-1', name: 'Vault' }]);
mockListItems.mockResolvedValue([
{ id: 'item-1', title: 'Empty Item' },
{ id: 'item-2', title: 'Valid Item' },
]);
mockGetItemById.mockResolvedValueOnce({ fields: [] }).mockResolvedValueOnce({
fields: [{ label: 'key', value: 'value' }],
});
await provider.update();
expect(provider.hasSecret('Empty Item')).toBe(false);
expect(provider.hasSecret('Valid Item')).toBe(true);
});
it('should skip fields without labels or values', async () => {
mockListVaults.mockResolvedValue([{ id: 'vault-1', name: 'Vault' }]);
mockListItems.mockResolvedValue([{ id: 'item-1', title: 'Mixed Item' }]);
mockGetItemById.mockResolvedValue({
fields: [
{ label: 'valid', value: 'data' },
{ label: '', value: 'no-label' },
{ label: 'no-value', value: '' },
{ label: undefined, value: 'undefined-label' },
{ label: 'null-value', value: undefined },
],
});
await provider.update();
expect(provider.getSecret('Mixed Item')).toEqual({ valid: 'data' });
});
it('should skip items without id or title', async () => {
mockListVaults.mockResolvedValue([{ id: 'vault-1', name: 'Vault' }]);
mockListItems.mockResolvedValue([
{ id: undefined, title: 'No ID' },
{ id: 'item-1', title: undefined },
{ id: 'item-2', title: 'Valid' },
]);
mockGetItemById.mockResolvedValue({
fields: [{ label: 'key', value: 'value' }],
});
await provider.update();
expect(mockGetItemById).toHaveBeenCalledTimes(1);
expect(mockGetItemById).toHaveBeenCalledWith('vault-1', 'item-2');
expect(provider.hasSecret('Valid')).toBe(true);
});
it('should skip vaults without id', async () => {
mockListVaults.mockResolvedValue([
{ id: undefined, name: 'No ID Vault' },
{ id: 'vault-1', name: 'Valid Vault' },
]);
mockListItems.mockResolvedValue([{ id: 'item-1', title: 'Secret' }]);
mockGetItemById.mockResolvedValue({
fields: [{ label: 'key', value: 'value' }],
});
await provider.update();
expect(mockListItems).toHaveBeenCalledTimes(1);
expect(mockListItems).toHaveBeenCalledWith('vault-1');
});
it('should skip items where all fields lack values', async () => {
mockListVaults.mockResolvedValue([{ id: 'vault-1', name: 'Vault' }]);
mockListItems.mockResolvedValue([{ id: 'item-1', title: 'Empty Fields' }]);
mockGetItemById.mockResolvedValue({
fields: [
{ label: 'field1', value: '' },
{ label: 'field2', value: undefined },
],
});
await provider.update();
expect(provider.hasSecret('Empty Fields')).toBe(false);
});
});
describe('getSecret / hasSecret / getSecretNames', () => {
it('should return undefined for non-existent secrets', () => {
expect(provider.getSecret('non-existent')).toBeUndefined();
});
it('should return false for non-existent secrets', () => {
expect(provider.hasSecret('non-existent')).toBe(false);
});
});
});

View file

@ -0,0 +1,152 @@
import type { OPConnect } from '@1password/connect';
import { Logger } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import { UserError, type IDataObject, type INodeProperties } from 'n8n-workflow';
import { DOCS_HELP_NOTICE } from '../constants';
import { SecretsProvider, type SecretsProviderSettings } from '../types';
export type OnePasswordContext = SecretsProviderSettings<{
serverUrl: string;
accessToken: string;
}>;
export class OnePasswordProvider extends SecretsProvider {
name = 'onePassword';
displayName = '1Password';
properties: INodeProperties[] = [
DOCS_HELP_NOTICE,
{
displayName: 'Connect Server URL',
name: 'serverUrl',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. http://localhost:8080',
hint: 'URL of your <a href="https://developer.1password.com/docs/connect/get-started/" target="_blank">1Password Connect Server</a>.',
noDataExpression: true,
},
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
default: '',
required: true,
typeOptions: { password: true },
placeholder: 'e.g. eyJhbGciOiJFUzI1NiIsImtpZCI6...',
hint: 'Token created for your Connect Server integration.',
noDataExpression: true,
},
];
private cachedSecrets: Record<string, IDataObject> = {};
private client: OPConnect;
private settings: { serverUrl: string; accessToken: string };
constructor(private readonly logger = Container.get(Logger)) {
super();
this.logger = this.logger.scoped('external-secrets');
}
async init(context: OnePasswordContext) {
const trimmedServerUrl = context.settings.serverUrl?.trim();
const trimmedAccessToken = context.settings.accessToken?.trim();
if (!trimmedServerUrl) {
throw new UserError('Connect Server URL is required.');
}
if (!trimmedAccessToken) {
throw new UserError('Access Token is required.');
}
this.settings = {
serverUrl: trimmedServerUrl,
accessToken: trimmedAccessToken,
};
}
protected async doConnect(): Promise<void> {
const { OnePasswordConnect } = await import('@1password/connect');
this.client = OnePasswordConnect({
serverURL: this.settings.serverUrl,
token: this.settings.accessToken,
keepAlive: true,
});
const [wasSuccessful, errorMessage] = await this.test();
if (!wasSuccessful) {
throw new Error(errorMessage || 'Connection failed');
}
this.logger.debug('1Password provider connected');
}
async test(): Promise<[boolean] | [boolean, string]> {
if (!this.client) return [false, 'Client not initialized'];
try {
await this.client.listVaults();
return [true];
} catch (error: unknown) {
return [false, error instanceof Error ? error.message : 'Unknown error'];
}
}
async disconnect() {
// Stateless HTTP client — nothing to disconnect
}
async update() {
const vaults = await this.client.listVaults();
const secrets: Record<string, IDataObject> = {};
for (const vault of vaults) {
if (!vault.id) continue;
const items = await this.client.listItems(vault.id);
for (const item of items) {
if (!item.id || !item.title) continue;
const fullItem = await this.client.getItemById(vault.id, item.id);
if (!fullItem.fields?.length) continue;
const fieldValues: IDataObject = {};
for (const field of fullItem.fields) {
if (field.label && field.value) {
fieldValues[field.label] = field.value;
}
}
if (Object.keys(fieldValues).length === 0) continue;
secrets[item.title] = fieldValues;
}
}
this.cachedSecrets = secrets;
this.logger.debug('1Password provider secrets updated');
}
getSecret(name: string): IDataObject {
return this.cachedSecrets[name];
}
hasSecret(name: string) {
return name in this.cachedSecrets;
}
getSecretNames() {
return Object.keys(this.cachedSecrets);
}
}

View file

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#0572EC" d="M12 .007C5.373.007 0 5.376 0 11.999c0 6.624 5.373 11.994 12 11.994S24 18.623 24 12C24 5.376 18.627.007 12 .007Zm-.895 4.857h1.788c.484 0 .729.002.914.096a.86.86 0 0 1 .377.377c.094.185.095.428.095.912v6.016c0 .12 0 .182-.015.238a.427.427 0 0 1-.067.137.923.923 0 0 1-.174.162l-.695.564c-.113.092-.17.138-.191.194a.216.216 0 0 0 0 .15c.02.055.078.101.191.193l.695.565c.094.076.14.115.174.162.03.042.053.087.067.137a.936.936 0 0 1 .015.238v2.746c0 .484-.001.727-.095.912a.86.86 0 0 1-.377.377c-.185.094-.43.096-.914.096h-1.788c-.484 0-.726-.002-.912-.096a.86.86 0 0 1-.377-.377c-.094-.185-.095-.428-.095-.912v-6.016c0-.12 0-.182.015-.238a.437.437 0 0 1 .067-.139c.034-.047.08-.083.174-.16l.695-.564c.113-.092.17-.138.191-.194a.216.216 0 0 0 0-.15c-.02-.055-.078-.101-.191-.193l-.695-.565a.92.92 0 0 1-.174-.162.437.437 0 0 1-.067-.139.92.92 0 0 1-.015-.236V6.25c0-.484.001-.727.095-.912a.86.86 0 0 1 .377-.377c.186-.094.428-.096.912-.096z"/></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -8,6 +8,7 @@ import vault from '../assets/images/hashicorp.webp';
import AwsSecretsManager from '../assets/images/aws-secrets-manager.svg';
import AzureKeyVault from '../assets/images/azure-key-vault.svg';
import GcpSecretsManager from '../assets/images/gcp-secrets-manager.svg';
import OnePassword from '../assets/images/one-password.svg';
const { provider } = defineProps<{
provider: ExternalSecretsProvider;
@ -20,5 +21,6 @@ const image = computed(() => ({ doppler, infisical, vault })[provider.name]);
<AwsSecretsManager v-if="provider.name === 'awsSecretsManager'" />
<AzureKeyVault v-else-if="provider.name === 'azureKeyVault'" />
<GcpSecretsManager v-else-if="provider.name === 'gcpSecretsManager'" />
<OnePassword v-else-if="provider.name === 'onePassword'" />
<img v-else :src="image" :alt="provider.displayName" width="28" height="28" />
</template>

View file

@ -8,6 +8,7 @@ import vault from '../../externalSecrets.ee/assets/images/hashicorp.webp';
import AwsSecretsManager from '../../externalSecrets.ee/assets/images/aws-secrets-manager.svg';
import AzureKeyVault from '../../externalSecrets.ee/assets/images/azure-key-vault.svg';
import GcpSecretsManager from '../../externalSecrets.ee/assets/images/gcp-secrets-manager.svg';
import OnePassword from '../../externalSecrets.ee/assets/images/one-password.svg';
const { provider } = defineProps<{
provider: SecretProviderTypeResponse;
@ -23,5 +24,6 @@ const image = computed(() => {
<AwsSecretsManager v-if="provider.type === 'awsSecretsManager'" />
<AzureKeyVault v-else-if="provider.type === 'azureKeyVault'" />
<GcpSecretsManager v-else-if="provider.type === 'gcpSecretsManager'" />
<OnePassword v-else-if="provider.type === 'onePassword'" />
<img v-else :src="image" :alt="provider.displayName" width="28" height="28" />
</template>

View file

@ -1918,6 +1918,9 @@ importers:
packages/cli:
dependencies:
'@1password/connect':
specifier: 1.4.2
version: 1.4.2
'@aws-sdk/client-secrets-manager':
specifier: 3.808.0
version: 3.808.0
@ -4005,6 +4008,9 @@ importers:
packages:
'@1password/connect@1.4.2':
resolution: {integrity: sha512-CxcDQIr76nloWwGWRrmz/U7DuU65WKrN/yarq45LrC3L6b/pC7bZyskvougadG32fRwBieLJX143lTI8T1bAtQ==}
'@aashutoshrathi/word-wrap@1.2.6':
resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==}
engines: {node: '>=0.10.0'}
@ -17312,6 +17318,10 @@ packages:
resolution: {integrity: sha512-tf+h5W1IrjNm/9rKKj0JU2MDMruiopx0jjVA5zCdBtcGjfp0+c5rHw/zADLC3IeKlGHtVbHtpfzvYA0OYT+HKg==}
engines: {node: '>=8.0.0'}
slugify@1.6.6:
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
engines: {node: '>=8.0.0'}
smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
@ -19380,6 +19390,16 @@ packages:
snapshots:
'@1password/connect@1.4.2':
dependencies:
axios: 1.13.5(debug@4.4.3)
debug: 4.4.3(supports-color@8.1.1)
lodash.clonedeep: 4.5.0
slugify: 1.6.6
uuid: 9.0.1
transitivePeerDependencies:
- supports-color
'@aashutoshrathi/word-wrap@1.2.6': {}
'@actions/core@1.11.1':
@ -36873,6 +36893,8 @@ snapshots:
slugify@1.4.7: {}
slugify@1.6.6: {}
smart-buffer@4.2.0:
optional: true