mirror of https://github.com/wg-easy/wg-easy
Browse Source
Add an optional trusted-header authentication path so an authenticated upstream reverse proxy (Authentik forward-auth outpost) can assert the caller identity and wg-easy establishes a session for the matching user, auto-provisioning federated accounts. - server/middleware/trustedAuth.ts: trust gate (feature flag + source-IP allowlist) -> read identity header -> getOrCreateExternal -> mint session - server/utils/config.ts: TRUSTED_PROXY_* env (off by default) - user/service.ts: getOrCreateExternal() for password-less federated users - user/schema.ts: password nullable + auth_provider column - password.ts: isPasswordValid null-safe (federated users fail password/Basic) Migration for the schema change generated separately via drizzle-kit. Authored By: Culpur Defense Inc.pull/2693/head
5 changed files with 187 additions and 2 deletions
@ -0,0 +1,81 @@ |
|||||
|
/* |
||||
|
* Trusted-header SSO (Culpur Defense fork). |
||||
|
* |
||||
|
* Runs before route handlers. When TRUSTED_PROXY_ENABLED is set AND the request's |
||||
|
* real socket peer is in TRUSTED_PROXY_IPS, this reads the identity header that a |
||||
|
* trusted upstream (an Authentik forward-auth outpost) has asserted, resolves or |
||||
|
* auto-provisions the matching wg-easy user, and establishes the normal wg-easy |
||||
|
* session. Everything downstream (getCurrentUser -> definePermissionEventHandler |
||||
|
* -> RBAC) then works unchanged, exactly as if the user had logged in with a form. |
||||
|
* |
||||
|
* SECURITY MODEL — why this is not an auth bypass: |
||||
|
* 1. Off by default. Stock wg-easy never runs this branch. |
||||
|
* 2. Source-IP allowlist. The identity header is honoured ONLY when the request |
||||
|
* arrives from the trusted proxy. We read the real connection peer via |
||||
|
* getRequestIP(event, { xForwardedFor: false }) — NOT X-Forwarded-For, which |
||||
|
* a client could spoof. In our topology the outpost also strips any |
||||
|
* client-supplied X-authentik-* headers and sets its own, so the header cannot |
||||
|
* arrive pre-set from a browser. |
||||
|
* |
||||
|
* If the feature is disabled, the source is not allowlisted, or no header is |
||||
|
* present, this middleware does nothing and the request falls through to the |
||||
|
* existing password/session auth untouched. |
||||
|
*/ |
||||
|
export default defineEventHandler(async (event) => { |
||||
|
if (!WG_ENV.TRUSTED_PROXY_ENABLED) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Only act on API calls and page loads; leave setup/i18n alone (mirrors setup.ts).
|
||||
|
const url = getRequestURL(event); |
||||
|
if (url.pathname.startsWith('/_i18n/') || url.pathname.startsWith('/setup/')) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 1. Trust gate — the real socket peer must be an allowlisted proxy.
|
||||
|
// xForwardedFor:false makes h3 return the connection remoteAddress, so a
|
||||
|
// spoofed X-Forwarded-For cannot satisfy this check.
|
||||
|
const peer = getRequestIP(event, { xForwardedFor: false }); |
||||
|
if (!peer || !WG_ENV.TRUSTED_PROXY_IPS.includes(peer)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 2. Read the identity the upstream verified.
|
||||
|
const username = getHeader(event, WG_ENV.TRUSTED_PROXY_HEADER)?.trim(); |
||||
|
if (!username) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// If a valid session already exists for this same user, don't re-mint it.
|
||||
|
const existingSession = await getWGSession(event); |
||||
|
if (existingSession.data.userId) { |
||||
|
const current = await Database.users.get(existingSession.data.userId); |
||||
|
if (current && current.username === username) { |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 3. Resolve or provision the federated user, then establish the session.
|
||||
|
const email = getHeader(event, WG_ENV.TRUSTED_PROXY_EMAIL_HEADER)?.trim(); |
||||
|
const name = getHeader(event, WG_ENV.TRUSTED_PROXY_NAME_HEADER)?.trim(); |
||||
|
|
||||
|
const user = await Database.users.getOrCreateExternal(username, { |
||||
|
email: email || null, |
||||
|
name: name || username, |
||||
|
defaultRole: |
||||
|
WG_ENV.TRUSTED_PROXY_DEFAULT_ROLE === 'admin' ? roles.ADMIN : roles.CLIENT, |
||||
|
}); |
||||
|
|
||||
|
if (!user.enabled) { |
||||
|
// A locally-disabled federated user is blocked here; the normal disabled-user
|
||||
|
// 403 in getCurrentUser also covers this once a session exists.
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const session = await useWGSession(event); |
||||
|
await session.update({ userId: user.id }); |
||||
|
|
||||
|
SERVER_DEBUG( |
||||
|
`Trusted-header SSO: session established for ${user.id} (${user.username}) from ${peer}` |
||||
|
); |
||||
|
}); |
||||
Loading…
Reference in new issue