diff --git a/packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts b/packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts new file mode 100644 index 00000000000..d19ec7d99e1 --- /dev/null +++ b/packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/engine/src/auth/index.ts b/packages/@n8n/engine/src/auth/index.ts index 3977f3f6507..42b3708494a 100644 --- a/packages/@n8n/engine/src/auth/index.ts +++ b/packages/@n8n/engine/src/auth/index.ts @@ -14,3 +14,4 @@ export { SharedSecretIdentityVerifier, } from './identity-token'; export type { AuthenticatedCaller, IdentityVerifier } from './identity.types'; +export { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from './resume-token'; diff --git a/packages/@n8n/engine/src/auth/resume-token.ts b/packages/@n8n/engine/src/auth/resume-token.ts new file mode 100644 index 00000000000..506f655c07e --- /dev/null +++ b/packages/@n8n/engine/src/auth/resume-token.ts @@ -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; +} diff --git a/packages/@n8n/engine/src/auth/shared-secret-token.ts b/packages/@n8n/engine/src/auth/shared-secret-token.ts index 3cc6afb4288..72eaf376ae0 100644 --- a/packages/@n8n/engine/src/auth/shared-secret-token.ts +++ b/packages/@n8n/engine/src/auth/shared-secret-token.ts @@ -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 & { + 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( - spec: SharedSecretTokenSpec, + spec: SharedSecretTokenSpec | UnexpiringTokenSpec, secret: string, token: string, schema: z.ZodType, @@ -54,7 +59,7 @@ export function verifySharedSecretToken( algorithms: ['HS256'], issuer: spec.issuer, audience: spec.audience, - maxAge: spec.ttlSeconds, + ...(spec.ttlSeconds === null ? {} : { maxAge: spec.ttlSeconds }), clockTolerance: spec.clockToleranceSeconds, clockTimestamp: now, }); diff --git a/packages/@n8n/engine/src/index.ts b/packages/@n8n/engine/src/index.ts index a3a5ce784ef..567b2019ebd 100644 --- a/packages/@n8n/engine/src/index.ts +++ b/packages/@n8n/engine/src/index.ts @@ -10,6 +10,7 @@ export { InvalidIdentityTokenError, mintActionToken, mintIdentityToken, + mintResumeToken, SharedSecretIdentityVerifier, verifyActionToken, } from './auth';