mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
185 lines
7.7 KiB
JavaScript
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');
|
|
}
|
|
]
|
|
}
|
|
};
|