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>(); const recentRotations = new Map(); 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 { 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 { 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 { 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 }, }); }