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..8855f1b6b09 --- /dev/null +++ b/packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts @@ -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); + }); +}); 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..e09407a9d8c --- /dev/null +++ b/packages/@n8n/engine/src/auth/resume-token.ts @@ -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; +} diff --git a/packages/@n8n/engine/src/auth/shared-secret-token.ts b/packages/@n8n/engine/src/auth/shared-secret-token.ts index 3cc6afb4288..cb99eaf1b8e 100644 --- a/packages/@n8n/engine/src/auth/shared-secret-token.ts +++ b/packages/@n8n/engine/src/auth/shared-secret-token.ts @@ -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 & { + 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( - spec: SharedSecretTokenSpec, + spec: SharedSecretTokenSpec | UnexpiringTokenSpec, secret: string, token: string, schema: z.ZodType, @@ -54,7 +63,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';