Browse Source

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.
pull/2693/head
Culpur Defense Inc. 6 days ago
parent
commit
8875cc35da
  1. 6
      src/server/database/repositories/user/schema.ts
  2. 60
      src/server/database/repositories/user/service.ts
  3. 81
      src/server/middleware/trustedAuth.ts
  4. 33
      src/server/utils/config.ts
  5. 9
      src/server/utils/password.ts

6
src/server/database/repositories/user/schema.ts

@ -6,7 +6,11 @@ import { client } from '../../schema';
export const user = sqliteTable('users_table', { export const user = sqliteTable('users_table', {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(), 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(), email: text(),
name: text().notNull(), name: text().notNull(),
role: int().$type<Role>().notNull(), role: int().$type<Role>().notNull(),

60
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<UserType> {
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) { async update(id: ID, name: string, email: string | null) {
return this.#statements.update.execute({ id, name, email }); return this.#statements.update.execute({ id, name, email });
} }

81
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}`
);
});

33
src/server/utils/config.ts

@ -38,6 +38,39 @@ export const WG_ENV = {
/** If IPv6 should be disabled */ /** If IPv6 should be disabled */
DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true', DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true',
WG_EXECUTABLE: await detectAwg(), 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 = { export const WG_INITIAL_ENV = {

9
src/server/utils/password.ts

@ -5,11 +5,18 @@ import { deserialize } from '@phc/format';
/** /**
* Checks if `password` matches the hash. * 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( export function isPasswordValid(
password: string, password: string,
hash: string hash: string | null
): Promise<boolean> { ): Promise<boolean> {
if (!hash) {
return Promise.resolve(false);
}
return argon2.verify(hash, password); return argon2.verify(hash, password);
} }

Loading…
Cancel
Save