feat: Refresh chat OAuth2 access tokens periodically (no-changelog) (#37541)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: MHMD <45968454+dou-mhmd@users.noreply.github.com>
This commit is contained in:
James Martin
2026-09-02 11:27:29 +00:00
committed by GitHub
co-authored by Claude Opus 5 MHMD
parent 6912024af8
commit d997348352
21 changed files with 1489 additions and 85 deletions
@@ -31,13 +31,16 @@ import { Container } from '@n8n/di';
import { cssVariables } from './constants';
import {
establishChatSessionIdentity,
handleChatTokenRefresh,
resolveInnerFrameIdentity,
validateAuth,
} from './GenericFunctions';
import {
buildChatRefreshUrl,
buildInnerFrameSrc,
CHAT_FRAME_SANDBOX,
isChatOAuth2Enabled,
isChatRefreshRequest,
isShellInnerRequest,
} from './shell';
import { createPage, createShellPage } from './templates';
@@ -943,20 +946,37 @@ export class ChatTrigger extends Node {
throw new NodeOperationError(ctx.getNode(), 'Default webhook url not set');
}
// The shell's token-refresh leg, ahead of any render: it answers with JSON,
// not a page, and authenticates itself from its own httpOnly cookie rather
// than from the handshake below. A GET because a POST to this path reaches
// the `default` webhook — the chat message endpoint — instead.
if (isChatRefreshRequest(req)) {
await handleChatTokenRefresh(ctx, resourceUrl);
return { noWebhookResponse: true };
}
if (!isShellInnerRequest(req)) {
// Outer shell: the AS handshake runs here — a normal top-level document with
// real cookies, unlike the sandboxed, opaque-origin frame this shell is about
// to create. It is the only gate: a visitor without an editor session is
// authenticated by the flow rather than bounced to sign-in ahead of it.
const ready = await establishChatSessionIdentity(ctx, resourceUrl);
if (!ready) {
const session = await establishChatSessionIdentity(ctx, resourceUrl);
if (!session) {
return { noWebhookResponse: true };
}
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res
.status(200)
.send(createShellPage({ iframeSrc: buildInnerFrameSrc(req) }))
.send(
createShellPage({
iframeSrc: buildInnerFrameSrc(req),
refresh: {
url: buildChatRefreshUrl(req),
expiresIn: session.expiresIn,
},
}),
)
.end();
return { noWebhookResponse: true };
}
@@ -7,9 +7,25 @@ import {
clearChatOAuthToken,
isChatOAuth2Enabled,
readChatOAuthToken,
readChatRefreshToken,
setChatOAuthToken,
setChatRefreshToken,
} from './shell';
import type { AuthenticationChatOption, ChatFrameIdentity } from './types';
import type { AuthenticationChatOption, ChatFrameIdentity, ChatShellSession } from './types';
/** Absolute expiry for an access token the AS just minted, from the duration it reported. */
function expiryFrom(expiresIn: number): number {
return Date.now() + expiresIn * 1000;
}
/**
* Seconds left on an absolute expiry, on the server's own clock. Converting here rather
* than in the page is the point: the page must never subtract its own `Date.now()` from
* a timestamp this process produced.
*/
function secondsUntil(expiresAt: number): number {
return Math.max(0, (expiresAt - Date.now()) / 1000);
}
export async function validateAuth(context: IWebhookFunctions) {
const authentication = context.getNodeParameter(
@@ -102,16 +118,20 @@ export async function validateAuth(context: IWebhookFunctions) {
* 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`.
* On success, stashes the AS token in the one-hop `n8n-chat-oauth` cookie, the
* grant's refresh token in the long-lived httpOnly `n8n-chat-oauth-refresh`
* cookie, and returns the session — the caller renders the shell around it,
* whose frame's own GET picks the one-hop cookie up via
* `resolveInnerFrameIdentity`. Returns `null` after already sending a
* redirect/error response — the caller must abort with `noWebhookResponse`.
*
* The returned session carries the expiry only. The refresh token stays in its
* cookie and never reaches the caller, so it can't reach a document either.
*/
export async function establishChatSessionIdentity(
context: IWebhookFunctions,
resourceUrl: string,
): Promise<boolean> {
): Promise<ChatShellSession | null> {
const req = context.getRequestObject();
const res = context.getResponseObject();
const { code, state } = req.query;
@@ -124,7 +144,7 @@ export async function establishChatSessionIdentity(
});
res.status(403).send('Access denied');
res.end();
return false;
return null;
}
if (typeof code === 'string' && typeof state === 'string') {
@@ -134,11 +154,15 @@ export async function establishChatSessionIdentity(
try {
const result = await context.completeN8nOAuth2Flow(code, state);
if (result.valid) {
setChatOAuthToken(res, req, resourceUrl, result.token);
setChatOAuthToken(res, req, resourceUrl, {
token: result.token,
expiresAt: expiryFrom(result.expiresIn),
});
setChatRefreshToken(res, req, resourceUrl, result.refreshToken);
const redirectPath = req.originalUrl.split('?')[0];
res.writeHead(302, { Location: redirectPath });
res.end();
return false;
return null;
}
// Fall through to restart the OAuth2 flow if the callback is invalid.
context.logger.warn('Chat OAuth2 flow failed, restarting', { reason: result.reason });
@@ -150,13 +174,20 @@ export async function establishChatSessionIdentity(
// 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);
const session = readChatOAuthToken(req);
if (session) {
const validation = await context.validateN8nOAuth2Token(session.token, resourceUrl);
if (validation.valid) {
return true;
return { expiresIn: secondsUntil(session.expiresAt) };
}
// Stale/invalid cookie — fall through to restart the OAuth2 flow.
} else {
// A reload mid-conversation: the one-hop cookie is long gone, but the grant
// is still live in the refresh cookie. Rotating is cheaper than a full
// redirect round trip through the AS, and keeps the visitor on the page.
const refreshed = await refreshChatSession(context, resourceUrl);
if (refreshed) return { expiresIn: refreshed.expiresIn };
// Refresh failed — fall through to restart the OAuth2 flow, which handles it.
}
}
@@ -169,7 +200,83 @@ export async function establishChatSessionIdentity(
context.logger.warn('Chat OAuth2 flow failed', { error });
throw new UnexpectedError('Chat OAuth2 flow failed');
}
return false;
return null;
}
/**
* Rotate the grant behind the refresh cookie into a fresh pair and re-set both
* cookies. Returns the fresh access token and its lifetime, or `null` when there is
* no refresh cookie or the AS refuses it — the caller decides whether that means
* restart the flow or answer 401.
*/
async function refreshChatSession(
context: IWebhookFunctions,
resourceUrl: string,
): Promise<{ token: string; expiresIn: number } | null> {
const req = context.getRequestObject();
const res = context.getResponseObject();
const refreshToken = readChatRefreshToken(req);
if (!refreshToken) return null;
try {
const result = await context.refreshN8nOAuth2Flow(refreshToken, resourceUrl);
if (!result.valid) {
context.logger.warn('Chat OAuth2 refresh rejected', { reason: result.reason });
return null;
}
const expiresAt = expiryFrom(result.expiresIn);
setChatOAuthToken(res, req, resourceUrl, { token: result.token, expiresAt });
// Rotation invalidates the token we just sent, so the cookie must be replaced
// in the same response or the next refresh presents a consumed one.
setChatRefreshToken(res, req, resourceUrl, result.refreshToken);
return { token: result.token, expiresIn: result.expiresIn };
} catch (error) {
context.logger.warn('Chat OAuth2 refresh failed', { error });
return null;
}
}
/**
* The shell's own refresh leg: a same-origin GET on the `setup` path that mints a
* fresh access token for the frame. Authenticates purely from the httpOnly refresh
* cookie — the shell's script never holds the refresh token and can't forge this.
*
* Answers the request itself; the caller must abort with `noWebhookResponse`.
*/
export async function handleChatTokenRefresh(
context: IWebhookFunctions,
resourceUrl: string,
): Promise<void> {
const req = context.getRequestObject();
const res = context.getResponseObject();
// `no-store` because the response body is a bearer token: a shared cache holding
// it would hand one visitor's token to the next.
res.setHeader('Cache-Control', 'no-store');
if (!readChatRefreshToken(req)) {
res.status(401).json({ error: 'invalid_grant' });
res.end();
return;
}
const refreshed = await refreshChatSession(context, resourceUrl);
if (!refreshed) {
// The cookie stays. A concurrent refresh on the same path — a second tab — wins
// the AS's atomic rotation and has already written its rotated token here, so
// clearing would erase a live grant and take the winner down with the loser. A
// cookie the AS really has finished with self-heals instead: the next shell GET
// fails its refresh, redirects through the AS, and the callback overwrites it.
res.status(401).json({ error: 'invalid_grant' });
res.end();
return;
}
// Only the access token crosses the wire; the rotated refresh token stays in its
// httpOnly cookie. `expiresIn` is a duration, so the page schedules off its own
// clock and never has to agree with the server's.
res.status(200).json({ token: refreshed.token, expiresIn: refreshed.expiresIn }).end();
}
/**
@@ -189,15 +296,17 @@ export async function resolveInnerFrameIdentity(
const req = context.getRequestObject();
const res = context.getResponseObject();
const cookieToken = readChatOAuthToken(req);
if (!cookieToken) {
const session = readChatOAuthToken(req);
if (!session) {
return null;
}
// Only the one-hop cookie: the refresh cookie has to survive this render, since
// every later refresh the shell asks for is authenticated by it.
clearChatOAuthToken(res, req, resourceUrl);
const validation = await context.validateN8nOAuth2Token(cookieToken, resourceUrl);
const validation = await context.validateN8nOAuth2Token(session.token, resourceUrl);
if (!validation.valid) {
return null;
}
return { visitor: validation.user, authToken: cookieToken };
return { visitor: validation.user, authToken: session.token };
}
@@ -8,6 +8,7 @@ import { ChatTrigger } from '../ChatTrigger.node';
import { ChatTriggerAuthorizationError } from '../error';
import {
establishChatSessionIdentity,
handleChatTokenRefresh,
resolveInnerFrameIdentity,
validateAuth,
} from '../GenericFunctions';
@@ -16,6 +17,7 @@ import type { LoadPreviousSessionChatOption } from '../types';
vi.mock('../GenericFunctions', () => ({
validateAuth: vi.fn(),
establishChatSessionIdentity: vi.fn(),
handleChatTokenRefresh: vi.fn(),
resolveInnerFrameIdentity: vi.fn(),
}));
@@ -571,7 +573,7 @@ describe('ChatTrigger Node', () => {
typeVersion: 1.4,
webhookId: 'webhook-1',
} as never);
vi.mocked(establishChatSessionIdentity).mockResolvedValue(true);
vi.mocked(establishChatSessionIdentity).mockResolvedValue({ expiresIn: 3600 });
vi.mocked(resolveInnerFrameIdentity).mockResolvedValue({
visitor,
authToken: 'as-token',
@@ -631,7 +633,7 @@ describe('ChatTrigger Node', () => {
// 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);
vi.mocked(establishChatSessionIdentity).mockResolvedValue(null);
const result = await renderSetupPage();
@@ -698,6 +700,7 @@ describe('ChatTrigger Node', () => {
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
expect(renderedPage()).toContain('createChat');
expect(renderedPage()).not.toContain('n8nShellInner');
expect(renderedPage()).not.toContain('n8nChatRefresh');
});
it.each(['none', 'basicAuth'])(
@@ -709,9 +712,54 @@ describe('ChatTrigger Node', () => {
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
expect(renderedPage()).toContain('createChat');
expect(renderedPage()).not.toContain('n8nShellInner');
expect(renderedPage()).not.toContain('n8nChatRefresh');
},
);
it('carries the refresh endpoint and schedule into the shell', async () => {
await renderSetupPage();
expect(renderedPage()).toContain('/webhook/abc/chat?n8nChatRefresh=1');
expect(renderedPage()).toContain("'x-n8n-chat-refresh': '1'");
// The session's duration, passed straight through — no timestamp of ours
// for the page's clock to disagree with.
expect(renderedPage()).toContain('planFor(3600)');
});
// The leg answers with JSON, not a page, and authenticates from its own httpOnly
// cookie — so it must be handled before either render branch decides anything.
it('routes the refresh leg ahead of the shell render', async () => {
mockRequest.query = { n8nChatRefresh: '1' };
mockRequest.headers = {
'x-forwarded-proto': 'http',
host: 'localhost:5678',
'x-n8n-chat-refresh': '1',
'sec-fetch-site': 'same-origin',
};
const result = await renderSetupPage();
expect(result).toEqual({ noWebhookResponse: true });
expect(handleChatTokenRefresh).toHaveBeenCalledWith(
mockContext,
'http://localhost:5678/webhook/abc/chat',
);
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
expect(resolveInnerFrameIdentity).not.toHaveBeenCalled();
expect(mockResponse.send).not.toHaveBeenCalled();
});
// Without the custom header the request is forgeable by shape alone, so it must
// fall through to the ordinary shell render rather than reach the leg.
it('does not route the refresh leg without the custom header', async () => {
mockRequest.query = { n8nChatRefresh: '1' };
await renderSetupPage();
expect(handleChatTokenRefresh).not.toHaveBeenCalled();
expect(establishChatSessionIdentity).toHaveBeenCalled();
});
// The builder opens the test URL from the canvas, so it must split exactly as
// production does for the flow to be testable end to end.
it('splits the page in test mode too', async () => {
@@ -4,6 +4,7 @@ import { mock } from 'vitest-mock-extended';
import { ChatTriggerAuthorizationError } from '../error';
import {
establishChatSessionIdentity,
handleChatTokenRefresh,
resolveInnerFrameIdentity,
validateAuth,
} from '../GenericFunctions';
@@ -295,14 +296,20 @@ describe('establishChatSessionIdentity', () => {
const res = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
end: vi.fn().mockReturnThis(),
writeHead: vi.fn().mockReturnThis(),
setHeader: vi.fn().mockReturnThis(),
cookie: vi.fn().mockReturnThis(),
clearCookie: vi.fn().mockReturnThis(),
};
return res as never;
};
/** The payload the one-hop cookie carries, percent-encoded as a browser sends it. */
const oauthCookie = (token: string, expiresAt: number) =>
`n8n-chat-oauth=${encodeURIComponent(JSON.stringify({ t: token, e: expiresAt }))}`;
beforeEach(() => {
vi.clearAllMocks();
mockContext.getResponseObject.mockReturnValue(mockRes());
@@ -319,30 +326,43 @@ describe('establishChatSessionIdentity', () => {
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(result).toBeNull();
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 () => {
it('completes the AS callback, hands both cookies off, 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 });
mockContext.completeN8nOAuth2Flow.mockResolvedValue({
valid: true,
token: 'as-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
user,
});
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(result).toBeNull();
expect(mockContext.completeN8nOAuth2Flow).toHaveBeenCalledWith('auth-code', 'flow-state');
expect(mockContext.getResponseObject().cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
'as-token',
expect.stringContaining('as-token'),
expect.objectContaining({ httpOnly: true }),
);
// The refresh token gets its own long-lived httpOnly cookie; nothing else
// carries it, so it never reaches a document.
expect(mockContext.getResponseObject().cookie).toHaveBeenCalledWith(
'n8n-chat-oauth-refresh',
'refresh-token',
expect.objectContaining({ httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000 }),
);
// 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, {
@@ -361,29 +381,34 @@ describe('establishChatSessionIdentity', () => {
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(result).toBeNull();
expect(mockContext.beginN8nOAuth2Flow).toHaveBeenCalledWith(resourceUrl);
});
it('confirms readiness from the one-hop cookie without clearing it, leaving it for the frame', async () => {
const expiresAt = Date.now() + 3_600_000;
mockContext.getRequestObject.mockReturnValue({
query: {},
headers: { cookie: 'n8n-chat-oauth=as-token' },
headers: { cookie: oauthCookie('as-token', expiresAt) },
originalUrl: '/webhook/abc/chat',
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user });
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(true);
// Converted from the cookie's absolute expiry here, on the clock that wrote it,
// so no server timestamp reaches the shell.
expect(result?.expiresIn).toBeGreaterThan(3590);
expect(result?.expiresIn).toBeLessThanOrEqual(3600);
expect(mockContext.validateN8nOAuth2Token).toHaveBeenCalledWith('as-token', resourceUrl);
expect(mockContext.getResponseObject().clearCookie).not.toHaveBeenCalled();
expect(mockContext.refreshN8nOAuth2Flow).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' },
headers: { cookie: oauthCookie('stale-token', Date.now() + 3_600_000) },
originalUrl: '/webhook/abc/chat',
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: false, reason: 'invalid_token' });
@@ -391,10 +416,107 @@ describe('establishChatSessionIdentity', () => {
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(result).toBeNull();
expect(mockContext.beginN8nOAuth2Flow).toHaveBeenCalledWith(resourceUrl);
});
// A reload mid-conversation: the 60-second one-hop cookie is long gone, but the
// grant behind the refresh cookie is still live, so rotating beats a full round
// trip back through the AS.
it('refreshes from the refresh cookie when the one-hop cookie has expired', async () => {
mockContext.getRequestObject.mockReturnValue({
query: {},
headers: { cookie: 'n8n-chat-oauth-refresh=refresh-token' },
originalUrl: '/webhook/abc/chat',
} as never);
mockContext.refreshN8nOAuth2Flow.mockResolvedValue({
valid: true,
token: 'fresh-token',
refreshToken: 'rotated-token',
expiresIn: 3600,
});
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(mockContext.refreshN8nOAuth2Flow).toHaveBeenCalledWith('refresh-token', resourceUrl);
// The AS's own duration, handed on untouched — the shell schedules off its own clock.
expect(result).toEqual({ expiresIn: 3600 });
expect(mockContext.beginN8nOAuth2Flow).not.toHaveBeenCalled();
// Both cookies are rewritten: the frame's next GET needs the new access token,
// and the rotated refresh token replaces the one just consumed.
expect(mockContext.getResponseObject().cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
expect.stringContaining('fresh-token'),
expect.objectContaining({ httpOnly: true }),
);
expect(mockContext.getResponseObject().cookie).toHaveBeenCalledWith(
'n8n-chat-oauth-refresh',
'rotated-token',
expect.objectContaining({ httpOnly: true }),
);
});
it('restarts the flow when the refresh cookie is refused', async () => {
mockContext.getRequestObject.mockReturnValue({
query: {},
headers: { cookie: 'n8n-chat-oauth-refresh=consumed-token' },
originalUrl: '/webhook/abc/chat',
} as never);
mockContext.refreshN8nOAuth2Flow.mockResolvedValue({
valid: false,
reason: 'invalid_grant',
});
mockContext.beginN8nOAuth2Flow.mockResolvedValue('https://as.example.com/authorize');
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBeNull();
expect(mockContext.beginN8nOAuth2Flow).toHaveBeenCalledWith(resourceUrl);
});
// Nothing ever clears the refresh cookie, so a cookie the AS has finished with has
// to heal itself. It does: the refresh fails, the visitor is sent through the AS,
// and the callback writes a live token over the dead one.
it('overwrites a stale refresh cookie rather than needing it cleared', async () => {
mockContext.getRequestObject.mockReturnValue({
query: {},
headers: { cookie: 'n8n-chat-oauth-refresh=long-dead-token' },
originalUrl: '/webhook/abc/chat',
} as never);
mockContext.refreshN8nOAuth2Flow.mockResolvedValue({ valid: false, reason: 'invalid_grant' });
mockContext.beginN8nOAuth2Flow.mockResolvedValue('https://as.example.com/authorize');
expect(await establishChatSessionIdentity(mockContext, resourceUrl)).toBeNull();
expect(mockContext.getResponseObject().clearCookie).not.toHaveBeenCalled();
expect(mockContext.getResponseObject().writeHead).toHaveBeenCalledWith(302, {
Location: 'https://as.example.com/authorize',
});
// The AS auto-approves against the visitor's existing consent, so this leg is
// silent, and its callback replaces the dead cookie.
mockContext.getResponseObject.mockReturnValue(mockRes());
mockContext.getRequestObject.mockReturnValue({
query: { code: 'auth-code', state: 'flow-state' },
headers: { cookie: 'n8n-chat-oauth-refresh=long-dead-token' },
originalUrl: '/webhook/abc/chat?code=auth-code&state=flow-state',
} as never);
mockContext.completeN8nOAuth2Flow.mockResolvedValue({
valid: true,
token: 'as-token',
refreshToken: 'brand-new-refresh-token',
expiresIn: 3600,
user,
});
expect(await establishChatSessionIdentity(mockContext, resourceUrl)).toBeNull();
expect(mockContext.getResponseObject().cookie).toHaveBeenCalledWith(
'n8n-chat-oauth-refresh',
'brand-new-refresh-token',
expect.objectContaining({ httpOnly: true }),
);
expect(mockContext.getResponseObject().clearCookie).not.toHaveBeenCalled();
});
it('reports denial without restarting the flow', async () => {
mockContext.getRequestObject.mockReturnValue({
query: { error: 'access_denied' },
@@ -404,12 +526,142 @@ describe('establishChatSessionIdentity', () => {
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toBe(false);
expect(result).toBeNull();
expect(mockContext.getResponseObject().status).toHaveBeenCalledWith(403);
expect(mockContext.beginN8nOAuth2Flow).not.toHaveBeenCalled();
});
});
describe('handleChatTokenRefresh', () => {
const mockContext = mock<IWebhookFunctions>();
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
const mockRes = () => {
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
end: vi.fn().mockReturnThis(),
setHeader: 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('rotates the grant and returns only the access token and its lifetime', async () => {
mockContext.getRequestObject.mockReturnValue({
headers: { cookie: 'n8n-chat-oauth-refresh=refresh-token' },
} as never);
mockContext.refreshN8nOAuth2Flow.mockResolvedValue({
valid: true,
token: 'fresh-token',
refreshToken: 'rotated-token',
expiresIn: 3600,
});
await handleChatTokenRefresh(mockContext, resourceUrl);
const res = mockContext.getResponseObject();
expect(mockContext.refreshN8nOAuth2Flow).toHaveBeenCalledWith('refresh-token', resourceUrl);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ token: 'fresh-token', expiresIn: 3600 });
// The rotated refresh token stays in its httpOnly cookie and never reaches the page.
expect(res.json).not.toHaveBeenCalledWith(
expect.objectContaining({ refreshToken: expect.anything() }),
);
expect(res.cookie).toHaveBeenCalledWith(
'n8n-chat-oauth-refresh',
'rotated-token',
expect.objectContaining({ httpOnly: true }),
);
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
});
it('answers 401 without calling the AS when there is no refresh cookie', async () => {
mockContext.getRequestObject.mockReturnValue({ headers: {} } as never);
await handleChatTokenRefresh(mockContext, resourceUrl);
expect(mockContext.refreshN8nOAuth2Flow).not.toHaveBeenCalled();
expect(mockContext.getResponseObject().status).toHaveBeenCalledWith(401);
expect(mockContext.getResponseObject().json).toHaveBeenCalledWith({ error: 'invalid_grant' });
});
// A token a concurrent refresh already consumed loses the AS's atomic rotation
// race. The page must be told to stop — but the cookie belongs to the winner by
// then, so clearing it would erase a live grant.
it('answers 401 without touching the refresh cookie when the AS refuses the token', async () => {
mockContext.getRequestObject.mockReturnValue({
headers: { cookie: 'n8n-chat-oauth-refresh=consumed-token' },
} as never);
mockContext.refreshN8nOAuth2Flow.mockResolvedValue({
valid: false,
reason: 'invalid_grant',
});
await handleChatTokenRefresh(mockContext, resourceUrl);
const res = mockContext.getResponseObject();
expect(res.clearCookie).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_grant' });
});
// Two tabs on one chat page share the single cookie slot on that path. The loser
// must leave the winner's rotated token alone: it is the only copy of the grant.
it('leaves a cookie a concurrent shell just rotated intact', async () => {
mockContext.getRequestObject.mockReturnValue({
headers: { cookie: 'n8n-chat-oauth-refresh=rotated-by-the-winner' },
} as never);
mockContext.refreshN8nOAuth2Flow.mockResolvedValueOnce({
valid: false,
reason: 'invalid_grant',
});
await handleChatTokenRefresh(mockContext, resourceUrl);
const loserRes = mockContext.getResponseObject();
expect(loserRes.status).toHaveBeenCalledWith(401);
expect(loserRes.clearCookie).not.toHaveBeenCalled();
// The loser's 5s retry now presents the same cookie, which the winner rotated.
mockContext.getResponseObject.mockReturnValue(mockRes());
mockContext.refreshN8nOAuth2Flow.mockResolvedValueOnce({
valid: true,
token: 'fresh-token',
refreshToken: 'rotated-again',
expiresIn: 3600,
});
await handleChatTokenRefresh(mockContext, resourceUrl);
const retryRes = mockContext.getResponseObject();
expect(retryRes.status).toHaveBeenCalledWith(200);
expect(retryRes.json).toHaveBeenCalledWith({ token: 'fresh-token', expiresIn: 3600 });
expect(retryRes.clearCookie).not.toHaveBeenCalled();
});
it('answers 401 rather than throwing when the AS call itself fails', async () => {
mockContext.getRequestObject.mockReturnValue({
headers: { cookie: 'n8n-chat-oauth-refresh=refresh-token' },
} as never);
mockContext.refreshN8nOAuth2Flow.mockRejectedValue(new Error('AS unreachable'));
await expect(handleChatTokenRefresh(mockContext, resourceUrl)).resolves.toBeUndefined();
const res = mockContext.getResponseObject();
expect(res.status).toHaveBeenCalledWith(401);
// A transient AS failure says nothing about the grant, so the cookie stays.
expect(res.clearCookie).not.toHaveBeenCalled();
});
});
describe('resolveInnerFrameIdentity', () => {
const mockContext = mock<IWebhookFunctions>();
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
@@ -432,9 +684,13 @@ describe('resolveInnerFrameIdentity', () => {
mockContext.getResponseObject.mockReturnValue(mockRes());
});
/** The payload the one-hop cookie carries, percent-encoded as a browser sends it. */
const oauthCookie = (token: string) =>
`n8n-chat-oauth=${encodeURIComponent(JSON.stringify({ t: token, e: Date.now() + 3_600_000 }))}`;
it('resolves the visitor from the one-hop cookie and clears it', async () => {
mockContext.getRequestObject.mockReturnValue({
headers: { cookie: 'n8n-chat-oauth=as-token' },
headers: { cookie: oauthCookie('as-token') },
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user });
@@ -448,6 +704,22 @@ describe('resolveInnerFrameIdentity', () => {
);
});
// Every later refresh the shell asks for is authenticated by the refresh cookie,
// so this render must leave it alone.
it('leaves the refresh cookie in place', async () => {
mockContext.getRequestObject.mockReturnValue({
headers: { cookie: `${oauthCookie('as-token')}; n8n-chat-oauth-refresh=refresh-token` },
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user });
await resolveInnerFrameIdentity(mockContext, resourceUrl);
expect(mockContext.getResponseObject().clearCookie).not.toHaveBeenCalledWith(
'n8n-chat-oauth-refresh',
expect.anything(),
);
});
it('returns null, without starting a new flow, when there is no cookie', async () => {
mockContext.getRequestObject.mockReturnValue({ headers: {} } as never);
@@ -459,7 +731,7 @@ describe('resolveInnerFrameIdentity', () => {
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' },
headers: { cookie: oauthCookie('stale-token') },
} as never);
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: false, reason: 'invalid_token' });
@@ -1,12 +1,16 @@
import type { Request, Response } from 'express';
import {
buildChatRefreshUrl,
buildInnerFrameSrc,
clearChatOAuthToken,
isChatOAuth2Enabled,
isChatRefreshRequest,
isShellInnerRequest,
readChatOAuthToken,
readChatRefreshToken,
setChatOAuthToken,
setChatRefreshToken,
} from '../shell';
const request = (overrides: Partial<Request> = {}) =>
@@ -88,8 +92,22 @@ describe('buildInnerFrameSrc', () => {
});
});
describe('buildChatRefreshUrl', () => {
it('flags the same endpoint as the refresh leg', () => {
expect(buildChatRefreshUrl(request())).toBe('/webhook/abc/chat?n8nChatRefresh=1');
});
it('keeps the page own query parameters', () => {
const req = request({ originalUrl: '/webhook-test/abc/chat?foo=bar' });
expect(buildChatRefreshUrl(req)).toBe('/webhook-test/abc/chat?foo=bar&n8nChatRefresh=1');
});
});
describe('chat OAuth2 one-hop cookie', () => {
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
const payload = { token: 'as-token', expiresAt: 1_700_000_000_000 };
const serialized = JSON.stringify({ t: 'as-token', e: 1_700_000_000_000 });
const response = () =>
({
@@ -101,9 +119,9 @@ describe('chat OAuth2 one-hop cookie', () => {
const req = request({ headers: { host: 'localhost:5678' }, protocol: 'http' });
const res = response();
setChatOAuthToken(res, req, resourceUrl, 'as-token');
setChatOAuthToken(res, req, resourceUrl, payload);
expect(res.cookie).toHaveBeenCalledWith('n8n-chat-oauth', 'as-token', {
expect(res.cookie).toHaveBeenCalledWith('n8n-chat-oauth', serialized, {
httpOnly: true,
sameSite: 'lax',
secure: false,
@@ -116,11 +134,11 @@ describe('chat OAuth2 one-hop cookie', () => {
const req = request({ headers: { 'x-forwarded-proto': 'https' }, protocol: 'http' });
const res = response();
setChatOAuthToken(res, req, resourceUrl, 'as-token');
setChatOAuthToken(res, req, resourceUrl, payload);
expect(res.cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
'as-token',
serialized,
expect.objectContaining({ secure: true }),
);
});
@@ -135,11 +153,11 @@ describe('chat OAuth2 one-hop cookie', () => {
});
const res = response();
setChatOAuthToken(res, req, resourceUrl, 'as-token');
setChatOAuthToken(res, req, resourceUrl, payload);
expect(res.cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
'as-token',
serialized,
expect.objectContaining({ secure: true }),
);
});
@@ -151,19 +169,23 @@ describe('chat OAuth2 one-hop cookie', () => {
});
const res = response();
setChatOAuthToken(res, req, resourceUrl, 'as-token');
setChatOAuthToken(res, req, resourceUrl, payload);
expect(res.cookie).toHaveBeenCalledWith(
'n8n-chat-oauth',
'as-token',
serialized,
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' } });
// The shell schedules its refresh off the expiry, so it has to survive the round
// trip through the cookie exactly as written.
it('round-trips the token and its expiry', () => {
const req = request({
headers: { cookie: `other=1; n8n-chat-oauth=${encodeURIComponent(serialized)}; more=2` },
});
expect(readChatOAuthToken(req)).toBe('as-token');
expect(readChatOAuthToken(req)).toEqual(payload);
});
it('returns null when the cookie is absent', () => {
@@ -171,10 +193,12 @@ describe('chat OAuth2 one-hop cookie', () => {
expect(readChatOAuthToken(request())).toBeNull();
});
it('decodes a percent-encoded value', () => {
const req = request({ headers: { cookie: 'n8n-chat-oauth=a%2Fb' } });
// The refresh cookie's name begins with this one's, so a request carrying only the
// refresh cookie must not be read as a payload.
it('does not read the refresh cookie as the one-hop payload', () => {
const req = request({ headers: { cookie: 'n8n-chat-oauth-refresh=refresh-token' } });
expect(readChatOAuthToken(req)).toBe('a/b');
expect(readChatOAuthToken(req)).toBeNull();
});
it('treats an undecodable value as no cookie', () => {
@@ -183,6 +207,20 @@ describe('chat OAuth2 one-hop cookie', () => {
expect(readChatOAuthToken(req)).toBeNull();
});
// Anything that isn't the payload shape — a bare token from an older build, a
// truncated value — must not schedule a refresh off a number we invented.
it.each([
['a bare token', 'as-token'],
['a payload with no expiry', '{"t":"as-token"}'],
['a payload with no token', '{"e":1700000000000}'],
['a payload with an empty token', '{"t":"","e":1700000000000}'],
['a non-finite expiry', '{"t":"as-token","e":null}'],
])('treats %s as no cookie', (_label, value) => {
const req = request({ headers: { cookie: `n8n-chat-oauth=${encodeURIComponent(value)}` } });
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();
@@ -197,3 +235,97 @@ describe('chat OAuth2 one-hop cookie', () => {
});
});
});
describe('chat OAuth2 refresh cookie', () => {
const resourceUrl = 'http://localhost:5678/webhook/abc/chat';
const response = () =>
({
cookie: vi.fn(),
clearCookie: vi.fn(),
}) as unknown as Response;
// httpOnly is the whole design: no document and no script may read this value.
// 30 days matches the AS's own refresh-token life, so the cookie never outlives
// the grant it names.
it('sets an httpOnly, path-scoped, 30-day cookie', () => {
const req = request({ headers: { host: 'localhost:5678' }, protocol: 'http' });
const res = response();
setChatRefreshToken(res, req, resourceUrl, 'refresh-token');
expect(res.cookie).toHaveBeenCalledWith('n8n-chat-oauth-refresh', 'refresh-token', {
httpOnly: true,
sameSite: 'lax',
secure: false,
path: '/webhook/abc/chat',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
});
it('marks the cookie secure over https (honouring x-forwarded-proto)', () => {
const req = request({ headers: { 'x-forwarded-proto': 'https' }, protocol: 'http' });
const res = response();
setChatRefreshToken(res, req, resourceUrl, 'refresh-token');
expect(res.cookie).toHaveBeenCalledWith(
'n8n-chat-oauth-refresh',
'refresh-token',
expect.objectContaining({ secure: true }),
);
});
it('reads the cookie back alongside the one-hop cookie', () => {
const req = request({
headers: { cookie: 'n8n-chat-oauth=%7B%7D; n8n-chat-oauth-refresh=refresh-token' },
});
expect(readChatRefreshToken(req)).toBe('refresh-token');
});
it('returns null when the cookie is absent', () => {
expect(readChatRefreshToken(request({ headers: { cookie: 'n8n-chat-oauth=x' } }))).toBeNull();
});
});
describe('isChatRefreshRequest', () => {
const refreshRequest = (overrides: Partial<Request> = {}) =>
request({
query: { n8nChatRefresh: '1' },
headers: { 'x-n8n-chat-refresh': '1', 'sec-fetch-site': 'same-origin' },
...overrides,
});
it('accepts the shell own same-origin fetch', () => {
expect(isChatRefreshRequest(refreshRequest())).toBe(true);
});
it('is false without the query flag', () => {
expect(isChatRefreshRequest(refreshRequest({ query: {} }))).toBe(false);
});
// The custom header is the CSRF guard: another origin cannot set it without a
// preflight this endpoint never answers. A plain forged GET has to be refused.
it('refuses a request with no custom header', () => {
expect(
isChatRefreshRequest(refreshRequest({ headers: { 'sec-fetch-site': 'same-origin' } })),
).toBe(false);
});
it.each(['cross-site', 'same-site', 'none'])('refuses Sec-Fetch-Site %s', (site) => {
expect(
isChatRefreshRequest(
refreshRequest({ headers: { 'x-n8n-chat-refresh': '1', 'sec-fetch-site': site } }),
),
).toBe(false);
});
// A proxy that strips Sec-Fetch-Site must not break the leg; the custom header
// still stands.
it('accepts a request with no Sec-Fetch-Site at all', () => {
expect(isChatRefreshRequest(refreshRequest({ headers: { 'x-n8n-chat-refresh': '1' } }))).toBe(
true,
);
});
});
@@ -657,6 +657,158 @@ describe('createShellPage', () => {
expect(shell).toContain("'n8n-chat-shell/sessionId' + window.location.pathname");
expect(shell).toContain("'#sessionId=' + encodeURIComponent(sessionId)");
});
// With the OAuth path off there is no token to keep alive, so the shell must be
// exactly the document it was before refresh existed.
it('carries no refresh machinery when no refresh is passed', () => {
expect(shell).not.toContain('n8nChatRefresh');
expect(shell).not.toContain('n8n-chat-auth-token');
expect(shell).not.toContain('n8n-chat-frame-ready');
expect(shell).not.toContain('MessageChannel');
expect(shell).not.toContain('fetch(');
});
// The refresh script had to move ahead of this one so its listener is installed
// while the frame is still about:blank. With refresh absent the document must be
// byte-for-byte the one it was before refresh existed.
it('places the frame script exactly where it was before refresh existed', () => {
expect(shell).toContain('></iframe>\n\t\t<script>');
expect(shell).toContain('\t\t</script>\n\t</body>');
});
});
describe('createShellPage with token refresh', () => {
const shell = createShellPage({
iframeSrc: '/webhook/abc/chat?n8nShellInner=1',
refresh: { url: '/webhook/abc/chat?n8nChatRefresh=1', expiresIn: 3600 },
});
it('schedules ahead of the lifetime it was given', () => {
expect(shell).toContain('planFor(3600)');
expect(shell).toContain('setTimeout');
// The lead is the margin BEFORE expiry, not the refresh time: a fifth of the
// lifetime clamped to [60s, 600s], so a one-hour token refreshes at t+50min.
expect(shell).toContain('Math.min(600, Math.max(60, lifetimeSeconds * 0.2))');
});
// The timer and its one retry are the only things that start a refresh, so two can
// never be in flight and the script needs no concurrency guard. A second trigger —
// a visibility or focus listener — would race the timer over the single refresh
// cookie on this path, so it cannot be added without a latch.
it('starts a refresh from the timer alone', () => {
expect(shell).toContain('timer = setTimeout(function () { refresh(false); }, delay);');
expect(shell).not.toContain('visibilitychange');
expect(shell).not.toContain('inFlight');
});
// An absolute expiry the server computed, compared against the page's own
// `Date.now()`, is wrong by however far the two clocks disagree — and the page has
// no way to detect that. Every reading the schedule makes must come from one clock,
// so the server converts to a duration before it reaches the document.
it('never interpolates a server timestamp into the document', () => {
const rendered = createShellPage({
iframeSrc: '/webhook/abc/chat?n8nShellInner=1',
refresh: { url: '/webhook/abc/chat?n8nChatRefresh=1', expiresIn: 3600 },
});
// A 13-digit epoch-ms literal is what a leaked `expiresAt` would look like.
expect(rendered).not.toMatch(/\b1[0-9]{12}\b/);
});
// Anchoring the new lifetime to when the response *arrived* always overstates what
// is left, by however long the round trip took — a slow leg, or a paused server —
// and overstating is the direction that ends in 401s.
it('subtracts the round trip it measured before rescheduling', () => {
expect(shell).toContain('var startedAt = Date.now();');
expect(shell).toContain('planFor(lifetime - (Date.now() - startedAt) / 1000);');
});
it('rounds a fractional lifetime and never emits a negative one', () => {
const soon = createShellPage({
iframeSrc: '/x',
refresh: { url: '/x?n8nChatRefresh=1', expiresIn: 0 },
});
expect(soon).toContain('planFor(0)');
expect(soon).not.toContain('planFor(-');
});
it('fetches the refresh leg with the custom header that guards it', () => {
expect(shell).toContain('"/webhook/abc/chat?n8nChatRefresh=1"');
expect(shell).toContain("'x-n8n-chat-refresh': '1'");
expect(shell).toContain("credentials: 'same-origin'");
});
// `frame.contentWindow` names the browsing context, not the document, so it keeps
// resolving after author script navigates the frame away — and would hand the next
// rotated token to whatever loaded there. A port dies with the document that made it.
it('hands the token down a channel the frame opened, never at its window', () => {
expect(shell).toContain("port.postMessage({ type: 'n8n-chat-auth-token', token: token })");
expect(shell).toContain('deliver(data.token);');
expect(shell).not.toContain('contentWindow.postMessage');
});
// Two parser-inserted scripts do not run in one uninterrupted turn, so a listener
// installed after the frame is navigated could miss the announcement. Installed
// first, it is in place while the frame is still about:blank — which, sandboxed
// without allow-same-origin, has no script and cannot post.
it('installs the ready listener before it navigates the frame', () => {
expect(shell.indexOf("addEventListener('message'")).toBeGreaterThan(-1);
expect(shell.indexOf("addEventListener('message'")).toBeLessThan(
shell.indexOf("frame.src = frame.getAttribute('data-src')"),
);
});
it('accepts a ready message only from the frame itself', () => {
expect(shell).toContain('if (!frame || event.source !== frame.contentWindow) return;');
expect(shell).toContain("data.type !== 'n8n-chat-frame-ready'");
});
it('latches the first ready message and never a later one', () => {
expect(shell).toContain('if (latched) return;');
expect(shell).toContain('var latched = false;');
expect(shell).toContain('latched = true;');
// Nothing ever sets it back: the only assignment to false is the declaration.
expect(shell.match(/latched = false/g)).toHaveLength(1);
});
// A browser with no MessageChannel announces itself without a port. That still has
// to close the latch, or a document that later replaces the frame could claim it.
it('closes the latch on a ready message that carries no port', () => {
expect(shell.indexOf('latched = true;')).toBeLessThan(
shell.indexOf('if (event.ports && event.ports.length) port = event.ports[0];'),
);
});
// With no port there is nowhere to put a fresh token, and the frame's own baked-in
// token lasts its full lifetime — exactly the pre-refresh behaviour.
it('stops refreshing when the frame announces readiness without a port', () => {
expect(shell).toContain('if (latched && !port) return;');
expect(shell).toContain('if (timer) { clearTimeout(timer); timer = null; }');
});
// A refresh can beat the frame's bootstrap, and the post is one-shot.
it('holds the newest token until the port arrives', () => {
expect(shell).toContain('pendingToken = token;');
expect(shell).toContain(
"port.postMessage({ type: 'n8n-chat-auth-token', token: pendingToken })",
);
expect(shell).toContain("pendingToken = '';");
// If no port ever arrives, reload rather than fall back to the frame's window.
expect(shell).toContain('portTimer = setTimeout(portMissing, 10000);');
});
it('retries once and then reloads exactly once', () => {
expect(shell).toContain('refresh(true)');
expect(shell).toContain('window.location.reload()');
expect(shell).toContain('if (reloaded) return;');
});
// The whole point of the httpOnly cookie: the refresh token exists in no document.
it('carries no refresh token', () => {
expect(shell).not.toContain('refreshToken');
expect(shell).not.toContain('n8n-chat-oauth-refresh');
});
});
describe('createPage inside the shell frame', () => {
@@ -702,7 +854,47 @@ describe('createPage inside the shell frame', () => {
});
it('authenticates messages by request header', () => {
expect(inner).toContain('\'x-auth-token\': "signed.jwt.token",');
expect(inner).toContain('headers[\'x-auth-token\'] = "signed.jwt.token"');
});
// `createChat` keeps this object by reference and the widget reads it on every
// send, so a later in-place write is what makes a refreshed token take effect.
it('hands the widget a header object it can keep mutating', () => {
expect(inner).toContain('const headers = window.__n8nChatAuthHeaders || {};');
expect(inner).toContain('headers: headers');
expect(inner).toContain("headers['X-Instance-Id'] = 'test-instance';");
// Guards the race where a refresh lands before this module script runs.
expect(inner).toContain("if (!headers['x-auth-token'])");
});
// Not a window listener: a port belongs to this document's realm, so it dies with
// this document. A replacement loaded by author script cannot obtain it, which is
// what keeps the shell's next token away from it.
it('opens a private channel to the shell instead of listening on window', () => {
expect(inner).toContain('window.__n8nChatAuthHeaders = {};');
expect(inner).toContain('var channel = new MessageChannel();');
expect(inner).toContain('channel.port1.onmessage = function (event) {');
expect(inner).toContain(
"window.parent.postMessage({ type: 'n8n-chat-frame-ready' }, '*', [channel.port2]);",
);
expect(inner).toContain("data.type !== 'n8n-chat-auth-token'");
expect(inner).toContain("window.__n8nChatAuthHeaders['x-auth-token'] = data.token;");
expect(inner).not.toContain("addEventListener('message'");
});
// Announcing with no port still closes the shell's latch, so a document loaded here
// later cannot claim the channel this one failed to open.
it('announces readiness without a port when the browser has no channel', () => {
expect(inner).toContain(
"try { window.parent.postMessage({ type: 'n8n-chat-frame-ready' }, '*'); } catch (postError) {}",
);
});
// The refresh token lives only in an httpOnly cookie; it must appear in neither
// document.
it('carries no refresh token', () => {
expect(inner).not.toContain('refreshToken');
expect(inner).not.toContain('n8n-chat-oauth-refresh');
});
// Not merely skipped at runtime: the bootstrap is never emitted, so there is no
@@ -728,6 +920,14 @@ describe('createPage inside the shell frame', () => {
expect(plain).toContain('const injectedVisitor = null;');
});
// Nothing refreshes this render, so it keeps the inline header literal rather
// than the mutable object the frame render needs.
it('keeps the header literal inline', () => {
expect(plain).toContain("'X-Instance-Id': 'test-instance',");
expect(plain).not.toContain('window.__n8nChatAuthHeaders');
expect(plain).not.toContain('headers: headers');
});
// The client-side bootstrap is what the flag-off n8nUserAuth render still relies on.
it('keeps the login bootstrap the flag-off render depends on', () => {
expect(plain).toContain("fetch('/rest/login'");
@@ -36,13 +36,45 @@ export function buildInnerFrameSrc(req: Request): string {
return `${path}?${params.toString()}`;
}
/** Query flag that asks the `setup` GET for the token-refresh leg instead of a page. */
export const CHAT_REFRESH_PARAM = 'n8nChatRefresh';
/**
* Custom header the shell's refresh `fetch` sets. A cross-origin page cannot set it
* without a CORS preflight this endpoint never answers, so requiring it is what stops
* another site from driving the leg with the visitor's cookies.
*/
export const CHAT_REFRESH_HEADER = 'x-n8n-chat-refresh';
/** Relative, so the shell's refresh `fetch` stays same-origin behind any host or prefix. */
export function buildChatRefreshUrl(req: Request): string {
const [path, query] = req.originalUrl.split('?');
const params = new URLSearchParams(query);
params.set(CHAT_REFRESH_PARAM, '1');
return `${path}?${params.toString()}`;
}
// 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.
// is not a new exposure. The expiry rides along because the shell has to know
// when to refresh and the frame's HTML carries the token alone.
const CHAT_OAUTH_COOKIE_NAME = 'n8n-chat-oauth';
/**
* Holds the grant's refresh token for the life of the hosted page. `httpOnly` is the
* whole point: neither document may read it, so it never enters any HTML and never
* enters any script scope. Only the refresh leg, server-side, ever sees it.
*
* Names must stay in step with `CHAT_OAUTH_REFRESH_COOKIE_NAME` in
* `packages/cli/src/constants.ts`, which strips both from every other webhook.
*/
const CHAT_OAUTH_REFRESH_COOKIE_NAME = 'n8n-chat-oauth-refresh';
/** Matches `REFRESH_TOKEN_EXPIRY_MS` in the AS: the cookie must not outlive the grant. */
const REFRESH_COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
/**
* 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
@@ -67,13 +99,21 @@ function chatOAuthCookieOptions(req: Request, resourceUrl: string) {
};
}
/** What the one-hop cookie carries: the access token and its absolute expiry in ms. */
export type ChatOAuthCookiePayload = { token: string; expiresAt: number };
// Short keys because a cookie value is capped at ~4KB and the token already
// takes most of it.
type SerializedPayload = { t: string; e: number };
export function setChatOAuthToken(
res: Response,
req: Request,
resourceUrl: string,
token: string,
payload: ChatOAuthCookiePayload,
): void {
res.cookie(CHAT_OAUTH_COOKIE_NAME, token, {
const value: SerializedPayload = { t: payload.token, e: payload.expiresAt };
res.cookie(CHAT_OAUTH_COOKIE_NAME, JSON.stringify(value), {
...chatOAuthCookieOptions(req, resourceUrl),
maxAge: 60_000, // one redirect hop; short by design
});
@@ -92,11 +132,73 @@ function decodeCookieValue(value: string): string | null {
}
}
export function readChatOAuthToken(req: Request): string | null {
const match = (req.headers.cookie ?? '').match(/(?:^|;\s*)n8n-chat-oauth=([^;]+)/);
return match ? decodeCookieValue(match[1]) : null;
/**
* Read one cookie by exact name. Split rather than matched, so `n8n-chat-oauth` can't
* pick up `n8n-chat-oauth-refresh`, whose name begins with it.
*/
function readRawCookie(req: Request, name: string): string | null {
for (const pair of (req.headers.cookie ?? '').split(';')) {
const separator = pair.indexOf('=');
if (separator === -1) continue;
if (pair.slice(0, separator).trim() !== name) continue;
return decodeCookieValue(pair.slice(separator + 1));
}
return null;
}
function isSerializedPayload(value: unknown): value is SerializedPayload {
if (typeof value !== 'object' || value === null) return false;
const { t, e } = value as Partial<SerializedPayload>;
return typeof t === 'string' && t !== '' && typeof e === 'number' && Number.isFinite(e);
}
export function readChatOAuthToken(req: Request): ChatOAuthCookiePayload | null {
const raw = readRawCookie(req, CHAT_OAUTH_COOKIE_NAME);
if (!raw) return null;
// Anything that isn't the payload shape — a cookie from another writer, a truncated
// value — is treated as absent, so the caller restarts the flow instead of
// scheduling off a number it invented.
try {
const parsed: unknown = JSON.parse(raw);
return isSerializedPayload(parsed) ? { token: parsed.t, expiresAt: parsed.e } : null;
} catch {
return null;
}
}
export function clearChatOAuthToken(res: Response, req: Request, resourceUrl: string): void {
res.clearCookie(CHAT_OAUTH_COOKIE_NAME, chatOAuthCookieOptions(req, resourceUrl));
}
export function setChatRefreshToken(
res: Response,
req: Request,
resourceUrl: string,
refreshToken: string,
): void {
res.cookie(CHAT_OAUTH_REFRESH_COOKIE_NAME, refreshToken, {
...chatOAuthCookieOptions(req, resourceUrl),
maxAge: REFRESH_COOKIE_MAX_AGE_MS,
});
}
export function readChatRefreshToken(req: Request): string | null {
return readRawCookie(req, CHAT_OAUTH_REFRESH_COOKIE_NAME);
}
/**
* Whether this GET is the shell asking for a fresh access token rather than for a page.
*
* A GET is required because a POST to this path reaches the `default` webhook — the
* chat message endpoint — not `setup`. That makes the request forgeable by shape alone,
* so the custom header carries the CSRF guard: it needs a preflight no other origin can
* get past. `Sec-Fetch-Site` is a second check where the browser sends it, and is
* ignored when absent so a stripping proxy doesn't break the leg.
*/
export function isChatRefreshRequest(req: Request): boolean {
if (req.query[CHAT_REFRESH_PARAM] !== '1') return false;
if (req.headers[CHAT_REFRESH_HEADER] !== '1') return false;
const site = req.headers['sec-fetch-site'];
if (typeof site === 'string' && site !== 'same-origin') return false;
return true;
}
@@ -85,10 +85,12 @@ export function getSanitizedCustomCss(customCss: string): string {
const WIDGET_SESSION_ID_KEY = 'n8n-chat/sessionId';
/**
* Runs before the widget's module script (classic inline scripts aren't deferred). Both
* jobs follow from the frame having no origin: stand in for `localStorage`, which the
* widget touches at startup and which throws here, and read the session id the shell
* passes in the fragment.
* Runs before the widget's module script (classic inline scripts aren't deferred). The
* first two jobs follow from the frame having no origin: stand in for `localStorage`,
* which the widget touches at startup and which throws here, and read the session id the
* shell passes in the fragment. The third is the auth channel — this document opens the
* `MessagePort` the shell delivers rotated tokens down, so the channel belongs to this
* document and no later one can inherit it.
*/
const innerBootstrapScript = `
<script>
@@ -114,6 +116,32 @@ const innerBootstrapScript = `
if (window.__n8nChatSessionId) {
shim.setItem(${escapeForScriptContext(WIDGET_SESSION_ID_KEY)}, window.__n8nChatSessionId);
}
// The widget reads this same object on every send, so writing the rotated
// token into it in place is all a refresh has to do. Created here, before
// the module script, so a token that lands early is never dropped.
window.__n8nChatAuthHeaders = {};
// A private channel rather than a window listener: the port is an object in
// this document's realm, so it dies with this document. If author script
// navigates the frame away, the replacement can't obtain the port and the
// shell's next token reaches nothing.
try {
var channel = new MessageChannel();
// Assigning onmessage implicitly starts the port. No sender check is needed
// or possible: a port has one peer, and only the shell holds it.
channel.port1.onmessage = function (event) {
var data = event.data;
if (!data || data.type !== 'n8n-chat-auth-token') return;
if (typeof data.token !== 'string' || !data.token) return;
window.__n8nChatAuthHeaders['x-auth-token'] = data.token;
};
window.parent.postMessage({ type: 'n8n-chat-frame-ready' }, '*', [channel.port2]);
} catch (error) {
// Announce anyway, with no port: that closes the shell's latch, so a document
// loaded here later cannot claim the channel we failed to open.
try { window.parent.postMessage({ type: 'n8n-chat-frame-ready' }, '*'); } catch (postError) {}
}
})();
</script>`;
@@ -122,7 +150,19 @@ const innerBootstrapScript = `
* the frame. Everything the author can shape lives in that frame, which has no origin
* and so can't reach this document's cookies or the OAuth `BroadcastChannel`.
*/
export function createShellPage({ iframeSrc }: { iframeSrc: string }) {
export function createShellPage({
iframeSrc,
refresh,
}: {
iframeSrc: string;
/**
* Set only on the OAuth2 path: where to ask for a fresh access token, and how many
* seconds the one the frame was just handed has left. A duration, never an absolute
* timestamp — see `ChatShellSession`. Absent leaves the shell exactly what it was
* before refresh existed.
*/
refresh?: { url: string; expiresIn: number };
}) {
return `<!doctype html>
<html lang="en">
<head>
@@ -140,7 +180,7 @@ export function createShellPage({ iframeSrc }: { iframeSrc: string }) {
title="Chat"
sandbox="${CHAT_FRAME_SANDBOX}"
data-src="${escapeForHtmlAttribute(iframeSrc)}"
></iframe>
></iframe>${refresh ? refreshScript(refresh) : ''}
<script>
(function () {
// Held here, not in the frame, whose storage dies with its opaque origin on
@@ -163,6 +203,145 @@ export function createShellPage({ iframeSrc }: { iframeSrc: string }) {
</html>`;
}
/**
* Keeps the frame's access token alive. The token is interpolated into the frame's HTML
* once and frozen for the life of that document, so without this a conversation older
* than the token fails every message with a 401.
*
* Lives on the shell, not in the frame: the refresh token is in an httpOnly cookie
* scoped to this path, and only a same-origin request carries it. The shell never reads
* that cookie either — it only asks the server to trade it.
*/
function refreshScript({ url, expiresIn }: { url: string; expiresIn: number }): string {
return `
<script>
(function () {
var endpoint = ${escapeForScriptContext(url)};
var timer = null;
var reloaded = false;
// Every token goes down a port the frame handed us, never at its contentWindow:
// that names the browsing context, which survives a navigation, so author script
// that navigates the frame away would be handed the next token. A port is an
// object in the frame document's realm — it dies with that document, and no
// replacement can obtain it.
var frame = document.getElementById('n8n-chat-frame');
var port = null;
var latched = false;
var pendingToken = '';
var portTimer = null;
window.addEventListener('message', function (event) {
// First announcement wins, and the latch never re-arms: the inner document is
// already unreloadable (its one-hop cookie is consumed on the first GET), so a
// second announcement can only come from a document we must not hand a token.
if (latched) return;
// allow-popups means a popup the frame opened can reach us as opener.parent,
// so the sender has to be the frame itself.
if (!frame || event.source !== frame.contentWindow) return;
var data = event.data;
if (!data || data.type !== 'n8n-chat-frame-ready') return;
latched = true;
if (event.ports && event.ports.length) port = event.ports[0];
if (portTimer) { clearTimeout(portTimer); portTimer = null; }
if (!port) {
// This browser can't carry a token to the frame. Stop: the frame keeps the
// token baked into its HTML for its full hour, as it did before refresh existed.
if (timer) { clearTimeout(timer); timer = null; }
return;
}
if (pendingToken) {
port.postMessage({ type: 'n8n-chat-auth-token', token: pendingToken });
pendingToken = '';
}
});
function deliver(token) {
if (port) { port.postMessage({ type: 'n8n-chat-auth-token', token: token }); return; }
// Hold the newest token rather than lose a one-shot post: a refresh can beat
// the frame's own bootstrap.
pendingToken = token;
if (!portTimer) portTimer = setTimeout(portMissing, 10000);
}
function portMissing() {
portTimer = null;
if (port) return;
// No fallback that posts at the frame's own window: a document that navigated
// the frame simply never announces itself, so the fallback would be the exact
// path that hands it the token. Reload instead — guarded, same-origin.
giveUp();
}
// How long BEFORE expiry to refresh, not when to refresh: a fifth of the
// lifetime, clamped to [60s, 600s]. A one-hour token is therefore replaced at
// t+50min, leaving ten minutes of margin — enough for a throttled background
// tab, a slept laptop, and the one retry before the reload fallback.
function leadSeconds(lifetimeSeconds) {
return Math.min(600, Math.max(60, lifetimeSeconds * 0.2));
}
// Takes a duration, never an absolute expiry the server computed: a clock that
// disagrees with the server's would otherwise skew every schedule. This timer is
// the only thing that starts a refresh, so two can never be in flight at once.
function planFor(lifetimeSeconds) {
var remaining = Math.max(0, lifetimeSeconds);
var delay = Math.max(0, (remaining - leadSeconds(remaining)) * 1000);
if (timer) clearTimeout(timer);
timer = setTimeout(function () { refresh(false); }, delay);
}
function giveUp() {
// One reload, guarded: it re-runs the handshake, which auto-approves against
// the visitor's existing consent. Without the guard a broken AS would put the
// page in a reload loop.
if (reloaded) return;
reloaded = true;
window.location.reload();
}
function refresh(isRetry) {
// The frame announced itself with no port, so there is nowhere to put a fresh
// token. Reachable through the 5s retry: a request already in flight when that
// announcement arrives still schedules one.
if (latched && !port) return;
// Taken before the request leaves, so the elapsed time subtracted below covers
// the whole window — both network legs, our handler, and the AS round trip.
// Without it the page anchors the lifetime to when the response *arrived* and
// so always believes it has more left than it does, which is the direction
// that ends in 401s.
var startedAt = Date.now();
fetch(endpoint, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
// Custom header, so the request needs a preflight no other origin gets
// past. This is the CSRF guard on the leg.
headers: { 'x-n8n-chat-refresh': '1' },
})
.then(function (response) {
if (!response.ok) throw new Error('refresh failed: ' + response.status);
return response.json();
})
.then(function (data) {
if (!data || typeof data.token !== 'string' || !data.token) {
throw new Error('refresh returned no token');
}
deliver(data.token);
var lifetime = typeof data.expiresIn === 'number' ? data.expiresIn : 3600;
planFor(lifetime - (Date.now() - startedAt) / 1000);
})
.catch(function () {
if (isRetry) giveUp();
else setTimeout(function () { refresh(true); }, 5000);
});
}
planFor(${String(Math.max(0, Math.round(expiresIn)))});
})();
</script>`;
}
export function createPage({
instanceId,
webhookUrl,
@@ -273,6 +452,26 @@ export function createPage({
email: frameIdentity.visitor.email,
})} };`;
// In the frame, the header object is hoisted out of the `createChat` literal so a
// reference to it survives the call: `createChat` keeps this object's identity and
// the widget reads it on every send, so the shell's refresh writes the rotated token
// into it in place and nothing re-enters this code. The `if` covers the narrow race
// where a refresh lands before this module script runs. The unsplit render keeps the
// literal inline so its page stays byte-for-byte what it was.
const headersBootstrap = frameIdentity
? `const headers = window.__n8nChatAuthHeaders || {};
headers['X-Instance-Id'] = '${instanceId}';
if (!headers['x-auth-token']) headers['x-auth-token'] = ${escapeForScriptContext(frameIdentity.authToken)};
`
: '';
const webhookConfigHeaders = frameIdentity
? 'headers: headers'
: `headers: {
'X-Instance-Id': '${instanceId}',
}`;
return `<!doctype html>
<html lang="en">
<head>
@@ -298,7 +497,7 @@ export function createPage({
(async function () {
${identityBootstrap}
createChat({
${headersBootstrap}createChat({
mode: 'fullscreen',
webhookUrl: ${escapeForScriptContext(webhookUrl ?? '')},
showWelcomeScreen: ${sanitizedShowWelcomeScreen},
@@ -306,10 +505,7 @@ export function createPage({
metadata: metadata,
${shellInner ? 'sessionId: window.__n8nChatSessionId || undefined,' : ''}
webhookConfig: {
headers: {
'X-Instance-Id': '${instanceId}',
${frameIdentity ? `'x-auth-token': ${escapeForScriptContext(frameIdentity.authToken)},` : ''}
}
${webhookConfigHeaders}
},
allowFileUploads: ${sanitizedAllowFileUploads},
allowedFilesMimeTypes: ${escapeForScriptContext(sanitizedAllowedFilesMimeTypes)},
@@ -13,6 +13,22 @@ export type LoadPreviousSessionChatOption = (typeof validOptions)[number];
*/
export type ChatFrameIdentity = { visitor: IUser; authToken: string };
/**
* What the trusted shell needs to keep the frame's token alive: how much longer the
* access token it just handed the frame will work, in seconds.
*
* A duration, not an absolute `expiresAt`, because this value is about to be written
* into a document and read by the browser's clock. An absolute server timestamp
* compared against `Date.now()` in the page is wrong by however far the two machines'
* clocks disagree; a duration is wrong only by the time in flight, which the page can
* measure and subtract. `expiresAt` stays inside `shell.ts`, where both ends of the
* cookie hop read the same clock.
*
* The refresh token is deliberately absent — it lives only in an httpOnly cookie, so
* no document and no script ever holds it.
*/
export type ChatShellSession = { expiresIn: number };
function isValidLoadPreviousSessionOption(value: unknown): value is LoadPreviousSessionChatOption {
return typeof value === 'string' && (validOptions as readonly string[]).includes(value);
}
+10
View File
@@ -87,6 +87,16 @@ export const OIDC_NONCE_COOKIE_NAME = 'n8n-oidc-nonce';
export const FORM_AUTH_COOKIE_PREFIX = 'n8n-form-auth';
export const FORM_OAUTH_COOKIE_NAME = 'n8n-form-oauth';
/**
* Cookies the Chat trigger's hosted page sets, duplicated here for the same reason
* as the form ones above — the names are owned by
* `@n8n/n8n-nodes-langchain/nodes/trigger/ChatTrigger/shell.ts`. Keep both sides in
* step. The `-refresh` one carries a 30-day refresh token, so it must never leak to
* an unrelated webhook.
*/
export const CHAT_OAUTH_COOKIE_NAME = 'n8n-chat-oauth';
export const CHAT_OAUTH_REFRESH_COOKIE_NAME = 'n8n-chat-oauth-refresh';
export const NPM_COMMAND_TOKENS = {
NPM_PACKAGE_NOT_FOUND_ERROR: '404 Not Found',
NPM_PACKAGE_VERSION_NOT_FOUND_ERROR: 'No matching version found for',
@@ -17,6 +17,7 @@ import { UrlService } from '@/services/url.service';
import { createOwner, createMember } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { RefreshTokenRepository } from '../database/repositories/oauth-refresh-token.repository';
import { OAuthAuthorizationCodeService } from '../oauth-authorization-code.service';
import { OAuth2FlowService } from '../oauth-flow.service';
import { OAuthServerService } from '../oauth-server.service';
@@ -188,6 +189,22 @@ describe('complete', () => {
}
});
// The AS already mints and persists these; a caller that never sees them can only
// restart the whole flow when the access token expires.
test('returns the refresh token and the access token lifetime', async () => {
const resourceUrl = await createProtectedFormWorkflow();
const { code, state } = await authorizeAndMintCode(resourceUrl, owner.id);
const result = await flow.complete(code, state);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.refreshToken).toEqual(expect.any(String));
expect(result.refreshToken).not.toBe(result.token);
expect(result.expiresIn).toBeGreaterThan(0);
}
});
test('returns metadata stashed at begin, and undefined when none was stashed', async () => {
const resourceUrl = await createProtectedFormWorkflow();
@@ -275,6 +292,115 @@ describe('complete', () => {
});
});
describe('refresh', () => {
/** Complete a flow and hand back the refresh token it issued. */
const grantFor = async (resourceUrl: string, userId = owner.id) => {
const { code, state } = await authorizeAndMintCode(resourceUrl, userId);
const result = await flow.complete(code, state);
if (!result.valid) throw new Error(`expected a valid flow, got ${result.reason}`);
return result;
};
test('rotates into a fresh pair bound to the same grant', async () => {
const resourceUrl = await createProtectedFormWorkflow();
const granted = await grantFor(resourceUrl);
const refreshed = await flow.refreshVirtualClientToken(granted.refreshToken, resourceUrl);
expect(refreshed.valid).toBe(true);
if (refreshed.valid) {
expect(refreshed.token).not.toBe(granted.token);
expect(refreshed.refreshToken).not.toBe(granted.refreshToken);
expect(refreshed.expiresIn).toBeGreaterThan(0);
expect(decodeJwtPayload(refreshed.token).sub).toBe(owner.id);
expect(decodeJwtPayload(refreshed.token).aud).toBe(resourceUrl);
}
});
// Rotation deletes only the refresh row, so a message already in flight with the
// previous access token must still be accepted.
test('leaves the previous access token usable', async () => {
const resourceUrl = await createProtectedFormWorkflow();
const granted = await grantFor(resourceUrl);
await flow.refreshVirtualClientToken(granted.refreshToken, resourceUrl);
await expect(
tokenService.verifyOAuthAccessToken(granted.token, resourceUrl),
).resolves.toMatchObject({ user: { id: owner.id } });
});
// The loser of a concurrent rotation presents a token `deleteValidByToken` already
// consumed. It must surface on the union, never as a silent reuse.
test('refuses a refresh token that was already consumed', async () => {
const resourceUrl = await createProtectedFormWorkflow();
const granted = await grantFor(resourceUrl);
const first = await flow.refreshVirtualClientToken(granted.refreshToken, resourceUrl);
expect(first.valid).toBe(true);
const replay = await flow.refreshVirtualClientToken(granted.refreshToken, resourceUrl);
expect(replay).toEqual({ valid: false, reason: 'invalid_grant' });
});
test('refuses an unknown refresh token', async () => {
const resourceUrl = await createProtectedFormWorkflow();
await grantFor(resourceUrl);
const result = await flow.refreshVirtualClientToken('not-a-refresh-token', resourceUrl);
expect(result).toEqual({ valid: false, reason: 'invalid_grant' });
});
// The grant's own resource bounds every later token request on it (RFC 8707 §2.2).
test('refuses a refresh token presented against another resource', async () => {
const resourceUrlA = await createProtectedFormWorkflow();
const resourceUrlB = await createProtectedFormWorkflow();
const granted = await grantFor(resourceUrlA);
const result = await flow.refreshVirtualClientToken(granted.refreshToken, resourceUrlB);
expect(result).toEqual({ valid: false, reason: 'invalid_grant' });
});
test('refuses a resource URL that is not a first-party protected resource', async () => {
await expect(
flow.refreshVirtualClientToken('any-token', resourceUrlFor(randomUUID())),
).rejects.toThrow(UserError);
});
test('rotates a chat grant the same way', async () => {
const chatResourceUrl = await createProtectedChatWorkflow();
const granted = await grantFor(chatResourceUrl, member.id);
const refreshed = await flow.refreshVirtualClientToken(granted.refreshToken, chatResourceUrl);
expect(refreshed.valid).toBe(true);
if (refreshed.valid) {
expect(decodeJwtPayload(refreshed.token).sub).toBe(member.id);
expect(decodeJwtPayload(refreshed.token).aud).toBe(chatResourceUrl);
}
});
// A page that refreshes for hours must not accumulate live refresh rows: the AS
// deletes the one it consumes, so the grant always holds exactly one.
test('keeps exactly one refresh row across repeated rotations', async () => {
const resourceUrl = await createProtectedChatWorkflow();
let current = (await grantFor(resourceUrl)).refreshToken;
for (let i = 0; i < 3; i++) {
const refreshed = await flow.refreshVirtualClientToken(current, resourceUrl);
if (!refreshed.valid) throw new Error(`rotation ${i} failed: ${refreshed.reason}`);
current = refreshed.refreshToken;
}
await expect(
Container.get(RefreshTokenRepository).countBy({ clientId: resourceUrl }),
).resolves.toBe(1);
});
});
/**
* Direct cover for "a credential-connect OAuth request initiated from a chat session is
* accepted as legitimate": otherwise it only holds transitively through `N8NIdentifier`.
@@ -223,7 +223,9 @@ describe('OAuthServerService', () => {
id: FIRST_PARTY_URL,
name: 'My Form',
redirectUris: [FIRST_PARTY_URL],
grantTypes: ['authorization_code'],
// `refresh_token` too: the AS issues one on every code exchange, and a
// long-lived trigger page rotates it instead of redirecting again.
grantTypes: ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethod: 'none',
clientSecret: null,
clientSecretExpiresAt: null,
@@ -235,7 +237,7 @@ describe('OAuthServerService', () => {
client_id: FIRST_PARTY_URL,
client_name: 'My Form',
redirect_uris: [FIRST_PARTY_URL],
grant_types: ['authorization_code'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
logo_uri: undefined,
@@ -253,7 +255,7 @@ describe('OAuthServerService', () => {
id: CHAT_FIRST_PARTY_URL,
name: 'My Chat',
redirectUris: [CHAT_FIRST_PARTY_URL],
grantTypes: ['authorization_code'],
grantTypes: ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethod: 'none',
clientSecret: null,
clientSecretExpiresAt: null,
@@ -1,7 +1,10 @@
import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import {
InvalidGrantError,
InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { UserError, type N8nOAuth2FlowResult } from 'n8n-workflow';
import { UserError, type N8nOAuth2FlowResult, type N8nOAuth2RefreshResult } from 'n8n-workflow';
import { createHash, randomBytes } from 'node:crypto';
import pkceChallenge from 'pkce-challenge';
@@ -100,9 +103,17 @@ export class OAuth2FlowService implements N8nOAuth2Flow {
if (!result.user) {
return { valid: false, reason: result.context?.reason ?? 'invalid_token' };
}
// Both are optional in the SDK's token shape but always set by our AS. Treat a
// missing one as a failed grant rather than throwing: a caller that can't refresh
// would silently stop working an hour later instead of restarting the flow now.
if (!tokens.refresh_token || tokens.expires_in === undefined) {
return { valid: false, reason: 'invalid_grant' };
}
return {
valid: true,
token: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
user: {
id: result.user.id,
email: result.user.email,
@@ -122,4 +133,53 @@ export class OAuth2FlowService implements N8nOAuth2Flow {
throw error;
}
}
/**
* Trade a refresh token from a completed flow for a fresh pair on the same grant.
* The client comes from `resourceUrl` alone, so this serves virtual clients only — the
* first-party trigger resources where client_id = redirect_uri = resource. A registered
* (DCR) client cannot refresh here; it uses the public `/oauth/token` endpoint.
* The AS rotates: the returned refresh token replaces the one passed in, and the
* old access token stays valid until its own expiry, so requests already in flight
* survive the rotation.
*/
async refreshVirtualClientToken(
refreshToken: string,
resourceUrl: string,
): Promise<N8nOAuth2RefreshResult> {
const resource = await this.resourceRegistry.getByResourceUrl(resourceUrl);
if (!resource?.isFirstParty) {
throw new UserError(`Not a first-party protected resource: ${resourceUrl}`);
}
const client = await this.oauthServerService.clientsStore.getClient(resourceUrl);
if (!client) return { valid: false, reason: 'invalid_client' };
try {
const tokens = await this.oauthServerService.exchangeRefreshToken(
client,
refreshToken,
undefined,
new URL(resourceUrl),
);
if (!tokens.refresh_token || tokens.expires_in === undefined) {
return { valid: false, reason: 'invalid_grant' };
}
return {
valid: true,
token: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
};
} catch (error) {
// A token already consumed by a concurrent refresh loses the atomic
// `deleteValidByToken` race and arrives here; so does a request naming a
// resource outside the grant. Both are the caller's cue to restart the flow,
// not a server fault — surface them on the union, never as a silent reuse.
if (error instanceof InvalidGrantError || error instanceof InvalidTargetError) {
return { valid: false, reason: 'invalid_grant' };
}
throw error;
}
}
}
@@ -271,7 +271,10 @@ export class OAuthServerService implements OAuthServerProvider {
id: clientId,
name: resource.displayName ?? clientId,
redirectUris: [clientId],
grantTypes: ['authorization_code'],
// `refresh_token` so the column matches what the grant actually supports:
// the AS issues a refresh token on every authorization-code exchange, and
// long-lived trigger pages rotate it rather than redirect again.
grantTypes: ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethod: 'none',
clientSecret: null,
clientSecretExpiresAt: null,
@@ -284,7 +287,7 @@ export class OAuthServerService implements OAuthServerProvider {
client_id: clientId,
client_name: resource.displayName ?? clientId,
redirect_uris: [clientId],
grant_types: ['authorization_code'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
logo_uri: undefined,
@@ -1,9 +1,23 @@
import { Service } from '@n8n/di';
import { UnexpectedError, type N8nOAuth2FlowResult } from 'n8n-workflow';
import {
UnexpectedError,
type N8nOAuth2FlowResult,
type N8nOAuth2RefreshResult,
} from 'n8n-workflow';
/**
* The in-process OAuth2 flow for first-party trigger resources. Every method resolves a
* virtual client from the resource URL, where client_id = redirect_uri = the trigger URL.
* A registered (DCR) client cannot use these methods — it goes through the public
* `/oauth/token` endpoint instead.
*/
export interface N8nOAuth2Flow {
begin(resourceUrl: string, metadata?: Record<string, string>): Promise<string>;
complete(code: string, state: string): Promise<N8nOAuth2FlowResult>;
refreshVirtualClientToken(
refreshToken: string,
resourceUrl: string,
): Promise<N8nOAuth2RefreshResult>;
}
@Service()
@@ -15,12 +29,20 @@ export class OAuth2FlowProxy implements N8nOAuth2Flow {
}
async begin(resourceUrl: string, metadata?: Record<string, string>): Promise<string> {
if (!this.provider) throw new UnexpectedError('OAuth2 form flow is not available');
if (!this.provider) throw new UnexpectedError('OAuth2 trigger flow is not available');
return await this.provider.begin(resourceUrl, metadata);
}
async complete(code: string, state: string): Promise<N8nOAuth2FlowResult> {
if (!this.provider) throw new UnexpectedError('OAuth2 form flow is not available');
if (!this.provider) throw new UnexpectedError('OAuth2 trigger flow is not available');
return await this.provider.complete(code, state);
}
async refreshVirtualClientToken(
refreshToken: string,
resourceUrl: string,
): Promise<N8nOAuth2RefreshResult> {
if (!this.provider) throw new UnexpectedError('OAuth2 trigger flow is not available');
return await this.provider.refreshVirtualClientToken(refreshToken, resourceUrl);
}
}
@@ -321,13 +321,15 @@ describe('webhookRequestSanitizer', () => {
it('should remove every disallowed cookie in one pass', () => {
mockRequest.headers = {
cookie:
'n8n-auth=a; n8n-browserId=b; n8n-form-auth-ex-12345=c; n8n-form-oauth=d; other-cookie=value',
'n8n-auth=a; n8n-browserId=b; n8n-form-auth-ex-12345=c; n8n-form-oauth=d; n8n-chat-oauth=e; n8n-chat-oauth-refresh=f; other-cookie=value',
};
mockRequest.cookies = {
'n8n-auth': 'a',
'n8n-browserId': 'b',
'n8n-form-auth-ex-12345': 'c',
'n8n-form-oauth': 'd',
'n8n-chat-oauth': 'e',
'n8n-chat-oauth-refresh': 'f',
'other-cookie': 'value',
};
@@ -363,4 +365,34 @@ describe('webhookRequestSanitizer', () => {
expect(mockRequest.cookies).toEqual({ 'other-cookie': 'value' });
});
});
// The chat endpoints skip sanitizing for their own node type, so the hosted page
// still receives these. Every other webhook must not see them — the `-refresh` one
// carries a 30-day credential.
describe('when the chat cookies are present', () => {
const chatCookieNames = ['n8n-chat-oauth', 'n8n-chat-oauth-refresh'];
it.each(chatCookieNames)('should remove %s from the header', (name) => {
mockRequest.headers = {
cookie: `${name}=abc123; other-cookie=value`,
};
sanitizeWebhookRequest(mockRequest);
expect(mockRequest.headers.cookie).toBe('other-cookie=value');
});
it.each(chatCookieNames)('should remove %s from parsed cookies', (name) => {
mockRequest.cookies = {
[name]: 'abc123',
'other-cookie': 'value',
};
sanitizeWebhookRequest(mockRequest);
expect(mockRequest.cookies).toEqual({
'other-cookie': 'value',
});
});
});
});
@@ -619,6 +619,9 @@ export async function executeWebhook(
additionalData.completeN8nOAuth2Flow = async (code: string, state: string) =>
await Container.get(OAuth2FlowProxy).complete(code, state);
additionalData.refreshN8nOAuth2Flow = async (refreshToken: string, resourceUrl: string) =>
await Container.get(OAuth2FlowProxy).refreshVirtualClientToken(refreshToken, resourceUrl);
// Captured here so `establishTriggerIdentity` seals the gate that admitted this
// request, instead of resolving the resource a second time.
let admittedBy: { resource: string; grant?: OAuthResourceGrant } | undefined;
@@ -2,6 +2,8 @@ import type { Request } from 'express';
import {
AUTH_COOKIE_NAME,
CHAT_OAUTH_COOKIE_NAME,
CHAT_OAUTH_REFRESH_COOKIE_NAME,
FORM_AUTH_COOKIE_PREFIX,
FORM_OAUTH_COOKIE_NAME,
OIDC_NONCE_COOKIE_NAME,
@@ -15,11 +17,12 @@ const BROWSER_ID_COOKIE_NAME = 'n8n-browserId';
/**
* Cookies n8n issues for its own UI and sign-in flows. They are set without a `path`, so
* browsers send them to `/webhook/*` too.
* browsers send them to `/webhook/*` too. Hence an explicit list of names rather than an
* `n8n-` prefix rule, which would take unrelated cookies with it.
*
* `n8n-form-oauth` is excluded: the form OAuth2 flow reads it back off the raw header on the
* redirect hop, so it has to keep flowing. Hence an explicit list of names rather than an
* `n8n-` prefix rule, which would take that cookie with it.
* The form and chat cookies belong to their own endpoints, which skip sanitizing entirely
* for their own node types (see `authAllowlistedNodes`), so those pages still receive
* them. Every other webhook has no use for them.
*/
const DISALLOWED_COOKIES = new Set([
AUTH_COOKIE_NAME,
@@ -30,6 +33,8 @@ const DISALLOWED_COOKIES = new Set([
OIDC_STATE_COOKIE_NAME,
OIDC_NONCE_COOKIE_NAME,
FORM_OAUTH_COOKIE_NAME,
CHAT_OAUTH_COOKIE_NAME,
CHAT_OAUTH_REFRESH_COOKIE_NAME,
]);
// The form auth cookie's name appends the workflow or execution it was minted
@@ -20,6 +20,7 @@ import type {
Workflow,
WorkflowExecuteMode,
N8nOAuth2FlowResult,
N8nOAuth2RefreshResult,
} from 'n8n-workflow';
import { UnexpectedError, createEmptyRunExecutionData } from 'n8n-workflow';
@@ -207,6 +208,16 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc
return await this.additionalData.completeN8nOAuth2Flow(code, state);
}
async refreshN8nOAuth2Flow(
refreshToken: string,
resourceUrl: string,
): Promise<N8nOAuth2RefreshResult> {
if (!this.additionalData.refreshN8nOAuth2Flow) {
throw new UnexpectedError('OAuth2 flow is not available');
}
return await this.additionalData.refreshN8nOAuth2Flow(refreshToken, resourceUrl);
}
async validateN8nOAuth2Token(
token: string,
resourceUrl: string,
@@ -991,6 +991,8 @@ describe('FormTrigger, formWebhook', () => {
ctx.completeN8nOAuth2Flow.mockResolvedValue({
valid: true,
token: 'as-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
user: authedUser,
});
@@ -1053,6 +1055,8 @@ describe('FormTrigger, formWebhook', () => {
ctx.completeN8nOAuth2Flow.mockResolvedValue({
valid: true,
token: 'as-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
user: authedUser,
metadata: { query: 'foo=bar' },
});
+32 -1
View File
@@ -155,7 +155,26 @@ export type N8nOAuth2ValidationResult =
| { valid: false; reason: OAuth2FailureReason };
export type N8nOAuth2FlowResult =
| { valid: true; token: string; user: IUser; metadata?: Record<string, string> }
| {
valid: true;
token: string;
/** Rotated on every use. Never hand this to a browser document or to page script. */
refreshToken: string;
/** Lifetime of `token` in seconds, as the AS reports it. A duration, not an
* absolute `exp`, so a browser scheduling off it is immune to clock skew. */
expiresIn: number;
user: IUser;
metadata?: Record<string, string>;
}
| { valid: false; reason: string };
/**
* Result of trading a refresh token for a fresh pair. Carries no user: the grant
* the token belongs to already fixes the subject, and the caller re-validates the
* access token when it needs the identity.
*/
export type N8nOAuth2RefreshResult =
| { valid: true; token: string; refreshToken: string; expiresIn: number }
| { valid: false; reason: string };
export type ProjectSharingData = {
@@ -1534,6 +1553,14 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod
* success, or a failure reason.
*/
completeN8nOAuth2Flow(code: string, state: string): Promise<N8nOAuth2FlowResult>;
/**
* Trades the refresh token from a completed flow for a fresh access/refresh pair on
* the same grant, keeping a long-lived page working past the access token's one-hour
* life without a new redirect. The AS rotates the refresh token, so the caller must
* store the returned one and drop the old one. `resourceUrl` must name the resource
* the grant was approved for.
*/
refreshN8nOAuth2Flow(refreshToken: string, resourceUrl: string): Promise<N8nOAuth2RefreshResult>;
/**
* Verifies an AS access token against `resourceUrl` (the expected audience) without
* running a redirect flow. Used by resource-server triggers (MCP) that receive a
@@ -3760,6 +3787,10 @@ export interface IWorkflowExecuteAdditionalData {
*/
beginN8nOAuth2Flow?: (resourceUrl: string, metadata?: Record<string, string>) => Promise<string>;
completeN8nOAuth2Flow?: (code: string, state: string) => Promise<N8nOAuth2FlowResult>;
refreshN8nOAuth2Flow?: (
refreshToken: string,
resourceUrl: string,
) => Promise<N8nOAuth2RefreshResult>;
validateN8nOAuth2Token?: (
token: string,
resourceUrl: string,