feat(n8n): add rotating provider access credential
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user