mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat: Remove Chat 'legacy' redirect and replace with OAuth2 flow (no-changelog) (#37207)
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
+21
-11
@@ -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';
|
||||
|
||||
|
||||
+26
-14
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user