NODEDC_PLATFORM/packages/n8n-nodes-ndc/credentials/NdcProviderRotatingAccessAp...

183 lines
4.9 KiB
TypeScript

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 },
});
}