183 lines
4.9 KiB
TypeScript
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 },
|
|
});
|
|
}
|