mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
feat(engine): Add the resume token (no-changelog)
A resume URL authorizes itself with a capability token of its own kind. The token has an issuer and audience that no other engine token uses, and it carries no expiry. It states its kind: this token is execution-bound and names the execution. The shared-secret primitive accepts a spec with no lifetime for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
eb2d071760
commit
e1401bd6df
81
packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts
Normal file
81
packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { mintActionToken } from '../action-token';
|
||||
import { mintIdentityToken } from '../identity-token';
|
||||
import { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from '../resume-token';
|
||||
|
||||
const secret = 'a'.repeat(32);
|
||||
const executionId = '0199a1b2-0000-7000-8000-000000000001';
|
||||
|
||||
const verify = (token: string, id = executionId) => verifyResumeToken(secret, token, id);
|
||||
|
||||
describe('verifyResumeToken', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('accepts a token minted for the same execution', () => {
|
||||
expect(verify(mintResumeToken(secret, executionId))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a token minted for another execution', () => {
|
||||
const token = mintResumeToken(secret, '0199a1b2-0000-7000-8000-000000000002');
|
||||
|
||||
expect(verify(token)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token signed with another secret', () => {
|
||||
expect(verify(mintResumeToken('b'.repeat(32), executionId))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects everything when the secret is too short', () => {
|
||||
const short = 'a'.repeat(31);
|
||||
|
||||
expect(verifyResumeToken(short, mintResumeToken(short, executionId), executionId)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an identity token and an action token signed with the same secret', () => {
|
||||
expect(verify(mintIdentityToken(secret, { cpId: 'cp-1', tenantId: 't-1' }))).toBe(false);
|
||||
expect(verify(mintActionToken(secret, 'lifecycle-events:write'))).toBe(false);
|
||||
});
|
||||
|
||||
it('carries no expiry and still verifies a year later', () => {
|
||||
const token = mintResumeToken(secret, executionId);
|
||||
|
||||
expect(jwt.decode(token)).not.toHaveProperty('exp');
|
||||
vi.advanceTimersByTime(365 * 24 * 60 * 60 * 1000);
|
||||
expect(verify(token)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([{}, { k: 'step-bound' }])(
|
||||
'rejects a token whose kind is not execution-bound %#',
|
||||
(kind) => {
|
||||
const token = jwt.sign({ ...kind, eid: executionId }, secret, {
|
||||
algorithm: 'HS256',
|
||||
issuer: RESUME_TOKEN.issuer,
|
||||
audience: RESUME_TOKEN.audience,
|
||||
});
|
||||
|
||||
expect(verify(token)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a token that claims to be issued in the future', () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
k: 'execution-bound',
|
||||
eid: executionId,
|
||||
iat: Math.floor(Date.now() / 1000) + RESUME_TOKEN.clockToleranceSeconds + 60,
|
||||
},
|
||||
secret,
|
||||
{ algorithm: 'HS256', issuer: RESUME_TOKEN.issuer, audience: RESUME_TOKEN.audience },
|
||||
);
|
||||
|
||||
expect(verify(token)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,3 +14,4 @@ export {
|
|||
SharedSecretIdentityVerifier,
|
||||
} from './identity-token';
|
||||
export type { AuthenticatedCaller, IdentityVerifier } from './identity.types';
|
||||
export { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from './resume-token';
|
||||
|
|
|
|||
49
packages/@n8n/engine/src/auth/resume-token.ts
Normal file
49
packages/@n8n/engine/src/auth/resume-token.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
signSharedSecretToken,
|
||||
verifySharedSecretToken,
|
||||
type UnexpiringTokenSpec,
|
||||
} from './shared-secret-token';
|
||||
|
||||
/**
|
||||
* The capability token a resume URL carries (ADR-20260904-a-derived-token-authorizes-a-resume-request).
|
||||
*
|
||||
* Its own issuer and audience, so the engine's other endpoints refuse it, and
|
||||
* it refuses their tokens. No lifetime: the
|
||||
* status of the step decides whether a request still applies, and a wait can
|
||||
* last months.
|
||||
*/
|
||||
export const RESUME_TOKEN: UnexpiringTokenSpec = Object.freeze({
|
||||
issuer: 'n8n-engine-dp',
|
||||
audience: 'n8n-engine-dp/resume',
|
||||
ttlSeconds: null,
|
||||
clockToleranceSeconds: 30,
|
||||
});
|
||||
|
||||
/**
|
||||
* An execution-bound token names the execution and not the step:
|
||||
* `$execution.resumeUrl` is built by whichever node reads it, usually before
|
||||
* the step that waits exists. The kind is a claim, so a step-bound wait can
|
||||
* refuse this token.
|
||||
*/
|
||||
const resumeClaimsSchema = z.object({
|
||||
k: z.literal('execution-bound'),
|
||||
eid: z.string().min(1),
|
||||
iat: z.number().int(),
|
||||
});
|
||||
|
||||
/** Derives an execution-bound token for a resume URL of `executionId`. Nothing stores it. */
|
||||
export function mintResumeToken(secret: string, executionId: string): string {
|
||||
return signSharedSecretToken(RESUME_TOKEN, secret, { k: 'execution-bound', eid: executionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `token` is an execution-bound token for `executionId`. One boolean
|
||||
* for every failure, so a caller cannot learn which check failed. An unset or
|
||||
* under-length secret rejects everything.
|
||||
*/
|
||||
export function verifyResumeToken(secret: string, token: string, executionId: string): boolean {
|
||||
const claims = verifySharedSecretToken(RESUME_TOKEN, secret, token, resumeClaimsSchema);
|
||||
return claims?.eid === executionId;
|
||||
}
|
||||
|
|
@ -15,8 +15,17 @@ export interface SharedSecretTokenSpec {
|
|||
readonly clockToleranceSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A token with no lifetime of its own, because something else decides when it
|
||||
* stops working. `null` rather than an omitted field, so a spec cannot lose its
|
||||
* expiry by accident.
|
||||
*/
|
||||
export type UnexpiringTokenSpec = Omit<SharedSecretTokenSpec, 'ttlSeconds'> & {
|
||||
readonly ttlSeconds: null;
|
||||
};
|
||||
|
||||
export function signSharedSecretToken(
|
||||
spec: SharedSecretTokenSpec,
|
||||
spec: SharedSecretTokenSpec | UnexpiringTokenSpec,
|
||||
secret: string,
|
||||
claims: object,
|
||||
): string {
|
||||
|
|
@ -24,7 +33,7 @@ export function signSharedSecretToken(
|
|||
algorithm: 'HS256',
|
||||
issuer: spec.issuer,
|
||||
audience: spec.audience,
|
||||
expiresIn: spec.ttlSeconds,
|
||||
...(spec.ttlSeconds === null ? {} : { expiresIn: spec.ttlSeconds }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +46,7 @@ export function signSharedSecretToken(
|
|||
* "token is bad" must look the same to a caller.
|
||||
*/
|
||||
export function verifySharedSecretToken<T extends { iat: number }>(
|
||||
spec: SharedSecretTokenSpec,
|
||||
spec: SharedSecretTokenSpec | UnexpiringTokenSpec,
|
||||
secret: string,
|
||||
token: string,
|
||||
schema: z.ZodType<T>,
|
||||
|
|
@ -54,7 +63,7 @@ export function verifySharedSecretToken<T extends { iat: number }>(
|
|||
algorithms: ['HS256'],
|
||||
issuer: spec.issuer,
|
||||
audience: spec.audience,
|
||||
maxAge: spec.ttlSeconds,
|
||||
...(spec.ttlSeconds === null ? {} : { maxAge: spec.ttlSeconds }),
|
||||
clockTolerance: spec.clockToleranceSeconds,
|
||||
clockTimestamp: now,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export {
|
|||
InvalidIdentityTokenError,
|
||||
mintActionToken,
|
||||
mintIdentityToken,
|
||||
mintResumeToken,
|
||||
SharedSecretIdentityVerifier,
|
||||
verifyActionToken,
|
||||
} from './auth';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user