mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Establish chat session identity for credential resolution (#37041)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
+43
-8
@@ -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 () => {
|
||||
|
||||
+236
-33
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user