mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-22 20:41:50 +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
ec99672131
commit
5bd513feca
85
packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts
Normal file
85
packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,3 +14,4 @@ export {
|
|||
SharedSecretIdentityVerifier,
|
||||
} from './identity-token';
|
||||
export type { AuthenticatedCaller, IdentityVerifier } from './identity.types';
|
||||
export { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from './resume-token';
|
||||
|
|
|
|||
47
packages/@n8n/engine/src/auth/resume-token.ts
Normal file
47
packages/@n8n/engine/src/auth/resume-token.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export {
|
|||
InvalidIdentityTokenError,
|
||||
mintActionToken,
|
||||
mintIdentityToken,
|
||||
mintResumeToken,
|
||||
SharedSecretIdentityVerifier,
|
||||
verifyActionToken,
|
||||
} from './auth';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user