feat(n8n): add rotating provider access credential

This commit is contained in:
Codex
2026-07-19 10:11:56 +03:00
parent 3c5d8f6cef
commit 0611a88971
10 changed files with 408 additions and 64 deletions
@@ -12,9 +12,10 @@ const nodeSpecs = [
['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'],
];
const credentialSpecs = [
['NdcDataProductWriterApi', 'ndcDataProductWriterApi'],
['NdcDataProductReaderApi', 'ndcDataProductReaderApi'],
['NdcFoundryBindingApi', 'ndcFoundryBindingApi'],
['NdcDataProductWriterApi', 'ndcDataProductWriterApi', 'opaque'],
['NdcDataProductReaderApi', 'ndcDataProductReaderApi', 'opaque'],
['NdcFoundryBindingApi', 'ndcFoundryBindingApi', 'opaque'],
['NdcProviderRotatingAccessApi', 'ndcProviderRotatingAccessApi', 'rotating'],
];
const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i;
@@ -67,19 +68,34 @@ async function main() {
assertProductSurfaceHasNdcBrand(node.description, className);
}
for (const [className, internalName] of credentialSpecs) {
const credentials = new Map();
for (const [className, internalName, kind] of credentialSpecs) {
const modulePath = path.join(packageRoot, 'dist', 'credentials', `${className}.credentials.js`);
const CredentialClass = require(modulePath)[className];
const credential = new CredentialClass();
credentials.set(className, credential);
assert.match(credential.displayName, /^NDC /);
assert.equal(credential.name, internalName);
assert.deepEqual(credential.icon, {
light: 'file:../icons/ndc.svg',
dark: 'file:../icons/ndc.dark.svg',
});
assert.deepEqual(credential.properties.map((property) => property.name), ['capability']);
assert.equal(credential.properties[0].typeOptions.password, true);
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.capability}}');
if (kind === 'opaque') {
assert.deepEqual(credential.properties.map((property) => property.name), ['capability']);
assert.equal(credential.properties[0].typeOptions.password, true);
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.capability}}');
} else {
assert.deepEqual(
credential.properties.map((property) => property.name),
['refreshToken', 'accessToken', 'accessExpiresAt'],
);
assert.equal(credential.properties[0].typeOptions.password, true);
assert.equal(credential.properties[1].type, 'hidden');
assert.equal(credential.properties[1].typeOptions.password, true);
assert.equal(credential.properties[1].typeOptions.expirable, true);
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.accessToken}}');
assert.equal(credential.test.request.url, 'https://api.geliospro.com/api/v1/auth');
}
assertProductSurfaceHasNdcBrand({
displayName: credential.displayName,
documentationUrl: credential.documentationUrl,
@@ -87,6 +103,8 @@ async function main() {
}, className);
}
await assertProviderRotatingCredential(credentials.get('NdcProviderRotatingAccessApi'));
for (const icon of ['ndc.svg', 'ndc.dark.svg']) {
const iconPath = path.join(packageRoot, 'dist', 'icons', icon);
assert.equal(fs.existsSync(iconPath), true, `${icon} was not copied`);
@@ -187,6 +205,57 @@ async function main() {
console.log('n8n-nodes-ndc package policy: ok');
}
async function assertProviderRotatingCredential(credential) {
let requestCount = 0;
let releaseRequest;
const request = new Promise((resolve) => { releaseRequest = resolve; });
const helper = {
helpers: {
async httpRequest(options) {
requestCount += 1;
assert.equal(options.method, 'POST');
assert.equal(options.url, 'https://api.geliospro.com/api/v1/auth/refresh');
assert.deepEqual(options.body, { refresh_token: 'refresh-old-single-flight' });
assert.equal(options.headers.Authorization, undefined);
return request;
},
},
};
const credentials = { refreshToken: 'refresh-old-single-flight', accessToken: '' };
const first = credential.preAuthentication.call(helper, credentials);
const second = credential.preAuthentication.call(helper, credentials);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(requestCount, 1, 'rotating refresh must be single-flight inside the one-service L2 runtime');
releaseRequest({
access_token: 'access-new',
refresh_token: 'refresh-new',
token_type: 'Bearer',
expires_in: 3600,
});
const [firstTokens, secondTokens] = await Promise.all([first, second]);
assert.deepEqual(firstTokens, secondTokens);
assert.equal(firstTokens.accessToken, 'access-new');
assert.equal(firstTokens.refreshToken, 'refresh-new');
assert.match(firstTokens.accessExpiresAt, /^\d{4}-\d{2}-\d{2}T/);
const replay = await credential.preAuthentication.call(helper, credentials);
assert.deepEqual(replay, firstTokens);
assert.equal(requestCount, 1, 'a stale concurrent caller must reuse the recent rotation result');
const leakedRefresh = 'refresh-token-must-not-leak';
await assert.rejects(
credential.preAuthentication.call({
helpers: {
async httpRequest() {
throw new Error(`remote rejected ${leakedRefresh}`);
},
},
}, { refreshToken: leakedRefresh, accessToken: '' }),
(error) => error?.message === 'provider_access_refresh_failed'
&& !error.message.includes(leakedRefresh),
);
}
function assertProductSurfaceHasNdcBrand(surface, className) {
const visibleStrings = [];
collectVisibleStrings(surface, visibleStrings);