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