feat: Remove Chat 'legacy' redirect and replace with OAuth2 flow (no-changelog) (#37207)

This commit is contained in:
James Martin
2026-08-27 09:40:54 +00:00
committed by GitHub
parent 7fe438e530
commit 8a9748f07c
8 changed files with 124 additions and 169 deletions
@@ -21,7 +21,6 @@ import type {
INodeExecutionData,
IBinaryData,
INodeProperties,
IUser,
} from 'n8n-workflow';
import * as a from 'node:assert';
import { ChatTriggerConfig } from '@n8n/config';
@@ -34,15 +33,13 @@ import {
validateAuth,
} from './GenericFunctions';
import {
buildAbsoluteChatUrl,
buildInnerFrameSrc,
CHAT_FRAME_SANDBOX,
isChatOAuth2Enabled,
isShellInnerRequest,
readAuthCookie,
} from './shell';
import { createPage, createShellPage } from './templates';
import { assertValidLoadPreviousSessionOption } from './types';
import { assertValidLoadPreviousSessionOption, type ChatFrameIdentity } from './types';
const isPublicChatTriggerDisabled = () => Container.get(ChatTriggerConfig).disablePublicChat;
const allowFileUploadsOption: INodeProperties = {
@@ -921,37 +918,19 @@ export class ChatTrigger extends Node {
// An n8n-controlled shell on the real origin, with the author's chat in a frame
// that has no origin. The connect experience needs the real origin (OAuth popup,
// success channel, `localStorage`), so nothing author-shaped may live there.
let shellInner = false;
let authToken: string | undefined;
let visitor: IUser | undefined;
let frameIdentity: ChatFrameIdentity | undefined;
if (isChatOAuth2Enabled() && authentication === 'n8nUserAuth') {
shellInner = isShellInnerRequest(req);
const resourceUrl = ctx.getWebhookResourceUrl('default');
if (!resourceUrl) {
throw new NodeOperationError(ctx.getNode(), 'Default webhook url not set');
}
if (!shellInner) {
// Outer shell: gates page access on the live session, then runs the AS
// handshake here — a normal top-level document with real cookies, unlike
// the sandboxed, opaque-origin frame this shell is about to create.
const authCookie = readAuthCookie(req);
if (authCookie) {
try {
visitor = await ctx.validateCookieAuth(authCookie);
} catch {}
}
if (!visitor) {
res.writeHead(302, {
Location: `/signin?redirect=${encodeURIComponent(buildAbsoluteChatUrl(req))}`,
});
res.end();
return { noWebhookResponse: true };
}
if (!isShellInnerRequest(req)) {
// Outer shell: the AS handshake runs here — a normal top-level document with
// real cookies, unlike the sandboxed, opaque-origin frame this shell is about
// to create. It is the only gate: a visitor without an editor session is
// authenticated by the flow rather than bounced to sign-in ahead of it.
const ready = await establishChatSessionIdentity(ctx, resourceUrl);
if (!ready) {
return { noWebhookResponse: true };
@@ -975,8 +954,7 @@ export class ChatTrigger extends Node {
res.end();
return { noWebhookResponse: true };
}
visitor = identity.visitor;
authToken = identity.authToken;
frameIdentity = identity;
// By header as well as by the iframe's attribute, so the document has no
// origin even if the attribute is ever stripped.
@@ -998,9 +976,7 @@ export class ChatTrigger extends Node {
allowedFilesMimeTypes: options.allowedFilesMimeTypes,
customCss: options.customCss,
enableStreaming,
shellInner,
authToken,
visitor,
frameIdentity,
});
res.status(200).send(page).end();
@@ -1,6 +1,6 @@
import basicAuth from 'basic-auth';
import { UnexpectedError } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject, IUser, IWebhookFunctions } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
import { ChatTriggerAuthorizationError } from './error';
import {
@@ -9,7 +9,7 @@ import {
readChatOAuthToken,
setChatOAuthToken,
} from './shell';
import type { AuthenticationChatOption } from './types';
import type { AuthenticationChatOption, ChatFrameIdentity } from './types';
export async function validateAuth(context: IWebhookFunctions) {
const authentication = context.getNodeParameter(
@@ -185,7 +185,7 @@ export async function establishChatSessionIdentity(
export async function resolveInnerFrameIdentity(
context: IWebhookFunctions,
resourceUrl: string,
): Promise<{ visitor: IUser; authToken: string } | null> {
): Promise<ChatFrameIdentity | null> {
const req = context.getRequestObject();
const res = context.getResponseObject();
@@ -423,12 +423,20 @@ describe('ChatTrigger Node', () => {
const renderedPage = () => vi.mocked(mockResponse.send).mock.calls.at(-1)?.[0] as string;
// Every body and header this request produced, so a `/signin` anywhere in the
// response — a redirect Location as much as a rendered page — shows up.
const everySentResponse = () =>
JSON.stringify([
vi.mocked(mockResponse.send).mock.calls,
vi.mocked(mockResponse.writeHead).mock.calls,
vi.mocked(mockResponse.setHeader).mock.calls,
]);
beforeEach(() => {
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getNodeWebhookUrl.mockReturnValue('http://localhost:5678/webhook/abc/chat');
mockContext.getWebhookResourceUrl.mockReturnValue('http://localhost:5678/webhook/abc/chat');
mockContext.getInstanceId.mockReturnValue('instance-1');
mockContext.validateCookieAuth.mockResolvedValue(visitor);
mockContext.getNode.mockReturnValue({
id: 'node-1',
name: 'Chat Trigger',
@@ -445,7 +453,6 @@ describe('ChatTrigger Node', () => {
mockRequest.headers = {
'x-forwarded-proto': 'http',
host: 'localhost:5678',
cookie: 'n8n-auth=session-token',
};
mockRequest.query = {};
mockRequest.originalUrl = '/webhook/abc/chat';
@@ -478,7 +485,6 @@ describe('ChatTrigger Node', () => {
mockRequest.headers = {
'x-forwarded-proto': 'http',
host: 'localhost:5678',
cookie: 'n8n-auth=session-token',
'sec-fetch-dest': 'iframe',
};
@@ -512,7 +518,6 @@ describe('ChatTrigger Node', () => {
mockRequest.headers = {
'x-forwarded-proto': 'http',
host: 'localhost:5678',
cookie: 'n8n-auth=session-token',
'sec-fetch-dest': 'iframe',
};
vi.mocked(resolveInnerFrameIdentity).mockResolvedValue(null);
@@ -531,7 +536,6 @@ describe('ChatTrigger Node', () => {
mockRequest.headers = {
'x-forwarded-proto': 'http',
host: 'localhost:5678',
cookie: 'n8n-auth=session-token',
'sec-fetch-dest': 'document',
};
@@ -541,17 +545,21 @@ describe('ChatTrigger Node', () => {
expect(renderedPage()).not.toContain('createChat');
});
it('sends an unauthenticated visitor to sign in', async () => {
// The page used to bounce a visitor with no editor session to `/signin` before the
// AS ever saw them, which defeated the whole point of end-user credentials for
// external visitors. The flow authenticates them instead.
it('begins the OAuth2 flow for a visitor with no session', async () => {
mockRequest.headers = { 'x-forwarded-proto': 'http', host: 'localhost:5678' };
mockContext.validateCookieAuth.mockRejectedValue(new Error('nope'));
const result = await renderSetupPage();
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponse.writeHead).toHaveBeenCalledWith(302, {
Location: '/signin?redirect=http%3A%2F%2Flocalhost%3A5678%2Fwebhook%2Fabc%2Fchat',
});
expect(mockResponse.send).not.toHaveBeenCalled();
expect(establishChatSessionIdentity).toHaveBeenCalledWith(
mockContext,
'http://localhost:5678/webhook/abc/chat',
);
expect(mockContext.validateCookieAuth).not.toHaveBeenCalled();
expect(everySentResponse()).not.toContain('/signin');
});
it('renders the page unsplit when the flag is off', async () => {
@@ -560,6 +568,7 @@ describe('ChatTrigger Node', () => {
await renderSetupPage();
expect(mockResponse.setHeader).not.toHaveBeenCalled();
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
expect(renderedPage()).toContain('createChat');
expect(renderedPage()).not.toContain('n8nShellInner');
});
@@ -570,6 +579,7 @@ describe('ChatTrigger Node', () => {
await renderSetupPage(authentication);
expect(mockResponse.setHeader).not.toHaveBeenCalled();
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
expect(renderedPage()).toContain('createChat');
expect(renderedPage()).not.toContain('n8nShellInner');
},
@@ -1,12 +1,10 @@
import type { Request, Response } from 'express';
import {
buildAbsoluteChatUrl,
buildInnerFrameSrc,
clearChatOAuthToken,
isChatOAuth2Enabled,
isShellInnerRequest,
readAuthCookie,
readChatOAuthToken,
setChatOAuthToken,
} from '../shell';
@@ -90,42 +88,6 @@ describe('buildInnerFrameSrc', () => {
});
});
describe('buildAbsoluteChatUrl', () => {
it('prefers the forwarding headers over the request', () => {
const req = request({
headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'chat.example.com' },
});
expect(buildAbsoluteChatUrl(req)).toBe('https://chat.example.com/webhook/abc/chat');
});
it('falls back to the request protocol and Host', () => {
const req = request({ headers: { host: 'localhost:5678' } });
expect(buildAbsoluteChatUrl(req)).toBe('http://localhost:5678/webhook/abc/chat');
});
});
describe('readAuthCookie', () => {
it('reads the session cookie from the raw header', () => {
const req = request({ headers: { cookie: 'other=1; n8n-auth=token-value; more=2' } });
expect(readAuthCookie(req)).toBe('token-value');
});
it('returns null when the cookie is absent', () => {
expect(readAuthCookie(request({ headers: { cookie: 'other=1' } }))).toBeNull();
expect(readAuthCookie(request())).toBeNull();
});
// `n8n-auth` must not be matched inside a longer cookie name.
it('does not match a cookie whose name merely ends with n8n-auth', () => {
const req = request({ headers: { cookie: 'notn8n-auth=nope' } });
expect(readAuthCookie(req)).toBeNull();
});
});
describe('chat OAuth2 one-hop cookie', () => {
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
@@ -683,9 +683,7 @@ describe('createPage inside the shell frame', () => {
const inner = createPage({
...params,
shellInner: true,
authToken: 'signed.jwt.token',
visitor,
frameIdentity: { visitor, authToken: 'signed.jwt.token' },
});
it('stands in for localStorage before the widget loads', () => {
@@ -707,12 +705,13 @@ describe('createPage inside the shell frame', () => {
expect(inner).toContain('\'x-auth-token\': "signed.jwt.token",');
});
// Not merely skipped at runtime: the bootstrap is never emitted, so there is no
// path from this document to a login endpoint it couldn't reach or a sign-in page it
// couldn't render.
it('takes the visitor from the server instead of fetching the login endpoint', () => {
expect(inner).toContain('const injectedVisitor = {"id":"user-1"');
expect(inner).toContain('if (injectedVisitor) {');
expect(inner.indexOf('if (injectedVisitor) {')).toBeLessThan(
inner.indexOf("fetch('/rest/login'"),
);
expect(inner).toContain('const metadata = { user: {"id":"user-1"');
expect(inner).not.toContain("fetch('/rest/login'");
expect(inner).not.toContain("'/signin?redirect='");
});
it('still renders the author own styling', () => {
@@ -729,12 +728,25 @@ describe('createPage inside the shell frame', () => {
expect(plain).toContain('const injectedVisitor = null;');
});
// The token and the visitor only ever belong to the sandboxed render.
it('ignores a token or visitor passed without the inner flag', () => {
const stray = createPage({ ...params, authToken: 'signed.jwt.token', visitor });
expect(stray).toContain('const injectedVisitor = null;');
expect(stray).not.toContain('x-auth-token');
// The client-side bootstrap is what the flag-off n8nUserAuth render still relies on.
it('keeps the login bootstrap the flag-off render depends on', () => {
expect(plain).toContain("fetch('/rest/login'");
expect(plain).toContain("'/signin?redirect='");
});
});
// A frame render holding half an identity would silently serve an anonymous chat where
// the single-document path redirects to sign-in, and the frame can resolve neither half
// for itself. Both fields are required together, so that state can't be expressed —
// this fails the build rather than the run if the shape ever loosens.
it('cannot represent a frame render missing half its identity', () => {
type FrameIdentity = Parameters<typeof createPage>[0]['frameIdentity'];
// @ts-expect-error the visitor and their token only ever travel together
const withoutVisitor: FrameIdentity = { authToken: 'signed.jwt.token' };
// @ts-expect-error ...in both directions
const withoutToken: FrameIdentity = { visitor };
expect([withoutVisitor, withoutToken]).toHaveLength(2);
});
});
@@ -36,26 +36,6 @@ export function buildInnerFrameSrc(req: Request): string {
return `${path}?${params.toString()}`;
}
/**
* For the post-signin redirect, from the forwarding headers so it survives a proxy. Only
* ever points back at this page, so a spoofed Host breaks one visitor's return trip at
* worst — it can't redirect to another origin.
*/
export function buildAbsoluteChatUrl(req: Request): string {
const headerValue = (name: string) => {
const raw = req.headers[name];
return typeof raw === 'string' ? raw.trim() : undefined;
};
const protocol = headerValue('x-forwarded-proto') ?? req.protocol ?? 'http';
const host = headerValue('x-forwarded-host') ?? req.headers.host ?? '';
return `${protocol}://${host}${req.originalUrl}`;
}
export function readAuthCookie(req: Request): string | null {
const match = (req.headers.cookie ?? '').match(/(?:^|;\s*)n8n-auth=([^;]+)/);
return match ? match[1].trim() : null;
}
// Carries the AS access token across the single same-site redirect from the AS
// callback to the clean inner-frame URL, so `code`/`state` never reach the
// author-shaped chat widget. The token is otherwise already embedded in the
@@ -1,8 +1,11 @@
import type { IUser } from 'n8n-workflow';
import sanitizeHtml from 'sanitize-html';
import { CHAT_FRAME_SANDBOX } from './shell';
import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types';
import type {
AuthenticationChatOption,
ChatFrameIdentity,
LoadPreviousSessionChatOption,
} from './types';
function sanitizeUserInput(input: unknown): string {
// Only strings and numbers are meaningful display values; sanitize-html
@@ -172,9 +175,7 @@ export function createPage({
allowedFilesMimeTypes,
customCss,
enableStreaming,
shellInner,
authToken,
visitor,
frameIdentity,
}: {
instanceId: string;
webhookUrl?: string;
@@ -190,12 +191,12 @@ export function createPage({
allowedFilesMimeTypes?: string;
customCss?: string;
enableStreaming?: boolean;
/** True when this page renders inside the shell's sandboxed frame. */
shellInner?: boolean;
/** Sent as `x-auth-token` on every message, since the frame can't send cookies. */
authToken?: string;
/** Injected server-side: the frame can't fetch `/rest/login` for itself. */
visitor?: IUser;
/**
* Set only for the render inside the shell's sandboxed frame, carrying the identity the
* server resolved for it. Absent means the single-document render, which resolves its
* own identity in the browser (or has none, under `none`/`basicAuth`).
*/
frameIdentity?: ChatFrameIdentity;
}) {
const validAuthenticationOptions: AuthenticationChatOption[] = [
'none',
@@ -225,43 +226,18 @@ export function createPage({
const sanitizedInitialMessages = getSanitizedInitialMessages(initialMessages);
const sanitizedI18nConfig = getSanitizedI18nConfig(en || {});
// Inner render only, where the frame's opaque origin makes the `/rest/login` bootstrap
// below impossible. Field-by-field so nothing else on the user object reaches the page.
const injectedVisitor =
shellInner && visitor
? escapeForScriptContext({
id: visitor.id,
firstName: visitor.firstName,
lastName: visitor.lastName,
email: visitor.email,
})
: 'null';
const shellInner = frameIdentity !== undefined;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat</title>
<link href="https://cdn.jsdelivr.net/npm/normalize.css@8.0.1/normalize.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" />
<style>
html,
body,
#n8n-chat {
width: 100%;
height: 100%;
}
</style>
<style>${sanitizedCustomCss}</style>
</head>
<body>${shellInner ? innerBootstrapScript : ''}
<script type="module">
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
(async function () {
const authentication = '${sanitizedAuthentication}';
const injectedVisitor = ${injectedVisitor};
// How the page learns who the visitor is. The `/rest/login` bootstrap can only work on
// the real origin: from the frame's opaque origin the request carries no cookie, and the
// `/signin` it falls back to would render editor-ui inside the sandbox. So the inner
// render omits that branch outright — nothing at runtime decides it — and takes the
// identity resolved server-side, field by field so nothing else on the user object
// reaches the page. The unsplit render is reproduced verbatim, vestigial
// `injectedVisitor` indirection and all, so its page stays byte-for-byte what it was.
const identityBootstrap = !frameIdentity
? `const authentication = '${sanitizedAuthentication}';
const injectedVisitor = null;
let metadata;
if (injectedVisitor) {
metadata = { user: injectedVisitor };
@@ -289,7 +265,38 @@ export function createPage({
window.location.href = '/signin?redirect=' + window.location.href;
return;
}
}
}`
: `const metadata = { user: ${escapeForScriptContext({
id: frameIdentity.visitor.id,
firstName: frameIdentity.visitor.firstName,
lastName: frameIdentity.visitor.lastName,
email: frameIdentity.visitor.email,
})} };`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat</title>
<link href="https://cdn.jsdelivr.net/npm/normalize.css@8.0.1/normalize.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" />
<style>
html,
body,
#n8n-chat {
width: 100%;
height: 100%;
}
</style>
<style>${sanitizedCustomCss}</style>
</head>
<body>${shellInner ? innerBootstrapScript : ''}
<script type="module">
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
(async function () {
${identityBootstrap}
createChat({
mode: 'fullscreen',
@@ -301,7 +308,7 @@ export function createPage({
webhookConfig: {
headers: {
'X-Instance-Id': '${instanceId}',
${shellInner && authToken ? `'x-auth-token': ${escapeForScriptContext(authToken)},` : ''}
${frameIdentity ? `'x-auth-token': ${escapeForScriptContext(frameIdentity.authToken)},` : ''}
}
},
allowFileUploads: ${sanitizedAllowFileUploads},
@@ -1,10 +1,18 @@
import type { INode } from 'n8n-workflow';
import type { INode, IUser } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
const validOptions = ['notSupported', 'memory', 'manually'] as const;
export type AuthenticationChatOption = 'none' | 'basicAuth' | 'n8nUserAuth';
export type LoadPreviousSessionChatOption = (typeof validOptions)[number];
/**
* Identity resolved server-side for the shell's sandboxed frame: who the visitor is,
* plus the AS token their messages carry (the frame has no origin, so it can send no
* cookie of its own). The two only ever arrive together — the frame can resolve
* neither for itself.
*/
export type ChatFrameIdentity = { visitor: IUser; authToken: string };
function isValidLoadPreviousSessionOption(value: unknown): value is LoadPreviousSessionChatOption {
return typeof value === 'string' && (validOptions as readonly string[]).includes(value);
}