From a82384f90d60fb688921d4d9237c70529ec04641 Mon Sep 17 00:00:00 2001 From: Adilson Junior <42716295+adilsitos@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:17:55 -0300 Subject: [PATCH] feat(core): Revamp infisical implementation (#30843) --- packages/cli/package.json | 1 - .../providers/__tests__/infisical.test.ts | 436 ++++++++++++++++++ .../providers/infisical.ts | 383 +++++++++++---- .../frontend/@n8n/i18n/src/locales/en.json | 1 - .../ExternalSecretsProviderCard.ee.vue | 24 +- .../composables/useConnectionModal.ee.test.ts | 63 --- .../composables/useConnectionModal.ee.ts | 21 +- pnpm-lock.yaml | 27 -- 8 files changed, 730 insertions(+), 226 deletions(-) create mode 100644 packages/cli/src/modules/external-secrets.ee/providers/__tests__/infisical.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 12cff825654..86e8ce4d227 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -179,7 +179,6 @@ "handlebars": "4.7.9", "helmet": "8.1.0", "http-proxy-middleware": "^3.0.5", - "infisical-node": "1.3.0", "ioredis": "5.3.2", "isbot": "3.6.13", "isolated-vm": "catalog:", diff --git a/packages/cli/src/modules/external-secrets.ee/providers/__tests__/infisical.test.ts b/packages/cli/src/modules/external-secrets.ee/providers/__tests__/infisical.test.ts new file mode 100644 index 00000000000..7900bf32cfd --- /dev/null +++ b/packages/cli/src/modules/external-secrets.ee/providers/__tests__/infisical.test.ts @@ -0,0 +1,436 @@ +import { Logger } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; +import nock from 'nock'; + +import { InfisicalProvider } from '../infisical'; + +const SITE_URL = 'https://app.infisical.com'; +const PROJECT_ID = 'project-123'; +const ENVIRONMENT = 'dev'; +const SECRET_PATH = '/'; + +const universalAuthSettings = { + connected: true, + connectedAt: new Date(), + settings: { + siteURL: SITE_URL, + projectId: PROJECT_ID, + environment: ENVIRONMENT, + secretPath: SECRET_PATH, + authMethod: 'universalAuth', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, +}; + +function workspaceResponse() { + return { + workspace: { + id: PROJECT_ID, + name: 'Test Project', + environments: [{ name: 'Development', slug: 'dev' }], + }, + }; +} + +function universalAuthLoginResponse(accessToken = 'new-access-token', expiresIn = 7200) { + return { + accessToken, + expiresIn, + accessTokenMaxTTL: 43244, + tokenType: 'Bearer', + }; +} + +type ImportFixture = { + secretPath: string; + environment: string; + secrets: Array<{ secretKey: string; secretValue: string }>; +}; + +function listSecretsResponse( + secrets: Array<{ secretKey: string; secretValue: string }>, + imports: ImportFixture[] = [], +) { + return { secrets, imports }; +} + +describe('InfisicalProvider', () => { + const logger = mockInstance(Logger); + logger.scoped.mockReturnValue(logger); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + beforeEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + describe('test', () => { + it('returns success when the workspace endpoint returns 200', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const scope = nock(SITE_URL) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(200, workspaceResponse()); + + const [success] = await provider.test(); + expect(success).toBe(true); + scope.done(); + }); + + it('returns "Invalid credentials" on 401', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const scope = nock(SITE_URL) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(401, { message: 'Unauthorized' }); + + const [success, message] = await provider.test(); + expect(success).toBe(false); + expect(message).toBe('Invalid credentials'); + scope.done(); + }); + + it('returns "Project not found" on 404', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const scope = nock(SITE_URL) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(404, { message: 'Not found' }); + + const [success, message] = await provider.test(); + expect(success).toBe(false); + expect(message).toBe('Project not found. Check the Project ID and Site URL.'); + scope.done(); + }); + + it('returns "Permission denied" on 403', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const scope = nock(SITE_URL) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(403, { message: 'Forbidden' }); + + const [success, message] = await provider.test(); + expect(success).toBe(false); + expect(message).toBe( + 'Permission denied. Verify the machine identity has access to this project.', + ); + scope.done(); + }); + }); + + describe('doConnect with Universal Auth', () => { + it('logs in with clientId/clientSecret and tests the workspace with the issued token', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const scope = nock(SITE_URL) + .post('/api/v1/auth/universal-auth/login', { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }) + .reply(200, universalAuthLoginResponse('issued-token')) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .matchHeader('authorization', 'Bearer issued-token') + .reply(200, workspaceResponse()); + + await provider.connect(); + + expect(provider.state).toBe('connected'); + scope.done(); + }); + + it('transitions to error state when login fails', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const scope = nock(SITE_URL) + .post('/api/v1/auth/universal-auth/login') + .reply(401, { message: 'Invalid client credentials' }); + + await provider.connect(); + + expect(provider.state).toBe('error'); + scope.done(); + }); + }); + + describe('update', () => { + it('caches secrets returned from the v4 list endpoint', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const connectScope = nock(SITE_URL) + .post('/api/v1/auth/universal-auth/login') + .reply(200, universalAuthLoginResponse('connect-token')) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(200, workspaceResponse()); + + await provider.connect(); + connectScope.done(); + + const updateScope = nock(SITE_URL) + .get('/api/v4/secrets') + .query({ + projectId: PROJECT_ID, + environment: ENVIRONMENT, + secretPath: SECRET_PATH, + }) + .matchHeader('authorization', 'Bearer connect-token') + .reply( + 200, + listSecretsResponse([ + { secretKey: 'API_KEY', secretValue: 'secret-value' }, + { secretKey: 'DB_PASSWORD', secretValue: 'hunter2' }, + ]), + ); + + await provider.update(); + + expect(provider.getSecret('API_KEY')).toBe('secret-value'); + expect(provider.getSecret('DB_PASSWORD')).toBe('hunter2'); + expect(provider.hasSecret('API_KEY')).toBe(true); + expect(provider.hasSecret('UNKNOWN')).toBe(false); + expect(provider.getSecretNames().sort()).toEqual(['API_KEY', 'DB_PASSWORD']); + updateScope.done(); + }); + + it('produces an empty cache when no secrets are returned', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const connectScope = nock(SITE_URL) + .post('/api/v1/auth/universal-auth/login') + .reply(200, universalAuthLoginResponse('connect-token')) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(200, workspaceResponse()); + + await provider.connect(); + connectScope.done(); + + const updateScope = nock(SITE_URL) + .get('/api/v4/secrets') + .query(true) + .reply(200, listSecretsResponse([])); + + await provider.update(); + + expect(provider.getSecretNames()).toHaveLength(0); + updateScope.done(); + }); + + it('re-authenticates and retries once when the token is rejected during update', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const connectScope = nock(SITE_URL) + .post('/api/v1/auth/universal-auth/login') + .reply(200, universalAuthLoginResponse('first-token')) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(200, workspaceResponse()); + + await provider.connect(); + connectScope.done(); + + const updateScope = nock(SITE_URL) + .get('/api/v4/secrets') + .query(true) + .matchHeader('authorization', 'Bearer first-token') + .reply(401, { message: 'Token expired' }) + .post('/api/v1/auth/universal-auth/login') + .reply(200, universalAuthLoginResponse('refreshed-token')) + .get('/api/v4/secrets') + .query(true) + .matchHeader('authorization', 'Bearer refreshed-token') + .reply(200, listSecretsResponse([{ secretKey: 'KEY', secretValue: 'val' }])); + + await provider.update(); + + expect(provider.getSecret('KEY')).toBe('val'); + updateScope.done(); + }); + }); + + describe('update with imports', () => { + const makeImport = ( + secrets: Array<{ secretKey: string; secretValue: string }>, + secretPath = '/imported', + environment = ENVIRONMENT, + ): ImportFixture => ({ secretPath, environment, secrets }); + + async function setupConnectedProvider() { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const connectScope = nock(SITE_URL) + .post('/api/v1/auth/universal-auth/login') + .reply(200, universalAuthLoginResponse('connect-token')) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(200, workspaceResponse()); + + await provider.connect(); + connectScope.done(); + + return provider; + } + + it('caches secrets from imports alongside top-level secrets', async () => { + const provider = await setupConnectedProvider(); + + const updateScope = nock(SITE_URL) + .get('/api/v4/secrets') + .query(true) + .reply( + 200, + listSecretsResponse( + [{ secretKey: 'API_KEY', secretValue: 'top-value' }], + [ + makeImport([ + { secretKey: 'IMPORTED_KEY', secretValue: 'from-import' }, + { secretKey: 'ANOTHER', secretValue: 'also' }, + ]), + ], + ), + ); + + await provider.update(); + + expect(provider.getSecret('API_KEY')).toBe('top-value'); + expect(provider.getSecret('IMPORTED_KEY')).toBe('from-import'); + expect(provider.getSecret('ANOTHER')).toBe('also'); + expect(provider.getSecretNames().sort()).toEqual(['ANOTHER', 'API_KEY', 'IMPORTED_KEY']); + updateScope.done(); + }); + + it('prefers top-level secrets over imports when keys collide', async () => { + const provider = await setupConnectedProvider(); + + const updateScope = nock(SITE_URL) + .get('/api/v4/secrets') + .query(true) + .reply( + 200, + listSecretsResponse( + [{ secretKey: 'SHARED_KEY', secretValue: 'wins' }], + [makeImport([{ secretKey: 'SHARED_KEY', secretValue: 'loses' }])], + ), + ); + + await provider.update(); + + expect(provider.getSecret('SHARED_KEY')).toBe('wins'); + expect(provider.getSecretNames()).toEqual(['SHARED_KEY']); + updateScope.done(); + }); + + it('keeps the value from the first import when later imports define the same key', async () => { + const provider = await setupConnectedProvider(); + + const updateScope = nock(SITE_URL) + .get('/api/v4/secrets') + .query(true) + .reply( + 200, + listSecretsResponse( + [], + [ + makeImport([{ secretKey: 'DUP', secretValue: 'first' }], '/imported-a'), + makeImport([{ secretKey: 'DUP', secretValue: 'second' }], '/imported-b'), + ], + ), + ); + + await provider.update(); + + expect(provider.getSecret('DUP')).toBe('first'); + updateScope.done(); + }); + + it('caches keys sourced only from imports when top-level secrets is empty', async () => { + const provider = await setupConnectedProvider(); + + const updateScope = nock(SITE_URL) + .get('/api/v4/secrets') + .query(true) + .reply( + 200, + listSecretsResponse( + [], + [ + makeImport([{ secretKey: 'FROM_A', secretValue: 'a' }], '/imported-a'), + makeImport([{ secretKey: 'FROM_B', secretValue: 'b' }], '/imported-b'), + ], + ), + ); + + await provider.update(); + + expect(provider.getSecret('FROM_A')).toBe('a'); + expect(provider.getSecret('FROM_B')).toBe('b'); + expect(provider.getSecretNames().sort()).toEqual(['FROM_A', 'FROM_B']); + updateScope.done(); + }); + + it('handles imports with empty secrets arrays without affecting the cache', async () => { + const provider = await setupConnectedProvider(); + + const updateScope = nock(SITE_URL) + .get('/api/v4/secrets') + .query(true) + .reply( + 200, + listSecretsResponse( + [], + [ + makeImport([], '/empty'), + makeImport([{ secretKey: 'REAL', secretValue: 'value' }], '/has-secrets'), + ], + ), + ); + + await provider.update(); + + expect(provider.getSecret('REAL')).toBe('value'); + expect(provider.getSecretNames()).toEqual(['REAL']); + updateScope.done(); + }); + }); + + describe('disconnect', () => { + it('clears cached secrets and the in-flight token', async () => { + const provider = new InfisicalProvider(logger); + await provider.init(universalAuthSettings); + + const scope = nock(SITE_URL) + .post('/api/v1/auth/universal-auth/login') + .reply(200, universalAuthLoginResponse('connect-token')) + .get(`/api/v1/workspace/${PROJECT_ID}`) + .reply(200, workspaceResponse()) + .get('/api/v4/secrets') + .query(true) + .reply(200, listSecretsResponse([{ secretKey: 'KEY', secretValue: 'val' }])); + + await provider.connect(); + await provider.update(); + expect(provider.getSecretNames()).toContain('KEY'); + + await provider.disconnect(); + + expect(provider.getSecretNames()).toHaveLength(0); + expect(provider.hasSecret('KEY')).toBe(false); + scope.done(); + }); + }); +}); diff --git a/packages/cli/src/modules/external-secrets.ee/providers/infisical.ts b/packages/cli/src/modules/external-secrets.ee/providers/infisical.ts index 27cb92cd43e..39d57e5b25d 100644 --- a/packages/cli/src/modules/external-secrets.ee/providers/infisical.ts +++ b/packages/cli/src/modules/external-secrets.ee/providers/infisical.ts @@ -1,47 +1,56 @@ -import type InfisicalClient from 'infisical-node'; -import { UnexpectedError, type IDataObject, type INodeProperties } from 'n8n-workflow'; +import { Logger } from '@n8n/backend-common'; +import { Container } from '@n8n/di'; +import type { AxiosInstance } from 'axios'; +import axios from 'axios'; +import { type INodeProperties, UnexpectedError } from 'n8n-workflow'; -import { SecretsProvider } from '../types'; +import { DOCS_HELP_NOTICE } from '../constants'; import type { SecretsProviderSettings } from '../types'; +import { SecretsProvider } from '../types'; -export interface InfisicalSettings { - token: string; +type InfisicalAuthMethod = 'universalAuth'; + +interface InfisicalSettings { siteURL: string; - cacheTTL: number; - debug: boolean; + projectId: string; + environment: string; + secretPath: string; + authMethod: InfisicalAuthMethod; + + // Universal Auth + clientId: string; + clientSecret: string; +} + +interface InfisicalUniversalAuthLoginResponse { + accessToken: string; + expiresIn: number; + accessTokenMaxTTL: number; + tokenType: string; } interface InfisicalSecret { - secretName: string; - secretValue?: string; + secretKey: string; + secretValue: string; } -interface InfisicalServiceToken { - environment?: string; - scopes?: Array<{ environment: string; path: string }>; +interface InfisicalImport { + secrets: InfisicalSecret[]; + secretPath: string; + environment: string; } +interface InfisicalListSecretsResponse { + secrets: InfisicalSecret[]; + imports: InfisicalImport[]; +} + +const TOKEN_REFRESH_LEEWAY_SECONDS = 60; +const MIN_REFRESH_DELAY_MS = 60 * 1000; + export class InfisicalProvider extends SecretsProvider { properties: INodeProperties[] = [ - { - displayName: - '

Important information about our infisical integration


From the 30th July, 2024, we will no longer be supporting new connections to inifiscal secrets vault using service tokens. Existing service tokens will remain usable until July, 2025. After that period, we will be removing support for Infisical from our external secrets integrations. You can find out more information about this change on our docs', - name: 'notice', - type: 'notice', - default: '', - noDataExpression: true, - }, - { - displayName: 'Service Token', - name: 'token', - type: 'string', - hint: 'The Infisical Service Token with read access', - default: '', - required: true, - placeholder: 'e.g. st.64ae963e1874ea.374226a166439dce.39557e4a1b7bdd82', - noDataExpression: true, - typeOptions: { password: true }, - }, + DOCS_HELP_NOTICE, { displayName: 'Site URL', name: 'siteURL', @@ -52,6 +61,76 @@ export class InfisicalProvider extends SecretsProvider { placeholder: 'https://app.infisical.com', default: 'https://app.infisical.com', }, + { + displayName: 'Project ID', + name: 'projectId', + type: 'string', + hint: 'The Infisical project to read secrets from', + required: true, + noDataExpression: true, + placeholder: 'e.g. 7c1cbe9c-3f1b-4a92-b3a2-9d5e2f1c8a4b', + default: '', + }, + { + displayName: 'Environment', + name: 'environment', + type: 'string', + hint: 'Environment slug (e.g. dev, staging, prod)', + required: true, + noDataExpression: true, + placeholder: 'dev', + default: 'dev', + }, + { + displayName: 'Secret Path', + name: 'secretPath', + type: 'string', + hint: 'The path within the project to read secrets from', + required: true, + noDataExpression: true, + placeholder: '/', + default: '/', + }, + { + displayName: 'Authentication Method', + name: 'authMethod', + type: 'options', + required: true, + noDataExpression: true, + options: [{ name: 'Universal Auth', value: 'universalAuth' }], + default: 'universalAuth', + }, + + // Universal Auth + { + displayName: 'Client ID', + name: 'clientId', + type: 'string', + default: '', + required: true, + noDataExpression: true, + placeholder: 'e.g. 8a7b1f1c-9f2a-4d6c-bf1a-2c4e6f8b1d3a', + displayOptions: { + show: { + authMethod: ['universalAuth'], + }, + }, + }, + { + displayName: 'Client Secret', + name: 'clientSecret', + type: 'string', + default: '', + required: true, + noDataExpression: true, + placeholder: '***************', + typeOptions: { password: true }, + displayOptions: { + show: { + authMethod: ['universalAuth'], + }, + }, + }, ]; displayName = 'Infisical'; @@ -60,87 +139,203 @@ export class InfisicalProvider extends SecretsProvider { private cachedSecrets: Record = {}; - private client: InfisicalClient; - private settings: InfisicalSettings; - private environment: string; + private http: AxiosInstance; + + private currentToken: string | null = null; + + private tokenExpiresAt: number | null = null; + + private refreshTimeout: NodeJS.Timeout | null = null; + + private refreshAbort = new AbortController(); + + constructor(readonly logger = Container.get(Logger)) { + super(); + this.logger = this.logger.scoped('external-secrets'); + } async init(settings: SecretsProviderSettings): Promise { this.settings = settings.settings as unknown as InfisicalSettings; - } - async update(): Promise { - if (!this.client) { - throw new UnexpectedError('Updated attempted on Infisical when initialization failed'); - } - if (!(await this.test())[0]) { - throw new UnexpectedError('Infisical provider test failed during update'); - } - const secrets = (await this.client.getAllSecrets({ - environment: this.environment, - path: '/', - attachToProcessEnv: false, - includeImports: true, - })) as InfisicalSecret[]; - const newCache = Object.fromEntries( - secrets.map((s) => [s.secretName, s.secretValue]), - ) as Record; - if (Object.keys(newCache).length === 1 && '' in newCache) { - this.cachedSecrets = {}; - } else { - this.cachedSecrets = newCache; - } + const baseURL = new URL(this.settings.siteURL); + + this.http = axios.create({ baseURL: baseURL.toString() }); + this.http.interceptors.request.use((config) => { + if (this.currentToken) { + config.headers.Authorization = `Bearer ${this.currentToken}`; + } + return config; + }); + + this.logger.debug('Infisical provider initialized'); } protected async doConnect(): Promise { - const { default: InfisicalClientClass } = await import('infisical-node'); - this.client = new InfisicalClientClass(this.settings); + this.refreshAbort = new AbortController(); - const [testSuccess] = await this.test(); + if (this.settings.authMethod === 'universalAuth') { + if (!this.settings.clientId || !this.settings.clientSecret) { + throw new UnexpectedError('Client ID and Client Secret are required for Universal Auth'); + } + await this.loginUniversalAuth(); + } + + const [testSuccess, testMessage] = await this.test(); if (!testSuccess) { - throw new Error('Connection test failed'); + throw new Error(testMessage ?? 'Connection test failed'); } - this.environment = await this.getEnvironment(); - } - - async getEnvironment(): Promise { - const { getServiceTokenData } = await import('infisical-node/lib/api/serviceTokenData'); - const serviceTokenData = (await getServiceTokenData( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.client.clientConfig, - )) as InfisicalServiceToken; - if (serviceTokenData.environment) { - return serviceTokenData.environment; - } - if (serviceTokenData.scopes) { - return serviceTokenData.scopes[0].environment; - } - throw new UnexpectedError("Couldn't find environment for Infisical"); - } - - async test(): Promise<[boolean] | [boolean, string]> { - if (!this.client) { - return [false, 'Client not initialized']; - } - try { - const { populateClientWorkspaceConfigsHelper } = await import( - 'infisical-node/lib/helpers/key' - ); - await populateClientWorkspaceConfigsHelper(this.client.clientConfig); - return [true]; - } catch (e) { - return [false]; - } + this.setupTokenRefresh(); } async disconnect(): Promise { - // + if (this.refreshTimeout !== null) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = null; + } + this.refreshAbort.abort(); + this.currentToken = null; + this.tokenExpiresAt = null; + this.cachedSecrets = {}; } - getSecret(name: string): IDataObject { - return this.cachedSecrets[name] as unknown as IDataObject; + async test(): Promise<[boolean] | [boolean, string]> { + try { + const resp = await this.http.get( + `/api/v1/workspace/${encodeURIComponent(this.settings.projectId)}`, + { validateStatus: () => true }, + ); + + if (resp.status >= 200 && resp.status < 300) { + return [true]; + } + + if (resp.status === 401) { + return [false, 'Invalid credentials']; + } + if (resp.status === 403) { + return [ + false, + 'Permission denied. Verify the machine identity has access to this project.', + ]; + } + if (resp.status === 404) { + return [false, 'Project not found. Check the Project ID and Site URL.']; + } + + return [false, `Unexpected response from Infisical (status ${resp.status}).`]; + } catch (error) { + if (axios.isAxiosError(error) && error.code === 'ECONNREFUSED') { + return [false, 'Connection refused. Check the Site URL.']; + } + return [false, error instanceof Error ? error.message : 'Connection test failed']; + } + } + + async update(): Promise { + if (!this.currentToken) { + throw new UnexpectedError('Update attempted on Infisical before authentication'); + } + + await this.ensureTokenFresh(); + + try { + this.cacheSecrets(await this.fetchSecrets()); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + this.logger.debug('Infisical token rejected during update; re-authenticating and retrying'); + await this.loginUniversalAuth(); + this.cacheSecrets(await this.fetchSecrets()); + return; + } + throw error; + } + } + + private async fetchSecrets(): Promise { + const resp = await this.http.get('/api/v4/secrets', { + params: { + projectId: this.settings.projectId, + environment: this.settings.environment, + secretPath: this.settings.secretPath, + }, + }); + return resp.data; + } + + private dedupeSecrets(secrets: InfisicalSecret[], imports: InfisicalImport[]): InfisicalSecret[] { + const dedupedSecrets = new Map(); + secrets.forEach((s) => { + dedupedSecrets.set(s.secretKey, s); + }); + imports.forEach((i) => { + i.secrets.forEach((s) => { + if (!dedupedSecrets.has(s.secretKey)) { + dedupedSecrets.set(s.secretKey, s); + } + }); + }); + return Array.from(dedupedSecrets.values()); + } + + private cacheSecrets(data: InfisicalListSecretsResponse): void { + const dedupedSecrets = this.dedupeSecrets(data.secrets, data.imports); + this.cachedSecrets = Object.fromEntries( + dedupedSecrets.map((s) => [s.secretKey, s.secretValue]), + ); + this.logger.debug( + `Infisical provider cached ${Object.keys(this.cachedSecrets).length} secrets`, + ); + } + + private async ensureTokenFresh(): Promise { + if (this.tokenExpiresAt === null) return; + if (Date.now() < this.tokenExpiresAt) return; + await this.loginUniversalAuth(); + } + + private async loginUniversalAuth(): Promise { + const resp = await this.http.post( + '/api/v1/auth/universal-auth/login', + { + clientId: this.settings.clientId, + clientSecret: this.settings.clientSecret, + }, + ); + + this.currentToken = resp.data.accessToken; + this.tokenExpiresAt = + Date.now() + Math.max(resp.data.expiresIn - TOKEN_REFRESH_LEEWAY_SECONDS, 60) * 1000; + } + + private setupTokenRefresh(): void { + if (this.refreshTimeout !== null) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = null; + } + if (this.tokenExpiresAt === null) return; + + const remaining = this.tokenExpiresAt - Date.now(); + const refreshIn = Math.max(remaining / 2, MIN_REFRESH_DELAY_MS); + this.refreshTimeout = setTimeout(this.tokenRefresh, refreshIn); + } + + private tokenRefresh = async (): Promise => { + if (this.refreshAbort.signal.aborted) return; + try { + await this.loginUniversalAuth(); + if (this.refreshAbort.signal.aborted) return; + this.setupTokenRefresh(); + } catch { + this.logger.error('Failed to refresh Infisical token. Attempting reconnect.'); + void this.connect(); + } + }; + + getSecret(name: string): string | undefined { + return this.cachedSecrets[name]; } getSecretNames(): string[] { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b7869a0fa3e..dcca3f5aee8 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3372,7 +3372,6 @@ "settings.externalSecrets.actionBox.description.link": "More info", "settings.externalSecrets.actionBox.buttonText": "See plans", "settings.externalSecrets.card.setUp": "Set Up", - "settings.externalSecrets.card.deprecated": "deprecated", "settings.externalSecrets.card.secretsCount": "{count} secret | {count} secrets", "settings.externalSecrets.card.connectedAt": "Connected {date}", "settings.externalSecrets.card.connected": "Enabled", diff --git a/packages/frontend/editor-ui/src/features/integrations/externalSecrets.ee/components/ExternalSecretsProviderCard.ee.vue b/packages/frontend/editor-ui/src/features/integrations/externalSecrets.ee/components/ExternalSecretsProviderCard.ee.vue index 42826536884..2cd8884f17f 100644 --- a/packages/frontend/editor-ui/src/features/integrations/externalSecrets.ee/components/ExternalSecretsProviderCard.ee.vue +++ b/packages/frontend/editor-ui/src/features/integrations/externalSecrets.ee/components/ExternalSecretsProviderCard.ee.vue @@ -12,14 +12,7 @@ import { DateTime } from 'luxon'; import { computed, nextTick, onMounted, toRef } from 'vue'; import { isDateObject } from '@/app/utils/typeGuards'; -import { - N8nActionToggle, - N8nBadge, - N8nButton, - N8nCard, - N8nIcon, - N8nText, -} from '@n8n/design-system'; +import { N8nActionToggle, N8nButton, N8nCard, N8nText } from '@n8n/design-system'; const props = defineProps<{ provider: ExternalSecretsProvider; }>(); @@ -142,12 +135,6 @@ async function onActionDropdownClick(id: string) { -
- - - {{ i18n.baseText('settings.externalSecrets.card.deprecated') }} - -
diff --git a/packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/composables/useConnectionModal.ee.test.ts b/packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/composables/useConnectionModal.ee.test.ts index 67ebc290e8c..519ee40b827 100644 --- a/packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/composables/useConnectionModal.ee.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/composables/useConnectionModal.ee.test.ts @@ -206,69 +206,6 @@ describe('useConnectionModal', () => { }); }); - describe('infisical deprecation', () => { - beforeEach(() => { - mockModuleSettings['external-secrets'] = { - multipleConnections: true, - forProjects: true, - roleBasedAccess: false, - }; - }); - it('should not provide option to create new infisical connection if multipleConnections is enabled', () => { - const providerTypesWithInfisical = ref([ - ...mockProviderTypes, - { - type: 'infisical', - displayName: 'Infisical', - icon: 'infisical', - properties: [], - } as SecretProviderTypeResponse, - ]); - - const { providerTypeOptions } = useConnectionModal({ - ...defaultOptions, - providerTypes: providerTypesWithInfisical, - }); - - expect(providerTypeOptions.value.find((opt) => opt.value === 'infisical')).toBeUndefined(); - expect(providerTypeOptions.value.length).toEqual(1); - expect(providerTypeOptions.value[0].value).toEqual('awsSecretsManager'); - }); - - it('should still set selectedProviderType to infisical on existing infisical connection', async () => { - const providerTypesWithInfisical = ref([ - ...mockProviderTypes, - { - type: 'infisical', - displayName: 'Infisical', - icon: 'infisical', - properties: [], - } as SecretProviderTypeResponse, - ]); - - // Mock existing infisical connection - mockConnection.getConnection.mockResolvedValue({ - id: 'infisical-id', - name: 'infisical-connection', - type: 'infisical', - state: 'connected', - settings: {}, - }); - - const options = { - ...defaultOptions, - providerTypes: providerTypesWithInfisical, - providerKey: ref('infisical-key'), - }; - - const { selectedProviderType, loadConnection } = useConnectionModal(options); - - await loadConnection(); - - expect(selectedProviderType.value?.type).toBe('infisical'); - }); - }); - describe('computed properties', () => { it('should detect unsaved changes', async () => { const options = { ...defaultOptions, providerKey: ref('testKey') }; diff --git a/packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/composables/useConnectionModal.ee.ts b/packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/composables/useConnectionModal.ee.ts index 0e8e5f8baae..1f82ae4bf53 100644 --- a/packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/composables/useConnectionModal.ee.ts +++ b/packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/composables/useConnectionModal.ee.ts @@ -15,8 +15,6 @@ import { getResourcePermissions } from '@n8n/permissions'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types'; import { isComponentPublicInstance } from '@/app/utils/typeGuards'; -import { useSettingsStore } from '@/app/stores/settings.store'; - interface UseConnectionModalOptions { providerTypes: Ref; existingProviderNames?: Ref; @@ -41,7 +39,6 @@ export function useConnectionModal(options: UseConnectionModalOptions) { const rbacStore = useRBACStore(); const toast = useToast(); const projectsStore = useProjectsStore(); - const settingsStore = useSettingsStore(); // State const providerKey = ref(options.providerKey?.value); @@ -166,22 +163,12 @@ export function useConnectionModal(options: UseConnectionModalOptions) { const isEditMode = computed(() => !!providerKey.value); - const providerTypeOptions = computed(() => { - const prvdrTypeOptions = providerTypes.value.map((type) => ({ + const providerTypeOptions = computed(() => + providerTypes.value.map((type) => ({ label: type.displayName, value: type.type, - })); - - if (settingsStore.moduleSettings['external-secrets']?.multipleConnections) { - // infisical has been deprecated for a long time. - // In order to be able to fully remove the code for it - // we are no longer showing users the option to create connections to infisical. - // Any previously existing connections will keep working for now. - return prvdrTypeOptions.filter((opt) => opt.value !== 'infisical'); - } - - return prvdrTypeOptions; - }); + })), + ); const settingsUpdated = computed(() => { return Object.keys(connectionSettings.value).some((key) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1cae6330f4..0cfd0a7df6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3197,9 +3197,6 @@ importers: http-proxy-middleware: specifier: ^3.0.5 version: 3.0.5 - infisical-node: - specifier: 1.3.0 - version: 1.3.0 ioredis: specifier: 5.3.2 version: 5.3.2 @@ -15531,10 +15528,6 @@ packages: infer-owner@1.0.4: resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} - infisical-node@1.3.0: - resolution: {integrity: sha512-tTnnExRAO/ZyqiRdnSlBisErNToYWgtunMWh+8opClEt5qjX7l6HC/b4oGo2AuR2Pf41IR+oqo+dzkM1TCvlUA==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - inflected@2.1.0: resolution: {integrity: sha512-hAEKNxvHf2Iq3H60oMBHkB4wl5jn3TPF3+fXek/sRwAB5gP9xWs4r7aweSF95f99HFoz69pnZTcu8f0SIHV18w==} @@ -20526,15 +20519,9 @@ packages: turndown@7.2.2: resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} - tweetnacl-util@0.15.1: - resolution: {integrity: sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==} - tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - tweetnacl@1.0.3: - resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -34998,16 +34985,6 @@ snapshots: infer-owner@1.0.4: optional: true - infisical-node@1.3.0: - dependencies: - axios: 1.16.1(debug@4.4.3) - dotenv: 16.6.1 - tweetnacl: 1.0.3 - tweetnacl-util: 0.15.1 - transitivePeerDependencies: - - debug - - supports-color - inflected@2.1.0: {} inflight@1.0.6: @@ -41236,12 +41213,8 @@ snapshots: dependencies: '@mixmark-io/domino': 2.2.0 - tweetnacl-util@0.15.1: {} - tweetnacl@0.14.5: {} - tweetnacl@1.0.3: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1