Files
computer-playbook/roles/web-app-n8n/files/javascript/hooks.js
kevinveenbirkenbachandClaude Opus 5.5 a0c7023eab fix(web-app-n8n): log the SSO hook through n8n's own logger
The SSO hook reported its start, every sign-in, every created account and
every error through this.logger (#852). n8n 2 calls an external hook with
a context that holds only dbCollections, so this.logger is undefined and
the optional chaining turned every one of those calls into a no-op: after
an SSO sign-in that created an account, the container log held no line
about it.

The hook now resolves the Logger service from n8n's dependency container
(@n8n/di and @n8n/backend-common, loaded through n8n's own require like
the other modules the hook uses) and writes every message through it. The
requires moved to the top of the ready hook so the first message can use
the logger.

Verified on the pinned image docker.n8n.io/n8nio/n8n:2.44.0, started
with EXTERNAL_HOOK_FILES pointing at the hook and N8N_FORWARD_AUTH_HEADER
set: with this hook the log carries "SSO middleware initializing with
header: X-Forwarded-Email" and "SSO middleware initialized successfully"
before "Editor is now accessible via:"; with the hook of main the same
start reaches the editor without a single SSO line.

Closes #852

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:32:10 +02:00

185 lines
7.7 KiB
JavaScript

// nocheck: mirrored-unit-test - the hook body resolves n8n's own auth/jwt module
// through require.resolve('n8n') at call time; without an n8n install there is nothing
// to invoke. The trusted-header contract is covered end to end by the role's Playwright
// spec instead
/**
* n8n EXTERNAL_HOOK_FILES middleware: trusted-header SSO bridge for n8n
* Community Edition.
*
* n8n CE does not accept the oauth2-proxy edge session as its own — a
* Keycloak round-trip only clears the `auth_request /oauth2/auth` gate in
* openresty, leaving the visitor on n8n's native email/password login form.
* This hook closes that gap: it reads the trusted identity header openresty
* sets after the auth_request succeeds (see
* roles/web-app-n8n/templates/proxy.conf.j2), auto-provisions a local n8n
* user on first sign-in, and issues n8n's own session cookie directly.
*
* Security: the header named by N8N_FORWARD_AUTH_HEADER (Remote-Email) is
* trusted unconditionally. This is only safe because any client-supplied
* copy of it is blanked by
* roles/sys-svc-proxy/templates/headers/untrusted_identity_strips.conf.j2
* before openresty re-sets it from the oauth2-proxy auth_request response,
* and n8n's published port is bound to the host's loopback interface
* (DOCKER_BIND_HOST), so only openresty on the same host can reach it
* directly — external clients cannot bypass the proxy to forge the header.
*
* Adapted from https://github.com/PavelSozonov/n8n-community-sso (MIT
* License, Copyright (c) 2025 n8n Community SSO Demo). Differences from
* upstream:
* - first/last name are decoded from X-Forwarded-Access-Token, the
* access token nginx bridges through
* roles/web-app-keycloak/templates/sso_proxy/following_directives.conf.j2,
* instead of the raw client Authorization header (which a client can
* set to anything and is not part of this platform's trust chain).
* - the upstream demo's `/logout` route is dropped: this platform's own
* `/oauth2/sign_out` (oauth2-proxy) and n8n's native `/signout` already
* cover logout (see roles/web-app-n8n/files/playwright/_shared.js).
*/
module.exports = {
n8n: {
ready: [
async function ({ app }, config) {
const { createRequire } = require('module');
const { dirname, resolve } = require('path');
const { randomBytes } = require('crypto');
const n8nRequire = createRequire(require.resolve('n8n'));
const logger = n8nRequire('@n8n/di').Container.get(n8nRequire('@n8n/backend-common').Logger);
const headerName = process.env.N8N_FORWARD_AUTH_HEADER;
if (!headerName) {
logger.info('N8N_FORWARD_AUTH_HEADER not set; SSO middleware disabled.');
return;
}
logger.info(`SSO middleware initializing with header: ${headerName}`);
const Layer = createRequire(n8nRequire.resolve('express'))('router/lib/layer');
const { hash } = n8nRequire('bcryptjs');
const { issueCookie } = require(resolve(dirname(require.resolve('n8n')), 'auth/jwt'));
// Trust the proxy for correct X-Forwarded-* handling and rate limiting
app.set('trust proxy', 1);
const ignoreAuth = /^\/(assets|healthz|webhook|rest\/oauth2-credential|health)/;
const cookieName = 'n8n-auth';
const UserRepo = this.dbCollections.User;
const { stack } = app.router;
const idx = stack.findIndex((l) => l?.name === 'cookieParser');
const layer = new Layer('/', { strict: false, end: false }, async (req, res, next) => {
try {
// Skip if URL matches ignore list
if (ignoreAuth.test(req.url)) return next();
// Skip until instance owner setup is complete
if (!config.get('userManagement.isInstanceOwnerSetUp', false)) return next();
// Skip if auth cookie already present
if (req.cookies?.[cookieName]) return next();
// Read email from the trusted header, and optional given/family
// names from the trusted access token forwarded alongside it.
const emailHeader = req.headers[headerName.toLowerCase()];
const accessToken = req.headers['x-forwarded-access-token'] || '';
let firstName = '';
let lastName = '';
if (accessToken) {
try {
const parts = String(accessToken).split('.');
if (parts.length === 3) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
firstName = payload.given_name || '';
lastName = payload.family_name || '';
logger.debug(`Extracted from access token: firstName="${firstName}", lastName="${lastName}"`);
}
} catch (e) {
logger.debug(`Failed to decode access token: ${e.message}`);
}
}
// If the forward-auth header with email is missing — do nothing
if (!emailHeader) {
logger.debug(`No ${headerName} header found, skipping SSO auto-login`);
return next();
}
const userEmail = Array.isArray(emailHeader) ? emailHeader[0] : String(emailHeader).trim();
const userFirstName = Array.isArray(firstName) ? firstName[0] : String(firstName).trim();
const userLastName = Array.isArray(lastName) ? lastName[0] : String(lastName).trim();
if (!userEmail) {
logger.debug(`Empty ${headerName} header, skipping SSO auto-login`);
return next();
}
logger.info(`SSO auto-login attempt for email: ${userEmail}`);
let user = await UserRepo.findOne({
where: { email: userEmail },
relations: ['role'],
});
if (!user) {
const hashed = await hash(randomBytes(16).toString('hex'), 10);
const userData = {
email: userEmail,
role: { slug: 'global:member' },
password: hashed,
};
if (userFirstName) userData.firstName = userFirstName;
if (userLastName) userData.lastName = userLastName;
const created = await UserRepo.createUserWithProject(userData);
user = created.user;
logger.info(`Created new user: ${userEmail} (${userFirstName} ${userLastName}) via SSO`);
} else {
let changed = false;
if (userFirstName && user.firstName !== userFirstName) {
user.firstName = userFirstName;
changed = true;
}
if (userLastName && user.lastName !== userLastName) {
user.lastName = userLastName;
changed = true;
}
if (changed) {
await UserRepo.save(user);
logger.info(`Updated user: ${userEmail} (${userFirstName} ${userLastName}) via SSO`);
} else {
logger.info(`Existing user logged in: ${userEmail} via SSO`);
}
}
if (!user.role) {
logger.error(`User ${userEmail} has no valid role; cannot issue cookie.`);
res.statusCode = 401;
res.end(`User ${userEmail} has no valid role. Ask admin to assign a role.`);
return;
}
issueCookie(res, user);
req.user = user;
req.userId = user.id;
return next();
} catch (error) {
logger.error(`SSO middleware error: ${error.message}`);
return next(error);
}
});
// Insert our middleware right after cookieParser
stack.splice(idx + 1, 0, layer);
logger.info('SSO middleware initialized successfully');
}
]
}
};