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