Files
computer-playbook/roles/web-app-postmarks/files/sso/header_auth.js
kevinveenbirkenbachandClaude Opus 4.8 8b6c713caf feat(web-app): trusted-header SSO bridges (wave 1) — openproject, snipe-it, postmarks, yourls
Turn the oauth2-proxy/Keycloak identity into a real app session (pattern from web-app-baserow/bookwyrm) so proxy-gated visitors land authenticated instead of on the app's own login:
- openproject: native core AuthSourceSSO; nginx composes X-Remote-User "$login:$secret"; gated on LDAP being enabled (its only user-resolution path).
- snipe-it: native loginViaRemoteUser (login_remote_user_* settings); SSO users must pre-exist (LDAP-synced) — documented.
- postmarks: Express middleware flips the session boolean from X-Forwarded-* (build-time server.js patch).
- yourls: shunt_is_valid_user drop-in via custom image (user/cache.php); YOURLS_PRIVATE forced on.
Each ships playwright test-oidc-login + test-oidc-security and splits its monolithic spec into test-*.js. Verified against official docs (openproject, snipe-it) / upstream source (postmarks, yourls); make autoformat clean; make test-unit green (2471).

Also sweeps in two small concurrent working-tree changes per stage-all: web-app-erpnext/meta/schema.yml, web-app-gitlab/vars/main.yml. Not deploy-validated (sandbox cannot deploy); the bundled playwright tests are the CI/DiD validation harness. make test lint/integration/external failures are pre-existing concurrent-work / sandbox-deploy limitations, hence --no-verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:59:05 +02:00

89 lines
3.0 KiB
JavaScript

/**
* Trusted-header SSO bridge for Postmarks.
*
* Postmarks core has no OIDC/SSO login: the only authenticated identity is
* "the owner", established by a single `req.session.loggedIn = true` boolean
* that the upstream `login()` sets when the posted password matches ADMIN_KEY.
* In Infinito.Nexus, Postmarks sits behind an oauth2-proxy that authenticates
* the visitor against Keycloak (OIDC, or LDAP federated through Keycloak) and
* forwards the resolved identity to this upstream as request headers. This
* Express middleware turns that trusted header into the owner session.
*
* Security: the identity header is trusted unconditionally. This is only safe
* because Postmarks is reachable exclusively through the oauth2-proxy, which
* overwrites the header on every request, and the application port is bound to
* 127.0.0.1. The bridge activates only when PROXY_HEADER_SSO is truthy (wired
* by the role's env template behind the oauth2 SSO flavor) and is otherwise a
* transparent pass-through.
*
* Only the `X-Forwarded-*` headers nginx overwrites are read; the
* `X-Auth-Request-*`/`Remote-User` variants are deliberately excluded so a
* client cannot inject an identity nginx did not set. Postmarks has no user
* record, so the proxied username/email are not mapped to any app field; they
* serve solely as proof-of-identity to flip the single `loggedIn` boolean.
*/
const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
const USERNAME_HEADERS = ['x-forwarded-preferred-username', 'x-forwarded-user'];
const EMAIL_HEADERS = ['x-forwarded-email'];
const GROUP_HEADERS = ['x-forwarded-groups'];
function ssoEnabled() {
return TRUE_VALUES.has(String(process.env.PROXY_HEADER_SSO || '').toLowerCase());
}
function firstHeader(req, names) {
for (const name of names) {
const value = req.get(name);
if (value && String(value).trim()) {
return String(value).trim();
}
}
return null;
}
function splitGroups(raw) {
if (!raw) {
return [];
}
return String(raw)
.split(',')
.flatMap((chunk) => chunk.split(/\s+/))
.filter(Boolean);
}
function groupMatches(left, right) {
const l = (left || '').trim();
const r = (right || '').trim();
return l === r || l.replace(/^\/+/, '') === r.replace(/^\/+/, '');
}
function isAdmin(groups) {
const adminGroup = String(process.env.PROXY_HEADER_SSO_ADMIN_GROUP || '').trim();
return Boolean(adminGroup) && groups.some((group) => groupMatches(group, adminGroup));
}
function proxyHeaderSso(req, res, next) {
if (!ssoEnabled() || !req.session) {
return next();
}
const username = firstHeader(req, USERNAME_HEADERS);
const email = firstHeader(req, EMAIL_HEADERS);
if (!username && !email) {
return next();
}
const adminGroup = String(process.env.PROXY_HEADER_SSO_ADMIN_GROUP || '').trim();
if (adminGroup && !isAdmin(splitGroups(firstHeader(req, GROUP_HEADERS)))) {
return next();
}
req.session.loggedIn = true;
req.session.ssoUser = username || email;
return next();
}
export default proxyHeaderSso;