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:
Danny Martini 2026-09-25 15:25:21 +02:00
parent eb2d071760
commit e1401bd6df
5 changed files with 145 additions and 4 deletions

View 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);
});
});

View File

@ -14,3 +14,4 @@ export {
SharedSecretIdentityVerifier,
} from './identity-token';
export type { AuthenticatedCaller, IdentityVerifier } from './identity.types';
export { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from './resume-token';

View 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;
}

View File

@ -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,
});

View File

@ -10,6 +10,7 @@ export {
InvalidIdentityTokenError,
mintActionToken,
mintIdentityToken,
mintResumeToken,
SharedSecretIdentityVerifier,
verifyActionToken,
} from './auth';