n8n/packages/@n8n/engine/src/auth/action-token.ts

50 lines
1.7 KiB
TypeScript

import { z } from 'zod';
import {
signSharedSecretToken,
verifySharedSecretToken,
type SharedSecretTokenSpec,
} from './shared-secret-token';
/**
* What an action token authorizes. Scoped, so a leaked token buys only what the
* data plane can already do: report lifecycle events.
*/
export type ActionScope = 'lifecycle-events:write';
/**
* Deliberately the mirror image of the identity token's spec. The swapped
* issuer and audience are what stop a token minted for CP → DP being replayed
* at a control plane endpoint, and vice versa, even though both are signed with
* the same shared secret.
*/
export const ACTION_TOKEN: SharedSecretTokenSpec = Object.freeze({
issuer: 'n8n-engine-dp',
audience: 'n8n-cp',
ttlSeconds: 60,
clockToleranceSeconds: 30,
});
const actionClaimsSchema = z.object({
scope: z.enum(['lifecycle-events:write']),
iat: z.number().int(),
exp: z.number().int(),
});
/** Every rejection path throws this one type, so no caller learns which check failed. */
export class InvalidActionTokenError extends Error {}
/** Signs an action token {@link verifyActionToken} accepts. The DP → CP direction. */
export function mintActionToken(secret: string, scope: ActionScope): string {
return signSharedSecretToken(ACTION_TOKEN, secret, { scope });
}
/**
* Throws unless the token is trustworthy and carries `requiredScope`. The secret
* is read per call, and an unset or under-length one rejects everything.
*/
export function verifyActionToken(secret: string, token: string, requiredScope: ActionScope): void {
const claims = verifySharedSecretToken(ACTION_TOKEN, secret, token, actionClaimsSchema);
if (!claims || claims.scope !== requiredScope) throw new InvalidActionTokenError();
}