From c96f329fde1b4e328d3a8ec11a13ce49d83703bf Mon Sep 17 00:00:00 2001 From: MHMD <45968454+dou-mhmd@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:15:10 +0000 Subject: [PATCH] feat(core): Establish chat session identity for credential resolution (#37041) --- .../trigger/ChatTrigger/ChatTrigger.node.ts | 64 +++-- .../trigger/ChatTrigger/GenericFunctions.ts | 130 ++++++++- .../__test__/ChatTrigger.node.test.ts | 51 +++- .../__test__/GenericFunctions.test.ts | 269 +++++++++++++++--- .../ChatTrigger/__test__/auth-token.test.ts | 82 ------ .../ChatTrigger/__test__/shell.test.ts | 115 +++++++- .../nodes/trigger/ChatTrigger/auth-token.ts | 70 ----- .../nodes/trigger/ChatTrigger/shell.ts | 67 ++++- 8 files changed, 629 insertions(+), 219 deletions(-) delete mode 100644 packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/auth-token.test.ts delete mode 100644 packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/auth-token.ts diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index d87e237f327..2cd693c7cd2 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -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({ diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts index 5d2dfa85a6d..92eed135355 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts @@ -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 { + 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 }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts index f52bcbc0c45..ac6370229a9 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts @@ -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 () => { diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/GenericFunctions.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/GenericFunctions.test.ts index 629added051..723ef55be36 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/GenericFunctions.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/GenericFunctions.test.ts @@ -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(); @@ -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(); + 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(); + 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(); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/auth-token.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/auth-token.test.ts deleted file mode 100644 index adbd68aa644..00000000000 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/auth-token.test.ts +++ /dev/null @@ -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 = {}) => - ({ - 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(); - }); -}); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/shell.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/shell.test.ts index fcb03f38836..1048c819e7a 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/shell.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/shell.test.ts @@ -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 = {}) => @@ -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', + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/auth-token.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/auth-token.ts deleted file mode 100644 index 612cf6c69e0..00000000000 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/auth-token.ts +++ /dev/null @@ -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; - 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, - }; -} diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/shell.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/shell.ts index cfba322e585..4bbcf6bf001 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/shell.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/shell.ts @@ -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)); +}