feat(core): Establish chat session identity for credential resolution (#37041)

This commit is contained in:
MHMD
2026-08-27 07:15:10 +00:00
committed by GitHub
parent 6826730318
commit c96f329fde
8 changed files with 629 additions and 219 deletions
@@ -27,9 +27,12 @@ import * as a from 'node:assert';
import { ChatTriggerConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { generateChatUserAuthToken } from './auth-token';
import { cssVariables } from './constants';
import { validateAuth } from './GenericFunctions';
import {
establishChatSessionIdentity,
resolveInnerFrameIdentity,
validateAuth,
} from './GenericFunctions';
import {
buildAbsoluteChatUrl,
buildInnerFrameSrc,
@@ -925,25 +928,35 @@ export class ChatTrigger extends Node {
if (isChatOAuth2Enabled() && authentication === 'n8nUserAuth') {
shellInner = isShellInnerRequest(req);
// The frame can't fetch `/rest/login` for itself, so resolve the visitor here.
// Same-site either way: the frame's first GET is issued by the shell, before
// any opaque document exists.
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 };
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 };
}
const ready = await establishChatSessionIdentity(ctx, resourceUrl);
if (!ready) {
return { noWebhookResponse: true };
}
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res
.status(200)
@@ -952,11 +965,22 @@ export class ChatTrigger extends Node {
return { noWebhookResponse: true };
}
// Inner frame: pick up the AS token the outer shell already obtained, via the
// one-hop cookie. Never runs the OAuth2 handshake itself — this opaque-origin
// document can't receive the AS's session-cookie check, so a redirect to
// sign-in/consent would render editor-ui inside the sandboxed frame.
const identity = await resolveInnerFrameIdentity(ctx, resourceUrl);
if (!identity) {
res.status(401).send('Session expired. Please reload the page.');
res.end();
return { noWebhookResponse: true };
}
visitor = identity.visitor;
authToken = identity.authToken;
// By header as well as by the iframe's attribute, so the document has no
// origin even if the attribute is ever stripped.
res.setHeader('Content-Security-Policy', `sandbox ${CHAT_FRAME_SANDBOX}`);
// Cookies aren't sent from an opaque origin; messages carry this instead.
authToken = generateChatUserAuthToken(ctx.getNode(), visitor);
}
const page = createPage({
@@ -1,9 +1,14 @@
import basicAuth from 'basic-auth';
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject, IUser, IWebhookFunctions } from 'n8n-workflow';
import { verifyChatUserAuthToken } from './auth-token';
import { ChatTriggerAuthorizationError } from './error';
import { isChatOAuth2Enabled } from './shell';
import {
clearChatOAuthToken,
isChatOAuth2Enabled,
readChatOAuthToken,
setChatOAuthToken,
} from './shell';
import type { AuthenticationChatOption } from './types';
export async function validateAuth(context: IWebhookFunctions) {
@@ -52,10 +57,19 @@ export async function validateAuth(context: IWebhookFunctions) {
// The sandboxed frame carries this instead of the session cookie, which an opaque
// origin never sends. Checked first so the frame doesn't depend on that cookie.
// Verified against n8n's internal AS (not just decoded) so the token also seeds
// the run's identity for private-credential resolution.
if (isChatOAuth2Enabled()) {
const chatToken = headers['x-auth-token'];
if (typeof chatToken === 'string' && chatToken) {
if (verifyChatUserAuthToken(chatToken, context.getNode())) return;
const resourceUrl = context.getWebhookResourceUrl('default');
if (resourceUrl) {
const validation = await context.validateN8nOAuth2Token(chatToken, resourceUrl);
if (validation.valid) {
await context.establishTriggerIdentity(chatToken, resourceUrl, validation.user.id);
return;
}
}
throw new ChatTriggerAuthorizationError(401, 'Invalid authentication token');
}
}
@@ -75,3 +89,111 @@ export async function validateAuth(context: IWebhookFunctions) {
return;
}
/**
* Runs the AS handshake — `beginN8nOAuth2Flow` → (AS redirect) →
* `completeN8nOAuth2Flow` — on the trusted shell's own GET, i.e. a normal
* top-level document with real cookies. Must never be called for the
* sandboxed frame's request: that document has no origin, so it can't
* receive the AS's session-cookie check, and any consent/sign-in page the AS
* falls back to would then render editor-ui inside the opaque frame.
*
* On success, stashes the AS token in the one-hop `n8n-chat-oauth` cookie and
* returns `true` — the caller renders the shell, whose frame's own GET picks
* the cookie up via `resolveInnerFrameIdentity`. Returns `false` after
* already sending a redirect/error response — the caller must abort with
* `noWebhookResponse`.
*/
export async function establishChatSessionIdentity(
context: IWebhookFunctions,
resourceUrl: string,
): Promise<boolean> {
const req = context.getRequestObject();
const res = context.getResponseObject();
const { code, state } = req.query;
if (typeof req.query.error === 'string') {
// The AS returned an error (e.g. the user denied consent). Restarting the flow
// here would loop straight back to the same denial, so stop and report.
context.logger.warn('Chat OAuth2 authorization was denied or failed', {
error: req.query.error,
});
res.status(403).send('Access denied');
res.end();
return false;
}
if (typeof code === 'string' && typeof state === 'string') {
// Handle the AS callback. Stash the token in a one-hop cookie and redirect to
// the clean shell URL — the follow-up GET (below) picks up the cookie and
// renders the shell, whose frame then consumes it via `resolveInnerFrameIdentity`.
try {
const result = await context.completeN8nOAuth2Flow(code, state);
if (result.valid) {
setChatOAuthToken(res, req, resourceUrl, result.token);
const redirectPath = req.originalUrl.split('?')[0];
res.writeHead(302, { Location: redirectPath });
res.end();
return false;
}
// Fall through to restart the OAuth2 flow if the callback is invalid.
context.logger.warn('Chat OAuth2 flow failed, restarting', { reason: result.reason });
} catch (error) {
// Ignore errors and fall through to the redirect below.
context.logger.warn('Chat OAuth2 flow failed, restarting', { error });
}
} else {
// Not an AS callback. If we just completed the flow, the token rides in the
// one-hop cookie set on the redirect above — leave it for the frame's own GET
// to consume, just confirm it's still good before rendering the shell around it.
const cookieToken = readChatOAuthToken(req);
if (cookieToken) {
const validation = await context.validateN8nOAuth2Token(cookieToken, resourceUrl);
if (validation.valid) {
return true;
}
// Stale/invalid cookie — fall through to restart the OAuth2 flow.
}
}
try {
const authorizationUrl = await context.beginN8nOAuth2Flow(resourceUrl);
res.writeHead(302, { Location: authorizationUrl });
res.end();
} catch (error) {
// Can't build the authorization URL — nothing to redirect to, so abort.
context.logger.warn('Chat OAuth2 flow failed', { error });
throw new UnexpectedError('Chat OAuth2 flow failed');
}
return false;
}
/**
* Resolves the visitor's identity for the sandboxed frame's own GET, purely by
* reading the one-hop cookie the shell's `establishChatSessionIdentity` left
* behind. Never runs the OAuth2 handshake itself — the frame's opaque origin
* can't receive the AS's session-cookie check, so `beginN8nOAuth2Flow` here
* would just redirect this document to a sign-in/consent page it can't render.
*
* Returns `null` when the cookie is missing or invalid; the caller should
* fail the request rather than start a flow it can't complete.
*/
export async function resolveInnerFrameIdentity(
context: IWebhookFunctions,
resourceUrl: string,
): Promise<{ visitor: IUser; authToken: string } | null> {
const req = context.getRequestObject();
const res = context.getResponseObject();
const cookieToken = readChatOAuthToken(req);
if (!cookieToken) {
return null;
}
clearChatOAuthToken(res, req, resourceUrl);
const validation = await context.validateN8nOAuth2Token(cookieToken, resourceUrl);
if (!validation.valid) {
return null;
}
return { visitor: validation.user, authToken: cookieToken };
}
@@ -6,11 +6,17 @@ import { mock } from 'vitest-mock-extended';
import { ChatTrigger } from '../ChatTrigger.node';
import { ChatTriggerAuthorizationError } from '../error';
import { validateAuth } from '../GenericFunctions';
import {
establishChatSessionIdentity,
resolveInnerFrameIdentity,
validateAuth,
} from '../GenericFunctions';
import type { LoadPreviousSessionChatOption } from '../types';
vi.mock('../GenericFunctions', () => ({
validateAuth: vi.fn(),
establishChatSessionIdentity: vi.fn(),
resolveInnerFrameIdentity: vi.fn(),
}));
const INBOUND_TRIGGER_AUTHENTICATION_BUILDER_HINT =
@@ -418,15 +424,9 @@ describe('ChatTrigger Node', () => {
const renderedPage = () => vi.mocked(mockResponse.send).mock.calls.at(-1)?.[0] as string;
beforeEach(() => {
// `generateChatUserAuthToken` needs the instance's hmac secret; everything
// else in the node still wants the chat config.
vi.mocked(Container.get).mockImplementation(((token: unknown) =>
token === ChatTriggerConfig
? chatTriggerConfig
: { hmacSignatureSecret: 'test-secret' }) as never);
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({
@@ -436,6 +436,11 @@ describe('ChatTrigger Node', () => {
typeVersion: 1.4,
webhookId: 'webhook-1',
} as never);
vi.mocked(establishChatSessionIdentity).mockResolvedValue(true);
vi.mocked(resolveInnerFrameIdentity).mockResolvedValue({
visitor,
authToken: 'as-token',
});
mockRequest.headers = {
'x-forwarded-proto': 'http',
@@ -489,6 +494,36 @@ describe('ChatTrigger Node', () => {
expect(renderedPage()).toContain("'x-auth-token'");
});
// The AS handshake must run on the outer, top-level document (real cookies) and
// never on the sandboxed frame's own request — a redirect to sign-in/consent from
// inside that opaque-origin frame would render editor-ui inside it and crash.
it('does not render the shell while the outer AS handshake is still in flight', async () => {
vi.mocked(establishChatSessionIdentity).mockResolvedValue(false);
const result = await renderSetupPage();
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponse.send).not.toHaveBeenCalled();
expect(resolveInnerFrameIdentity).not.toHaveBeenCalled();
});
it("fails the frame's own request instead of starting a new OAuth flow when the one-hop cookie is missing", async () => {
mockRequest.query = { n8nShellInner: '1' };
mockRequest.headers = {
'x-forwarded-proto': 'http',
host: 'localhost:5678',
cookie: 'n8n-auth=session-token',
'sec-fetch-dest': 'iframe',
};
vi.mocked(resolveInnerFrameIdentity).mockResolvedValue(null);
const result = await renderSetupPage();
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
});
// Honouring the flag on a top-level navigation would let a visitor skip the
// trusted document, and with it the connect UI that lives there.
it('still renders the shell for a hand-typed inner URL', async () => {
@@ -1,10 +1,12 @@
import { Container } from '@n8n/di';
import type { ICredentialDataDecryptedObject, INode, IWebhookFunctions } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { generateChatUserAuthToken } from '../auth-token';
import { ChatTriggerAuthorizationError } from '../error';
import { validateAuth } from '../GenericFunctions';
import {
establishChatSessionIdentity,
resolveInnerFrameIdentity,
validateAuth,
} from '../GenericFunctions';
describe('validateAuth', () => {
const mockContext = mock<IWebhookFunctions>();
@@ -166,20 +168,21 @@ describe('validateAuth', () => {
});
// Messages sent from the sandboxed chat frame can't carry the session cookie:
// the document has no origin, so `SameSite=Lax` never sends it.
// the document has no origin, so `SameSite=Lax` never sends it. The frame
// carries an AS-issued token instead, verified — and used to seed the run's
// identity — through the shared trigger-identity pipeline.
describe('x-auth-token from the sandboxed frame', () => {
const node = {
id: 'node-1',
name: 'Chat Trigger',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.4,
webhookId: 'webhook-1',
} as INode;
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
const user = {
id: 'user-1',
email: 'user@example.com',
firstName: 'Test',
lastName: 'User',
};
beforeEach(() => {
vi.mocked(Container.get).mockReturnValue({ hmacSignatureSecret: 'test-secret' } as never);
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getNode.mockReturnValue(node);
mockContext.getWebhookResourceUrl.mockReturnValue(resourceUrl);
vi.stubEnv('N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2', 'true');
});
@@ -187,47 +190,247 @@ describe('validateAuth', () => {
vi.unstubAllEnvs();
});
it('should pass with a token minted for this node', async () => {
mockContext.getHeaderData.mockReturnValue({
'x-auth-token': generateChatUserAuthToken(node, {
id: 'user-1',
email: 'user@example.com',
firstName: 'Test',
lastName: 'User',
}),
});
it('establishes the trigger identity and passes for a valid token', async () => {
mockContext.getHeaderData.mockReturnValue({ 'x-auth-token': 'as-token' });
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user });
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
expect(mockContext.validateN8nOAuth2Token).toHaveBeenCalledWith('as-token', resourceUrl);
expect(mockContext.establishTriggerIdentity).toHaveBeenCalledWith(
'as-token',
resourceUrl,
user.id,
);
expect(mockContext.validateCookieAuth).not.toHaveBeenCalled();
});
it('should throw 401 for a token it did not mint', async () => {
mockContext.getHeaderData.mockReturnValue({ 'x-auth-token': 'not.a.token' });
it('should throw 401 for a token the AS rejects', async () => {
mockContext.getHeaderData.mockReturnValue({ 'x-auth-token': 'not-a-token' });
mockContext.validateN8nOAuth2Token.mockResolvedValue({
valid: false,
reason: 'invalid_token',
});
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'Invalid authentication token',
});
expect(mockContext.establishTriggerIdentity).not.toHaveBeenCalled();
});
it('should throw 401 when the resource URL cannot be resolved', async () => {
mockContext.getHeaderData.mockReturnValue({ 'x-auth-token': 'as-token' });
mockContext.getWebhookResourceUrl.mockReturnValue(undefined);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'Invalid authentication token',
});
expect(mockContext.validateN8nOAuth2Token).not.toHaveBeenCalled();
});
// The header only means anything on the split page, so with the flag off it
// must not become a second way in.
it('should ignore the header when the flag is off', async () => {
vi.stubEnv('N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2', 'false');
mockContext.getHeaderData.mockReturnValue({
'x-auth-token': generateChatUserAuthToken(node, {
id: 'user-1',
email: 'user@example.com',
firstName: 'Test',
lastName: 'User',
}),
});
mockContext.getHeaderData.mockReturnValue({ 'x-auth-token': 'as-token' });
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'User not authenticated!',
});
expect(mockContext.validateN8nOAuth2Token).not.toHaveBeenCalled();
});
});
});
});
describe('establishChatSessionIdentity', () => {
const mockContext = mock<IWebhookFunctions>();
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
const user = {
id: 'user-1',
email: 'visitor@example.com',
firstName: 'Vi',
lastName: 'Sitor',
};
const mockRes = () => {
const res = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
end: vi.fn().mockReturnThis(),
writeHead: vi.fn().mockReturnThis(),
cookie: vi.fn().mockReturnThis(),
clearCookie: vi.fn().mockReturnThis(),
};
return res as never;
};
beforeEach(() => {
vi.clearAllMocks();
mockContext.getResponseObject.mockReturnValue(mockRes());
mockContext.logger = { warn: vi.fn() } as never;
});
it('starts the AS flow on a fresh request with no cookie', async () => {
mockContext.getRequestObject.mockReturnValue({
query: {},
headers: {},
originalUrl: '/webhook/abc/chat',
} as never);
mockContext.beginN8nOAuth2Flow.mockResolvedValue('https://as.example.com/authorize');
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(mockContext.beginN8nOAuth2Flow).toHaveBeenCalledWith(resourceUrl);
expect(mockContext.getResponseObject().writeHead).toHaveBeenCalledWith(302, {
Location: 'https://as.example.com/authorize',
});
});
it('completes the AS callback, hands the token off via a one-hop cookie, and redirects to the clean shell URL', async () => {
mockContext.getRequestObject.mockReturnValue({
query: { code: 'auth-code', state: 'flow-state' },
headers: {},
originalUrl: '/webhook/abc/chat?code=auth-code&state=flow-state',
} as never);
mockContext.completeN8nOAuth2Flow.mockResolvedValue({ valid: true, token: 'as-token', user });
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(mockContext.completeN8nOAuth2Flow).toHaveBeenCalledWith('auth-code', 'flow-state');
expect(mockContext.getResponseObject().cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
'as-token',
expect.objectContaining({ httpOnly: true }),
);
// Redirects to the plain top-level URL — never to the inner-frame URL, which
// would render editor-ui/the AS callback inside the sandboxed frame.
expect(mockContext.getResponseObject().writeHead).toHaveBeenCalledWith(302, {
Location: '/webhook/abc/chat',
});
});
it('restarts the flow when the callback is invalid', async () => {
mockContext.getRequestObject.mockReturnValue({
query: { code: 'auth-code', state: 'stale-state' },
headers: {},
originalUrl: '/webhook/abc/chat?code=auth-code&state=stale-state',
} as never);
mockContext.completeN8nOAuth2Flow.mockResolvedValue({ valid: false, reason: 'expired' });
mockContext.beginN8nOAuth2Flow.mockResolvedValue('https://as.example.com/authorize');
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(mockContext.beginN8nOAuth2Flow).toHaveBeenCalledWith(resourceUrl);
});
it('confirms readiness from the one-hop cookie without clearing it, leaving it for the frame', async () => {
mockContext.getRequestObject.mockReturnValue({
query: {},
headers: { cookie: 'n8n-chat-oauth=as-token' },
originalUrl: '/webhook/abc/chat',
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user });
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(true);
expect(mockContext.validateN8nOAuth2Token).toHaveBeenCalledWith('as-token', resourceUrl);
expect(mockContext.getResponseObject().clearCookie).not.toHaveBeenCalled();
});
it('restarts the flow when the one-hop cookie fails to validate', async () => {
mockContext.getRequestObject.mockReturnValue({
query: {},
headers: { cookie: 'n8n-chat-oauth=stale-token' },
originalUrl: '/webhook/abc/chat',
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: false, reason: 'invalid_token' });
mockContext.beginN8nOAuth2Flow.mockResolvedValue('https://as.example.com/authorize');
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(mockContext.beginN8nOAuth2Flow).toHaveBeenCalledWith(resourceUrl);
});
it('reports denial without restarting the flow', async () => {
mockContext.getRequestObject.mockReturnValue({
query: { error: 'access_denied' },
headers: {},
originalUrl: '/webhook/abc/chat?error=access_denied',
} as never);
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(mockContext.getResponseObject().status).toHaveBeenCalledWith(403);
expect(mockContext.beginN8nOAuth2Flow).not.toHaveBeenCalled();
});
});
describe('resolveInnerFrameIdentity', () => {
const mockContext = mock<IWebhookFunctions>();
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
const user = {
id: 'user-1',
email: 'visitor@example.com',
firstName: 'Vi',
lastName: 'Sitor',
};
const mockRes = () => {
const res = {
clearCookie: vi.fn().mockReturnThis(),
};
return res as never;
};
beforeEach(() => {
vi.clearAllMocks();
mockContext.getResponseObject.mockReturnValue(mockRes());
});
it('resolves the visitor from the one-hop cookie and clears it', async () => {
mockContext.getRequestObject.mockReturnValue({
headers: { cookie: 'n8n-chat-oauth=as-token' },
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user });
const result = await resolveInnerFrameIdentity(mockContext, resourceUrl);
expect(result).toEqual({ visitor: user, authToken: 'as-token' });
expect(mockContext.validateN8nOAuth2Token).toHaveBeenCalledWith('as-token', resourceUrl);
expect(mockContext.getResponseObject().clearCookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
expect.objectContaining({ httpOnly: true }),
);
});
it('returns null, without starting a new flow, when there is no cookie', async () => {
mockContext.getRequestObject.mockReturnValue({ headers: {} } as never);
const result = await resolveInnerFrameIdentity(mockContext, resourceUrl);
expect(result).toBeNull();
expect(mockContext.beginN8nOAuth2Flow).not.toHaveBeenCalled();
});
it('returns null, without starting a new flow, when the cookie fails to validate', async () => {
mockContext.getRequestObject.mockReturnValue({
headers: { cookie: 'n8n-chat-oauth=stale-token' },
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: false, reason: 'invalid_token' });
const result = await resolveInnerFrameIdentity(mockContext, resourceUrl);
expect(result).toBeNull();
expect(mockContext.beginN8nOAuth2Flow).not.toHaveBeenCalled();
});
});
@@ -1,82 +0,0 @@
import { Container } from '@n8n/di';
import jwt from 'jsonwebtoken';
import type { INode, IUser } from 'n8n-workflow';
import { generateChatUserAuthToken, verifyChatUserAuthToken } from '../auth-token';
const SECRET = 'test-hmac-secret';
const node = (overrides: Partial<INode> = {}) =>
({
id: 'node-1',
name: 'When chat message received',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.4,
webhookId: 'webhook-1',
position: [0, 0],
parameters: {},
...overrides,
}) as INode;
const visitor: IUser = {
id: 'user-1',
email: 'visitor@example.com',
firstName: 'Vi',
lastName: 'Sitor',
};
describe('chat user auth token', () => {
beforeEach(() => {
vi.mocked(Container.get).mockReturnValue({ hmacSignatureSecret: SECRET } as never);
});
it('round-trips the visitor', () => {
const token = generateChatUserAuthToken(node(), visitor);
expect(verifyChatUserAuthToken(token, node())).toEqual(visitor);
});
it('rejects a token signed with another secret', () => {
const token = jwt.sign(
{ ...visitor, sub: visitor.id, nid: 'node-1', wid: 'webhook-1' },
'other-secret',
{ algorithm: 'HS256' },
);
expect(verifyChatUserAuthToken(token, node())).toBeNull();
});
it('rejects garbage', () => {
expect(verifyChatUserAuthToken('not-a-token', node())).toBeNull();
});
it('rejects an expired token', () => {
const token = jwt.sign(
{ ...visitor, sub: visitor.id, nid: 'node-1', wid: 'webhook-1' },
SECRET,
{ algorithm: 'HS256', expiresIn: -1 },
);
expect(verifyChatUserAuthToken(token, node())).toBeNull();
});
// The `nid`/`wid` claims are what stop a token minted for one chat from being
// replayed against another chat on the same instance.
it('rejects a token minted for a different node', () => {
const token = generateChatUserAuthToken(node(), visitor);
expect(verifyChatUserAuthToken(token, node({ id: 'node-2' }))).toBeNull();
});
it('rejects a token minted for a different webhook', () => {
const token = generateChatUserAuthToken(node(), visitor);
expect(verifyChatUserAuthToken(token, node({ webhookId: 'webhook-2' }))).toBeNull();
});
it('rejects a token missing the expected claims', () => {
const token = jwt.sign({ sub: visitor.id }, SECRET, { algorithm: 'HS256' });
expect(verifyChatUserAuthToken(token, node())).toBeNull();
});
});
@@ -1,11 +1,14 @@
import type { Request } from 'express';
import type { Request, Response } from 'express';
import {
buildAbsoluteChatUrl,
buildInnerFrameSrc,
clearChatOAuthToken,
isChatOAuth2Enabled,
isShellInnerRequest,
readAuthCookie,
readChatOAuthToken,
setChatOAuthToken,
} from '../shell';
const request = (overrides: Partial<Request> = {}) =>
@@ -122,3 +125,113 @@ describe('readAuthCookie', () => {
expect(readAuthCookie(req)).toBeNull();
});
});
describe('chat OAuth2 one-hop cookie', () => {
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
const response = () =>
({
cookie: vi.fn(),
clearCookie: vi.fn(),
}) as unknown as Response;
it('sets the cookie scoped to the resource path, httpOnly and short-lived', () => {
const req = request({ headers: { host: 'localhost:5678' }, protocol: 'http' });
const res = response();
setChatOAuthToken(res, req, resourceUrl, 'as-token');
expect(res.cookie).toHaveBeenCalledWith('n8n-chat-oauth', 'as-token', {
httpOnly: true,
sameSite: 'lax',
secure: false,
path: '/webhook/abc/chat',
maxAge: 60_000,
});
});
it('marks the cookie secure over https (honouring x-forwarded-proto)', () => {
const req = request({ headers: { 'x-forwarded-proto': 'https' }, protocol: 'http' });
const res = response();
setChatOAuthToken(res, req, resourceUrl, 'as-token');
expect(res.cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
'as-token',
expect.objectContaining({ secure: true }),
);
});
// A multi-hop proxy chain sends the closest proxy's scheme first (e.g. the
// external request was https, an internal hop back to the app is http) —
// only that first value decides whether the client's own leg was secure.
it('marks the cookie secure from the first hop of a comma-separated proxy chain', () => {
const req = request({
headers: { 'x-forwarded-proto': 'https, http' },
protocol: 'http',
});
const res = response();
setChatOAuthToken(res, req, resourceUrl, 'as-token');
expect(res.cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
'as-token',
expect.objectContaining({ secure: true }),
);
});
it('marks the cookie secure from the first hop when the header repeats as an array', () => {
const req = request({
headers: { 'x-forwarded-proto': ['https', 'http'] },
protocol: 'http',
});
const res = response();
setChatOAuthToken(res, req, resourceUrl, 'as-token');
expect(res.cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
'as-token',
expect.objectContaining({ secure: true }),
);
});
it('reads the cookie back from the raw header', () => {
const req = request({ headers: { cookie: 'other=1; n8n-chat-oauth=as-token; more=2' } });
expect(readChatOAuthToken(req)).toBe('as-token');
});
it('returns null when the cookie is absent', () => {
expect(readChatOAuthToken(request({ headers: { cookie: 'other=1' } }))).toBeNull();
expect(readChatOAuthToken(request())).toBeNull();
});
it('decodes a percent-encoded value', () => {
const req = request({ headers: { cookie: 'n8n-chat-oauth=a%2Fb' } });
expect(readChatOAuthToken(req)).toBe('a/b');
});
it('treats an undecodable value as no cookie', () => {
const req = request({ headers: { cookie: 'n8n-chat-oauth=%' } });
expect(readChatOAuthToken(req)).toBeNull();
});
it('clears the cookie scoped to the same path', () => {
const req = request({ headers: { host: 'localhost:5678' }, protocol: 'http' });
const res = response();
clearChatOAuthToken(res, req, resourceUrl);
expect(res.clearCookie).toHaveBeenCalledWith('n8n-chat-oauth', {
httpOnly: true,
sameSite: 'lax',
secure: false,
path: '/webhook/abc/chat',
});
});
});
@@ -1,70 +0,0 @@
import { Container } from '@n8n/di';
import jwt from 'jsonwebtoken';
import { InstanceSettings } from 'n8n-core';
import type { INode, IUser } from 'n8n-workflow';
/** Long enough for a conversation, short enough to limit a leak out of the frame. */
const CHAT_USER_AUTH_TOKEN_TTL_SECONDS = 60 * 60;
type ChatUserAuthClaims = {
sub: string;
email: string;
firstName: string;
lastName: string;
nid: string;
wid: string;
};
function isChatUserAuthClaims(value: unknown): value is ChatUserAuthClaims {
if (typeof value !== 'object' || value === null) return false;
const c = value as Record<string, unknown>;
return (
typeof c.sub === 'string' &&
typeof c.email === 'string' &&
typeof c.firstName === 'string' &&
typeof c.lastName === 'string' &&
typeof c.nid === 'string' &&
typeof c.wid === 'string'
);
}
/**
* The token the sandboxed frame sends as `x-auth-token` on every message: the frame's
* opaque origin means the `n8n-auth` cookie is never sent from it (null site-for-cookies
* + `SameSite=Lax`). The `nid`/`wid` claims stop it being replayed against another chat.
*/
export function generateChatUserAuthToken(node: INode, user: IUser): string {
const secret = Container.get(InstanceSettings).hmacSignatureSecret;
const payload: ChatUserAuthClaims = {
sub: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
nid: node.id,
wid: node.webhookId ?? '',
};
return jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: CHAT_USER_AUTH_TOKEN_TTL_SECONDS,
});
}
/** Returns the encoded user, or `null` on any failure — the caller decides how to surface it. */
export function verifyChatUserAuthToken(token: string, node: INode): IUser | null {
const secret = Container.get(InstanceSettings).hmacSignatureSecret;
let claims: unknown;
try {
claims = jwt.verify(token, secret, { algorithms: ['HS256'] });
} catch {
return null;
}
if (!isChatUserAuthClaims(claims)) return null;
if (claims.nid !== node.id) return null;
if (claims.wid !== (node.webhookId ?? '')) return null;
return {
id: claims.sub,
email: claims.email,
firstName: claims.firstName,
lastName: claims.lastName,
};
}
@@ -1,4 +1,4 @@
import type { Request } from 'express';
import type { Request, Response } from 'express';
/** Opt-in: with the flag off the hosted chat page renders as a single document, as before. */
export function isChatOAuth2Enabled(): boolean {
@@ -55,3 +55,68 @@ 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
// frame's HTML (sent back as `x-auth-token` on every message), so this cookie
// is not a new exposure.
const CHAT_OAUTH_COOKIE_NAME = 'n8n-chat-oauth';
/**
* Derive `secure` from the request scheme (honouring x-forwarded-proto) rather
* than config, so the cookie is actually sent back over http in dev while
* staying Secure over https.
*/
function isSecureRequest(req: Request): boolean {
const forwardedProto = req.headers['x-forwarded-proto'];
// A proxy chain sends this as a comma-separated list (closest proxy first), and
// Node normalises a repeated header into an array — handle both, and take only
// the first hop so a later "http" in the chain can't mask an https client leg.
const firstValue = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
const proto = firstValue?.split(',')[0]?.trim() || req.protocol;
return proto === 'https';
}
function chatOAuthCookieOptions(req: Request, resourceUrl: string) {
return {
httpOnly: true,
sameSite: 'lax' as const, // must be Lax: sent on our own top-level redirect → GET
secure: isSecureRequest(req),
path: new URL(resourceUrl).pathname,
};
}
export function setChatOAuthToken(
res: Response,
req: Request,
resourceUrl: string,
token: string,
): void {
res.cookie(CHAT_OAUTH_COOKIE_NAME, token, {
...chatOAuthCookieOptions(req, resourceUrl),
maxAge: 60_000, // one redirect hop; short by design
});
}
/**
* Decode a cookie value, or `null` when it isn't valid percent-encoding. A
* value we can't read is treated as no cookie at all rather than throwing out
* of the request.
*/
function decodeCookieValue(value: string): string | null {
try {
return decodeURIComponent(value.trim());
} catch {
return null;
}
}
export function readChatOAuthToken(req: Request): string | null {
const match = (req.headers.cookie ?? '').match(/(?:^|;\s*)n8n-chat-oauth=([^;]+)/);
return match ? decodeCookieValue(match[1]) : null;
}
export function clearChatOAuthToken(res: Response, req: Request, resourceUrl: string): void {
res.clearCookie(CHAT_OAUTH_COOKIE_NAME, chatOAuthCookieOptions(req, resourceUrl));
}