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
+10 -2
View File
@@ -30,8 +30,16 @@ The defaults are `http://external-data-plane:18106` and
`http://nodedc-module-foundry:3333`. They are provider-neutral Platform service
addresses, not workflow configuration.
The three credential types contain only one password-protected opaque
capability. Writer, reader, and Foundry capabilities are intentionally distinct.
The writer, reader, and Foundry credential types contain only one
password-protected opaque capability and are intentionally distinct. The
separate `NDC Provider Rotating Access API` credential keeps the Gelios REST access
and refresh pair inside native Engine Credentials. It exchanges the refresh
token only against the fixed Gelios refresh endpoint, persists both rotated
tokens through the supported expirable-credential lifecycle, and injects only
the current access token into provider requests. Concurrent refresh attempts in
the single-service L2 runtime are coalesced and briefly replay the same rotated
pair so a stale caller cannot immediately spend the invalidated predecessor
refresh token again.
Provider identity, product version, ontology revision, persistence policy, and
tenant scope are materialized by the receiving service from the grant. The node
never accepts them from a workflow.
@@ -0,0 +1,182 @@
import { createHash } from 'node:crypto';
import type {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
ICredentialTestRequest,
ICredentialType,
IHttpRequestHelper,
IHttpRequestOptions,
INodeProperties,
Icon,
} from 'n8n-workflow';
const GELIOS_API_BASE_URL = 'https://api.geliospro.com';
const GELIOS_REFRESH_URL = `${GELIOS_API_BASE_URL}/api/v1/auth/refresh`;
const RECENT_ROTATION_TTL_MS = 30_000;
const MAX_RECENT_ROTATIONS = 64;
interface RotatedTokens extends ICredentialDataDecryptedObject {
accessToken: string;
refreshToken: string;
accessExpiresAt: string;
}
interface RecentRotation {
expiresAt: number;
tokens: RotatedTokens;
}
const refreshInFlight = new Map<string, Promise<RotatedTokens>>();
const recentRotations = new Map<string, RecentRotation>();
export class NdcProviderRotatingAccessApi implements ICredentialType {
name = 'ndcProviderRotatingAccessApi';
displayName = 'NDC Provider Rotating Access API';
icon: Icon = {
light: 'file:../icons/ndc.svg',
dark: 'file:../icons/ndc.dark.svg',
};
documentationUrl = '';
properties: INodeProperties[] = [
{
displayName: 'Refresh Token',
name: 'refreshToken',
type: 'string',
typeOptions: { password: true },
default: '',
required: true,
description: 'Rotating refresh token issued by the approved provider profile',
},
{
displayName: 'Access Token',
name: 'accessToken',
type: 'hidden',
typeOptions: {
expirable: true,
password: true,
},
default: '',
},
{
displayName: 'Access Token Expires At',
name: 'accessExpiresAt',
type: 'hidden',
default: '',
},
];
async preAuthentication(
this: IHttpRequestHelper,
credentials: ICredentialDataDecryptedObject,
): Promise<ICredentialDataDecryptedObject> {
const refreshToken = credentials.refreshToken;
if (typeof refreshToken !== 'string' || refreshToken.trim() === '') {
throw new Error('provider_refresh_token_required');
}
const key = createHash('sha256').update(refreshToken, 'utf8').digest('hex');
const now = Date.now();
pruneRecentRotations(now);
const recent = recentRotations.get(key);
if (recent && recent.expiresAt > now) return { ...recent.tokens };
let pending = refreshInFlight.get(key);
if (!pending) {
pending = rotateTokens(this, refreshToken)
.then((tokens) => {
rememberRotation(key, tokens);
return tokens;
})
.finally(() => refreshInFlight.delete(key));
refreshInFlight.set(key, pending);
}
return { ...(await pending) };
}
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.accessToken}}',
},
},
};
get test(): ICredentialTestRequest {
return {
request: {
method: 'GET',
url: `${GELIOS_API_BASE_URL}/api/v1/auth`,
},
};
}
}
async function rotateTokens(
helper: IHttpRequestHelper,
refreshToken: string,
): Promise<RotatedTokens> {
let response: unknown;
try {
response = await helper.helpers.httpRequest({
method: 'POST',
url: GELIOS_REFRESH_URL,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: { refresh_token: refreshToken },
json: true,
} satisfies IHttpRequestOptions);
} catch {
throw new Error('provider_access_refresh_failed');
}
if (!isRecord(response)) throw new Error('provider_access_refresh_failed');
const accessToken = response.access_token;
const nextRefreshToken = response.refresh_token;
const tokenType = response.token_type;
const expiresIn = response.expires_in;
if (
typeof accessToken !== 'string'
|| accessToken.trim() === ''
|| typeof nextRefreshToken !== 'string'
|| nextRefreshToken.trim() === ''
|| typeof tokenType !== 'string'
|| tokenType.toLowerCase() !== 'bearer'
|| !Number.isSafeInteger(expiresIn)
|| Number(expiresIn) <= 0
) {
throw new Error('provider_access_refresh_failed');
}
return {
accessToken,
refreshToken: nextRefreshToken,
accessExpiresAt: new Date(Date.now() + Number(expiresIn) * 1000).toISOString(),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
function pruneRecentRotations(now: number): void {
for (const [key, value] of recentRotations) {
if (value.expiresAt <= now) recentRotations.delete(key);
}
}
function rememberRotation(key: string, tokens: RotatedTokens): void {
while (recentRotations.size >= MAX_RECENT_ROTATIONS) {
const oldest = recentRotations.keys().next().value as string | undefined;
if (!oldest) break;
recentRotations.delete(oldest);
}
recentRotations.set(key, {
expiresAt: Date.now() + RECENT_ROTATION_TTL_MS,
tokens: { ...tokens },
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "n8n-nodes-ndc",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "n8n-nodes-ndc",
"version": "0.1.3",
"version": "0.1.4",
"license": "UNLICENSED",
"devDependencies": {
"@n8n/node-cli": "0.39.3",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "n8n-nodes-ndc",
"version": "0.1.3",
"version": "0.1.4",
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
"private": true,
"license": "UNLICENSED",
@@ -24,7 +24,8 @@
"credentials": [
"dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js"
"dist/credentials/NdcFoundryBindingApi.credentials.js",
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js"
],
"nodes": [
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
@@ -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);