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 ec99672131
commit 5bd513feca
5 changed files with 143 additions and 4 deletions

View File

@ -0,0 +1,85 @@
import jwt from 'jsonwebtoken';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ACTION_TOKEN } from '../action-token';
import { IDENTITY_TOKEN } from '../identity-token';
import { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from '../resume-token';
import { signSharedSecretToken } from '../shared-secret-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('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);
});
// The claims are valid, so only the issuer or the audience can reject the token.
it.each([
IDENTITY_TOKEN,
ACTION_TOKEN,
{ ...RESUME_TOKEN, issuer: IDENTITY_TOKEN.issuer },
{ ...RESUME_TOKEN, audience: IDENTITY_TOKEN.audience },
])('rejects resume claims under a foreign issuer or audience %#', (spec) => {
const token = signSharedSecretToken(spec, secret, { k: 'execution-bound', eid: executionId });
expect(verify(token)).toBe(false);
});
it('accepts its own token, which carries no expiry and still verifies a year later', () => {
const token = mintResumeToken(secret, executionId);
expect(verify(token)).toBe(true);
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.each([
[29, true],
[31, false],
])('allows %i seconds of clock skew on the issue time: %s', (secondsAhead, accepted) => {
const token = jwt.sign(
{ k: 'execution-bound', eid: executionId, iat: Math.floor(Date.now() / 1000) + secondsAhead },
secret,
{ algorithm: 'HS256', issuer: RESUME_TOKEN.issuer, audience: RESUME_TOKEN.audience },
);
expect(verify(token)).toBe(accepted);
});
});

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,47 @@
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).
*
* It has its own audience. No other verifier accepts it, and its verifier
* accepts no other token. 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 token states its kind in `k`, so a wait
* bound to a step can refuse it.
*/
const resumeClaimsSchema = z.object({
k: z.literal('execution-bound'),
eid: z.string().min(1),
iat: z.number().int(),
});
/** Mints an execution-bound token for a resume URL of `executionId`. */
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`. 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,13 @@ export interface SharedSecretTokenSpec {
readonly clockToleranceSeconds: number;
}
/** `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 +29,7 @@ export function signSharedSecretToken(
algorithm: 'HS256',
issuer: spec.issuer,
audience: spec.audience,
expiresIn: spec.ttlSeconds,
...(spec.ttlSeconds === null ? {} : { expiresIn: spec.ttlSeconds }),
});
}
@ -37,7 +42,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 +59,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';