Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.web.example
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,26 @@ APP_PATH_PREFIX=/
VARLENS_ADMIN_USERNAME=
VARLENS_ADMIN_PASSWORD_HASH=
VARLENS_ADMIN_DISPLAY_NAME=

# --- Optional platform OIDC authentication -------------------------------
#
# Unset or `local` keeps the existing username/password flow. `platform`
# redirects web sign-in through an OIDC provider such as Keycloak. Desktop
# builds do not read these settings.
#
# Before the first platform login, bind the expected OIDC subject to this
# VarLens instance using the built operator command:
#
# node out/web/provision-platform-user.cjs \
# --subject <oidc-subject> --display-name <name> --role admin

# VARLENS_AUTH_MODE=platform
# VARLENS_PLATFORM_ISSUER_URL=https://identity.example/realms/varlens
# VARLENS_PLATFORM_CLIENT_ID=varlens
# VARLENS_PLATFORM_AUDIENCE=varlens
# VARLENS_PLATFORM_CALLBACK_PATH=/auth/platform/callback
# VARLENS_PLATFORM_REQUIRED_ACR=urn:example:acr:password-plus-totp
# VARLENS_PLATFORM_REQUIRED_AMR=pwd,otp
# VARLENS_PLATFORM_ENTITLEMENTS_URL=https://platform.example/api/varlens/entitlements
# VARLENS_PLATFORM_ENTITLEMENTS_TOKEN=
# VARLENS_PLATFORM_VERIFY_ACCESS_TOKEN=false
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ RUN node -e "(async () => { \
require('./out/web/server.cjs'); \
require('node:fs').accessSync('./out/web/postgres-import-worker.cjs'); \
require('./out/web/postgres-import-worker.cjs'); \
require('node:fs').accessSync('./out/web/provision-platform-user.cjs'); \
require('./out/web/provision-platform-user.cjs'); \
const Database = require('better-sqlite3-multiple-ciphers'); \
new Database(':memory:').prepare('SELECT 1').get(); \
const argon2 = require('@node-rs/argon2'); \
Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ web-gate-postgres: build-web ## Run fail-loud Postgres-backed web integration te
@if [ -z "$$VARLENS_PG_URL" ]; then echo "VARLENS_PG_URL is required for web-gate-postgres. This is intentionally opt-in and never part of default desktop CI."; exit 2; fi
npx vitest run --project web-gate tests/web-gate/integration
VARLENS_RUN_POSTGRES_E2E=1 npx vitest run --project main \
tests/main/storage/postgres-cases-query-repository.e2e.test.ts
tests/main/storage/postgres-cases-query-repository.e2e.test.ts \
tests/main/storage/postgres-migrations-idempotent.test.ts \
tests/main/web/auth/provision-platform-user-postgres.test.ts

web-gate-parity: web-data-verify ## Run Layer 3 parity scenarios (opt-in; boots Electron, switches native ABI)
@echo "=== web-gate-parity (opt-in; switches native module to Electron ABI) ==="
Expand Down
5 changes: 5 additions & 0 deletions src/main/storage/postgres/migrations/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ const MIGRATION_FILES: readonly MigrationFile[] = [
version: '0015',
name: 'import_visibility',
fileName: '0015_import_visibility.sql'
},
{
version: '0016',
name: 'platform_identity',
fileName: '0016_platform_identity.sql'
}
]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Optional web OIDC identity binding. Instance and database lifecycle remains
-- external; one VarLens database may be bound to at most one platform subject.

ALTER TABLE "__schema__"."users"
ADD COLUMN IF NOT EXISTS auth_source TEXT NOT NULL DEFAULT 'local';

UPDATE "__schema__"."users"
SET auth_source = 'platform'
WHERE password_hash = 'platform-identity-disabled-local-password';

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'users_auth_source_check'
AND conrelid = '"__schema__"."users"'::regclass
) THEN
ALTER TABLE "__schema__"."users"
ADD CONSTRAINT users_auth_source_check
CHECK (auth_source IN ('local', 'platform'));
END IF;
END
$$;

CREATE UNIQUE INDEX IF NOT EXISTS users_single_platform_identity
ON "__schema__"."users" (auth_source)
WHERE auth_source = 'platform';
74 changes: 74 additions & 0 deletions src/web/auth/PostgresPlatformUserStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Pool } from 'pg'

import type { UserRole } from '../../shared/auth/auth-constants'

const DISABLED_LOCAL_PASSWORD_HASH = 'platform-identity-disabled-local-password'

interface PostgresError extends Error {
code?: string
constraint?: string
}

function quoteSchema(schema: string): string {
return `"${schema.replace(/"/g, '""')}"`
}

export class PostgresPlatformUserStore {
private readonly schemaQuoted: string

constructor(
private readonly pool: Pool,
schema: string
) {
this.schemaQuoted = quoteSchema(schema)
}

async upsert(input: {
subject: string
displayName: string
role: UserRole
}): Promise<{ id: number; subject: string; role: UserRole }> {
let result
try {
result = await this.pool.query<{ id: string; username: string; role: UserRole }>(
`INSERT INTO ${this.schemaQuoted}."users" AS platform_target
(username, display_name, password_hash, role, must_change_password, is_active,
password_changed_at, auth_source)
VALUES ($1, $2, $3, $4, FALSE, TRUE, now(), 'platform')
ON CONFLICT (username)
DO UPDATE SET
display_name = EXCLUDED.display_name,
role = EXCLUDED.role,
is_active = TRUE,
must_change_password = FALSE,
updated_at = now()
WHERE platform_target.auth_source = 'platform'
AND platform_target.password_hash = $5
RETURNING id, username, role`,
[
input.subject,
input.displayName,
DISABLED_LOCAL_PASSWORD_HASH,
input.role,
DISABLED_LOCAL_PASSWORD_HASH
]
)
} catch (error) {
const postgresError = error as PostgresError
if (
postgresError.code === '23505' &&
postgresError.constraint === 'users_single_platform_identity'
) {
throw new Error('VarLens instance is already bound to another platform subject', {
cause: error
})
}
throw error
}
if ((result.rowCount ?? 0) === 0) {
throw new Error(`Platform identity cannot overwrite local user: ${input.subject}`)
}
const row = result.rows[0]
return { id: Number(row.id), subject: row.username, role: row.role }
}
}
11 changes: 11 additions & 0 deletions src/web/auth/PostgresWebAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,17 @@ export class PostgresWebAuthService {
return mapPgRowToUser(sel.rows[0])
}

async getPlatformUser(subject: string): Promise<User | undefined> {
const sch = this.schemaQuoted
const sel = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM ${sch}."users"
WHERE username = $1 AND auth_source = 'platform'`,
[subject]
)
if ((sel.rowCount ?? 0) === 0) return undefined
return mapPgRowToUser(sel.rows[0])
}

async listUsers(): Promise<Omit<User, 'password_hash'>[]> {
const sch = this.schemaQuoted
const sel = await this.pool.query<Record<string, unknown>>(
Expand Down
86 changes: 86 additions & 0 deletions src/web/provision-platform-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { getPostgresStorageConfig } from '../main/storage/config'
import { createPostgresStorageSession } from '../main/storage/postgres/createPostgresStorageSession'
import { ROLE_ADMIN, ROLE_USER, type UserRole } from '../shared/auth/auth-constants'
import { PostgresPlatformUserStore } from './auth/PostgresPlatformUserStore'

interface Options {
subject: string
displayName: string
role: UserRole
}

const VALUE_ARGS = new Set(['--subject', '--display-name', '--role'])

function readArg(args: string[], name: string): string | undefined {
const index = args.indexOf(name)
if (index < 0) return undefined
const value = args[index + 1]
if (value === undefined || value.startsWith('--')) {
throw new Error(`${name} requires a value`)
}
return value
}

function assertKnownArgs(args: string[]): void {
for (let index = 0; index < args.length; index += 2) {
const name = args[index]
if (!VALUE_ARGS.has(name)) {
throw new Error(`Unknown argument: ${name}`)
}
if (args[index + 1] === undefined) {
throw new Error(`${name} requires a value`)
}
}
}

export function parseOptions(args: string[]): Options {
assertKnownArgs(args)
const subject = readArg(args, '--subject')?.trim()
const displayName = readArg(args, '--display-name')?.trim()
const role = readArg(args, '--role')?.trim() ?? ROLE_USER

if (subject === undefined || subject === '') {
throw new Error('--subject is required')
}
if (displayName === undefined || displayName === '') {
throw new Error('--display-name is required')
}
if (role !== ROLE_USER && role !== ROLE_ADMIN) {
throw new Error('--role must be either user or admin')
}

return { subject, displayName, role }
}

async function main(): Promise<void> {
const options = parseOptions(process.argv.slice(2))
const config = getPostgresStorageConfig(process.env)
if (config === null) {
throw new Error('VARLENS_PG_URL is required')
}

const session = await createPostgresStorageSession(config)
try {
const users = new PostgresPlatformUserStore(session.getPool(), config.schema)
const result = await users.upsert({
subject: options.subject,
displayName: options.displayName,
role: options.role
})
process.stdout.write(
JSON.stringify({ ok: true, subject: result.subject, role: result.role }) + '\n'
)
} finally {
await session.close()
}
}

declare const require: NodeJS.Require
declare const module: NodeJS.Module
if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) {
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(JSON.stringify({ ok: false, error: message }) + '\n')
process.exit(1)
})
}
56 changes: 52 additions & 4 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@ import { createPostgresStorageSession } from '../main/storage/postgres/createPos
import type { PostgresStorageSession } from '../main/storage/postgres/PostgresStorageSession'
import type { StorageSession } from '../main/storage/session'
import { AdminAlreadyExistsError, PostgresWebAuthService } from './auth/PostgresWebAuthService'
import { recordAuthAudit } from './server/audit'
import { buildDispatcher, registerDispatcher } from './server/dispatcher'
import { registerSessions } from './server/auth'
import { registerEventStream, WebEventHub } from './server/events'
import { registerLoginRoute, resolveAppPathPrefix } from './server/login-route'
import { registerPageGate } from './server/page-gate'
import { PlatformIdentityService } from './server/platform-identity'
import { readPlatformIdentityConfig } from './server/platform-identity-config'
import { registerPlatformIdentityRoutes } from './server/platform-identity-routes'
import { registerWebRateLimit } from './server/rate-limit'
import { registerImportUploadRoutes } from './server/routes/upload-staging'
import { registerOpenApi } from './server/routes/openapi'
Expand Down Expand Up @@ -82,6 +86,13 @@ export interface BuildAppOptions {
}

export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
const appPathPrefix = resolveAppPathPrefix()
const platformIdentityConfig = readPlatformIdentityConfig(process.env)
const platformIdentity =
platformIdentityConfig === null
? undefined
: new PlatformIdentityService(platformIdentityConfig)

// Validate Postgres config BEFORE building the app; any later
// failure path means we'd hold a partially-spun Fastify instance,
// which the SIGTERM tests can't cleanly tear down.
Expand All @@ -95,6 +106,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn

const app = Fastify({
genReqId: () => randomUUID(),
// Pinned, not inherited. Fastify 5 already defaults this to false, but the
// default has changed across majors and `true` resolves to the `request-id`
// header — which would let a client choose its own request ID and forge or
// collide log correlation. Stating it explicitly means a future Fastify
// default flip cannot silently re-enable header trust.
requestIdHeader: false,
logController: new LogController({ requestIdLogLabel: 'request_id' }),
logger: {
level: process.env.VARLENS_LOG_LEVEL ?? 'info'
Expand Down Expand Up @@ -122,7 +139,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
await maybeBootstrapAdmin(authService, options.admin, app.log)
}

await registerSessions(app, { authService })
await registerSessions(app, {
authService,
...(platformIdentity !== undefined ? { platformIdentity } : {})
})
await registerOpenApi(app)
const events = new WebEventHub()

Expand All @@ -131,9 +151,37 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
// static handler so the explicit `/login` route wins over the SPA
// fallback, and so the gate runs before any route handler ships
// bytes. `/api/*`, `/healthz`, and `/login*` are passthrough.
const appPathPrefix = resolveAppPathPrefix()
registerLoginRoute(app)
registerPageGate(app, { appPathPrefix })
if (platformIdentity !== undefined) {
registerPlatformIdentityRoutes(app, {
identity: platformIdentity,
authService,
appPathPrefix,
audit: async (event) => {
await recordAuthAudit(
{ session: session as StorageSession } as Parameters<typeof recordAuthAudit>[0],
{
action_type: event.action,
username: event.subject ?? 'platform-login-attempt',
...(event.subject !== undefined ? { actor: event.subject } : {}),
...(event.role !== undefined ? { role: event.role } : {}),
success: event.action === 'auth_login_success',
...(event.reason !== undefined ? { reason: event.reason } : {})
}
)
}
})
}
registerLoginRoute(app, { platformAuthEnabled: platformIdentity !== undefined })
registerPageGate(app, {
appPathPrefix,
loginPath: platformIdentity !== undefined ? '/auth/platform/start' : '/login',
...(platformIdentity !== undefined
? {
platformCallbackPath: platformIdentity.config.callbackPath,
requirePlatformAuth: true
}
: {})
})

const dispatcherDeps = {
session: session as StorageSession,
Expand Down
Loading