From 8875cc35da5c26f2ed3e2480e42898724aacecbe Mon Sep 17 00:00:00 2001 From: "Culpur Defense Inc." Date: Wed, 15 Jul 2026 18:24:41 +0200 Subject: [PATCH] feat(auth): trusted-header SSO (Culpur fork) 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. --- .../database/repositories/user/schema.ts | 6 +- .../database/repositories/user/service.ts | 60 ++++++++++++++ src/server/middleware/trustedAuth.ts | 81 +++++++++++++++++++ src/server/utils/config.ts | 33 ++++++++ src/server/utils/password.ts | 9 ++- 5 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/server/middleware/trustedAuth.ts diff --git a/src/server/database/repositories/user/schema.ts b/src/server/database/repositories/user/schema.ts index 77eb13e2..69fba4b3 100644 --- a/src/server/database/repositories/user/schema.ts +++ b/src/server/database/repositories/user/schema.ts @@ -6,7 +6,11 @@ import { client } from '../../schema'; export const user = sqliteTable('users_table', { id: int().primaryKey({ autoIncrement: true }), username: text().notNull().unique(), - password: text().notNull(), + // Nullable: federated users (authProvider != 'local') have no local password. + password: text(), + // Which auth backend owns this account. 'local' = password login, + // 'trusted-header' = provisioned/authenticated by a trusted upstream proxy. + authProvider: text('auth_provider').notNull().default('local'), email: text(), name: text().notNull(), role: int().$type().notNull(), diff --git a/src/server/database/repositories/user/service.ts b/src/server/database/repositories/user/service.ts index f130da20..fd8ee4f3 100644 --- a/src/server/database/repositories/user/service.ts +++ b/src/server/database/repositories/user/service.ts @@ -98,6 +98,66 @@ export class UserService { }); } + /** + * Look up a federated (trusted-header SSO) user by username, creating one if + * absent. Provisioned accounts have no local password (authProvider = + * 'trusted-header'), so they can never log in via the password/Basic path. + * + * The FIRST user ever created still becomes ADMIN (matches create()); after + * that the caller-supplied defaultRole applies. Existing accounts keep their + * role — this never escalates a user on subsequent logins. email/name from the + * upstream are refreshed on each login so the profile stays current. + */ + async getOrCreateExternal( + username: string, + opts: { email?: string | null; name?: string | null; defaultRole?: Role } + ): Promise { + return this.#db.transaction(async (tx) => { + const existing = await tx.query.user + .findFirst({ where: eq(user.username, username) }) + .execute(); + + if (existing) { + // Refresh profile fields from the upstream, but never touch role/enabled. + if ( + (opts.email !== undefined && opts.email !== existing.email) || + (opts.name && opts.name !== existing.name) + ) { + await tx + .update(user) + .set({ + email: opts.email ?? existing.email, + name: opts.name ?? existing.name, + }) + .where(eq(user.id, existing.id)) + .execute(); + } + return (await tx.query.user + .findFirst({ where: eq(user.id, existing.id) }) + .execute())!; + } + + const userCount = await tx.$count(user); + const role = + userCount === 0 ? roles.ADMIN : (opts.defaultRole ?? roles.CLIENT); + + await tx.insert(user).values({ + username, + password: null, + authProvider: 'trusted-header', + email: opts.email ?? null, + name: opts.name ?? username, + role, + totpVerified: false, + enabled: true, + }); + + return (await tx.query.user + .findFirst({ where: eq(user.username, username) }) + .execute())!; + }); + } + async update(id: ID, name: string, email: string | null) { return this.#statements.update.execute({ id, name, email }); } diff --git a/src/server/middleware/trustedAuth.ts b/src/server/middleware/trustedAuth.ts new file mode 100644 index 00000000..90e8b4c2 --- /dev/null +++ b/src/server/middleware/trustedAuth.ts @@ -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}` + ); +}); diff --git a/src/server/utils/config.ts b/src/server/utils/config.ts index 1c5b2081..8f2a4b34 100644 --- a/src/server/utils/config.ts +++ b/src/server/utils/config.ts @@ -38,6 +38,39 @@ export const WG_ENV = { /** If IPv6 should be disabled */ DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true', WG_EXECUTABLE: await detectAwg(), + /** + * Trusted-header SSO (Culpur fork). + * + * When enabled, an authenticated upstream reverse proxy (e.g. an Authentik + * forward-auth outpost) may assert the caller's identity via a header. wg-easy + * then establishes a session for the matching user, auto-provisioning one if + * absent. DISABLED by default: stock behaviour is unchanged unless set. + * + * SECURITY: the identity header is trusted ONLY when the request's real socket + * peer is in TRUSTED_PROXY_IPS. Any client can forge headers, so without the IP + * allowlist this would be an auth bypass. Leave both unset to keep the feature off. + */ + TRUSTED_PROXY_ENABLED: process.env.TRUSTED_PROXY_ENABLED === 'true', + /** Header carrying the verified username, e.g. 'x-authentik-username'. */ + TRUSTED_PROXY_HEADER: ( + process.env.TRUSTED_PROXY_HEADER ?? 'x-authentik-username' + ).toLowerCase(), + /** Optional header carrying the verified email. */ + TRUSTED_PROXY_EMAIL_HEADER: ( + process.env.TRUSTED_PROXY_EMAIL_HEADER ?? 'x-authentik-email' + ).toLowerCase(), + /** Optional header carrying the display name. */ + TRUSTED_PROXY_NAME_HEADER: ( + process.env.TRUSTED_PROXY_NAME_HEADER ?? 'x-authentik-name' + ).toLowerCase(), + /** Comma-separated allowlist of proxy source IPs permitted to assert identity. */ + TRUSTED_PROXY_IPS: (process.env.TRUSTED_PROXY_IPS ?? '') + .split(',') + .map((x) => x.trim()) + .filter((x) => x.length > 0), + /** Role for auto-provisioned federated users: 'admin' or 'client' (default). */ + TRUSTED_PROXY_DEFAULT_ROLE: + process.env.TRUSTED_PROXY_DEFAULT_ROLE === 'admin' ? 'admin' : 'client', }; export const WG_INITIAL_ENV = { diff --git a/src/server/utils/password.ts b/src/server/utils/password.ts index 915795c7..b4320625 100644 --- a/src/server/utils/password.ts +++ b/src/server/utils/password.ts @@ -5,11 +5,18 @@ import { deserialize } from '@phc/format'; /** * Checks if `password` matches the hash. + * + * `hash` may be null for federated users (authProvider !== 'local') who have + * no local password. Such accounts must never authenticate via password/Basic, + * so a null hash always fails closed. */ export function isPasswordValid( password: string, - hash: string + hash: string | null ): Promise { + if (!hash) { + return Promise.resolve(false); + } return argon2.verify(hash, password); }