From a6c69bc48349e2fed4a78c6f3760eaaa968e47d5 Mon Sep 17 00:00:00 2001 From: lftobs Date: Mon, 22 Jun 2026 05:09:33 +0100 Subject: [PATCH 1/3] feat(api): add PAM auth and token utilities - Add PAM-based login flow using a Python PAM verifier and a new pam-verify.py script - Implement token-based auth: sign/verify access tokens, rotate and store refresh tokens in SQLite - Add refresh_tokens table, Drizzle migration, and related utilities to manage tokens - Auto-generate and load a JWT secret on first API startup; initialize auth at boot - Expose /auth/login, /auth/logout, /auth/refresh, and /auth/me endpoints - Update Dockerfile to install Python3 for PAM script execution - Add frontend login page and route; integrate with auth client and layout - Add docs pages for authentication and installation; include new auth.md - Add unit tests for auth utilities and DB integrations - Update server-info endpoint to check base domain status --- apps/api/Dockerfile | 6 +- apps/api/src/api/auth/index.ts | 93 +++++++ apps/api/src/api/index.ts | 42 ++- apps/api/src/api/server-info/index.ts | 4 +- apps/api/src/db/__tests__/auth.test.ts | 95 +++++++ .../src/db/migrations/0001_refresh_tokens.sql | 8 + apps/api/src/db/migrations/meta/_journal.json | 11 +- apps/api/src/db/schema.ts | 9 + apps/api/src/index.ts | 6 + apps/api/src/utils/__tests__/auth.test.ts | 91 +++++++ apps/api/src/utils/auth.ts | 116 ++++++++ apps/api/src/utils/config.ts | 6 +- apps/api/src/utils/dns.ts | 30 +++ apps/api/src/utils/secrets.ts | 16 ++ apps/docs/src/content/docs/auth.md | 53 ++++ apps/docs/src/content/docs/installation.md | 12 + apps/web/src/api/client.ts | 18 +- apps/web/src/components/ConfigWarnings.tsx | 13 +- apps/web/src/components/Layout.tsx | 26 ++ apps/web/src/routes/Login.tsx | 247 ++++++++++++++++++ apps/web/src/routes/index.tsx | 9 +- docker-compose.yml | 3 + scripts/pam-verify.py | 127 +++++++++ 23 files changed, 1016 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/api/auth/index.ts create mode 100644 apps/api/src/db/__tests__/auth.test.ts create mode 100644 apps/api/src/db/migrations/0001_refresh_tokens.sql create mode 100644 apps/api/src/utils/__tests__/auth.test.ts create mode 100644 apps/api/src/utils/auth.ts create mode 100644 apps/api/src/utils/secrets.ts create mode 100644 apps/docs/src/content/docs/auth.md create mode 100644 apps/web/src/routes/Login.tsx create mode 100644 scripts/pam-verify.py diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 4392ef5..867b354 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ unzip \ tar \ gnupg \ + python3 \ && rm -rf /var/lib/apt/lists/* # Install Docker CLI + buildx plugin from Docker's official repo @@ -31,10 +32,11 @@ RUN curl -sSL https://mise.jdx.dev/install.sh | sh \ WORKDIR /app -COPY package.json ./ +COPY apps/api/package.json ./ RUN bun install -COPY src ./src +COPY apps/api/src ./src +COPY scripts ./scripts RUN mkdir -p /app/data /app/workspace /caddy/routes diff --git a/apps/api/src/api/auth/index.ts b/apps/api/src/api/auth/index.ts new file mode 100644 index 0000000..cab9e18 --- /dev/null +++ b/apps/api/src/api/auth/index.ts @@ -0,0 +1,93 @@ +import { Elysia } from "elysia"; +import { spawn } from "node:child_process"; +import { signAccessToken, verifyAccessToken, generateRefreshToken, storeRefreshToken, validateRefreshToken, blacklistRefreshToken } from "../../utils/auth"; +import { config } from "../../utils/config"; +import { join } from "node:path"; + +const PAM_SCRIPT = "/app/scripts/pam-verify.py"; + +const callPam = (username: string, password: string): Promise<{ ok: boolean; username?: string; error?: string }> => + new Promise((resolve) => { + const proc = spawn("python3", [PAM_SCRIPT], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (chunk) => { stdout += String(chunk); }); + proc.stderr.on("data", (chunk) => { stderr += String(chunk); }); + proc.on("close", (code) => { + if (code !== 0) { + try { resolve(JSON.parse(stdout)); } + catch { resolve({ ok: false, error: stderr.trim() || "Authentication failed" }); } + return; + } + try { resolve(JSON.parse(stdout)); } + catch { resolve({ ok: false, error: "Invalid response from auth helper" }); } + }); + proc.stdin!.end(JSON.stringify({ username, password })); + }); + +const COOKIE_OPTS = { + path: "/", + httpOnly: true, + sameSite: "strict" as const, + secure: config.caddyBaseDomain !== "localhost", + maxAge: 7 * 24 * 60 * 60, +}; + +export const authRoutes = new Elysia() + .post("/auth/login", async ({ body, cookie: { dequel_session, dequel_refresh }, set }) => { + const { username, password } = body as { username?: string; password?: string }; + if (!username || !password) { + set.status = 400; + return { error: "Username and password required" }; + } + const result = await callPam(username, password); + if (!result.ok) { + set.status = 401; + return { error: result.error || "Authentication failed" }; + } + const accessToken = await signAccessToken(username); + const refreshToken = generateRefreshToken(); + await storeRefreshToken(username, refreshToken); + dequel_session.value = accessToken; + dequel_session.set(COOKIE_OPTS); + dequel_refresh.value = refreshToken; + dequel_refresh.set(COOKIE_OPTS); + return { ok: true, username }; + }) + .post("/auth/logout", async ({ cookie: { dequel_session, dequel_refresh } }) => { + const rt = dequel_refresh.value; + if (rt) { + try { await blacklistRefreshToken(rt); } catch {} + } + dequel_session.remove(); + dequel_refresh.remove(); + return { ok: true }; + }) + .post("/auth/refresh", async ({ cookie: { dequel_session, dequel_refresh }, set }) => { + const rt = dequel_refresh.value; + if (!rt) { + set.status = 401; + return { error: "No refresh token" }; + } + const username = await validateRefreshToken(rt); + if (!username) { + set.status = 401; + return { error: "Invalid or expired refresh token" }; + } + try { await blacklistRefreshToken(rt); } catch {} + const accessToken = await signAccessToken(username); + const newRefreshToken = generateRefreshToken(); + await storeRefreshToken(username, newRefreshToken); + dequel_session.value = accessToken; + dequel_session.set(COOKIE_OPTS); + dequel_refresh.value = newRefreshToken; + dequel_refresh.set(COOKIE_OPTS); + return { ok: true, username }; + }) + .get("/auth/me", async ({ cookie: { dequel_session } }) => { + const token = dequel_session.value; + if (!token) return { authenticated: false }; + const payload = await verifyAccessToken(token); + if (!payload) return { authenticated: false }; + return { authenticated: true, username: payload.sub }; + }); diff --git a/apps/api/src/api/index.ts b/apps/api/src/api/index.ts index a47c08a..8d03eab 100644 --- a/apps/api/src/api/index.ts +++ b/apps/api/src/api/index.ts @@ -1,6 +1,7 @@ import { Elysia } from "elysia"; import { alertsRoutes } from "./alerts"; import { apiKeysRoutes } from "./api-keys"; +import { authRoutes } from "./auth"; import { databasesRoutes } from "./databases"; import { deploymentsRoutes } from "./deployments"; import { domainsRoutes } from "./domains"; @@ -15,27 +16,42 @@ import { serversRoutes } from "./servers"; import { volumesRoutes } from "./volumes"; import { settingsRoutes } from "./settings"; +const BYPASS_PATHS = new Set(["/api/auth/login", "/api/auth/logout", "/api/auth/refresh", "/api/auth/me", "/api/health"]); + const authMiddleware = (app: Elysia) => - app.onBeforeHandle(async ({ request, set }) => { - const authHeader = request.headers.get( - "authorization", - ); - if (!authHeader?.startsWith("Bearer ")) - return; - const token = authHeader.slice(7); - if (!token) return; - const { validateApiKey } = - await import("../db/repo"); - const key = await validateApiKey(token); - if (!key) { + app.onBeforeHandle(async ({ request, set, path }) => { + if (BYPASS_PATHS.has(path)) return; + + const cookie = request.headers.get("cookie") || ""; + const match = cookie.match(/(?:^|;\s*)dequel_session=([^;]+)/); + if (match) { + const { verifyAccessToken } = await import("../utils/auth"); + const payload = await verifyAccessToken(match[1]); + if (payload) return; set.status = 401; - return { error: "Invalid API key" }; + return { error: "Invalid session" }; } + + const authHeader = request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + if (token) { + const { validateApiKey } = await import("../db/repo"); + const key = await validateApiKey(token); + if (key) return; + set.status = 401; + return { error: "Invalid API key" }; + } + } + + set.status = 401; + return { error: "Authentication required" }; }); export const apiRoutes = new Elysia({ prefix: "/api", }) + .use(authRoutes) .use(authMiddleware) .use(healthRoutes) .use(projectsRoutes) diff --git a/apps/api/src/api/server-info/index.ts b/apps/api/src/api/server-info/index.ts index 9a163a4..c27604c 100644 --- a/apps/api/src/api/server-info/index.ts +++ b/apps/api/src/api/server-info/index.ts @@ -3,7 +3,7 @@ import { Elysia } from "elysia"; export const serverInfoRoutes = new Elysia().get( "/server/ip", async () => { - const { resolveServerIp } = await import("../../utils/dns"); - return { ip: await resolveServerIp() }; + const { checkBaseDomainStatus } = await import("../../utils/dns"); + return await checkBaseDomainStatus(); }, ); diff --git a/apps/api/src/db/__tests__/auth.test.ts b/apps/api/src/db/__tests__/auth.test.ts new file mode 100644 index 0000000..5b1c675 --- /dev/null +++ b/apps/api/src/db/__tests__/auth.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, mock, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { Database } from 'bun:sqlite'; + +const TEST_SECRET = 'test-jwt-secret-for-testing-purposes-only'; + +let db: Database; + +const fileUrl = (path: string) => new URL(path, import.meta.url).toString(); +mock.module(fileUrl('../client.ts'), () => ({ + getDb: () => db, +})); + +beforeAll(async () => { + db = new Database(':memory:'); + db.run(` + CREATE TABLE refresh_tokens ( + id text PRIMARY KEY NOT NULL, + username text NOT NULL, + token_hash text NOT NULL UNIQUE, + expires_at text NOT NULL, + created_at text NOT NULL, + blacklisted_at text + ) + `); + const { initAuth } = await import('../../utils/auth'); + initAuth(TEST_SECRET); +}); + +beforeEach(() => { + db.run('DELETE FROM refresh_tokens'); +}); + +afterAll(() => { + db.close(); +}); + +describe('storeRefreshToken / validateRefreshToken', () => { + it('stores and validates a refresh token', async () => { + const { generateRefreshToken, storeRefreshToken, validateRefreshToken } = await import('../../utils/auth'); + const token = generateRefreshToken(); + await storeRefreshToken('testuser', token); + const username = await validateRefreshToken(token); + expect(username).toBe('testuser'); + }); + + it('returns null for unknown token', async () => { + const { validateRefreshToken } = await import('../../utils/auth'); + const result = await validateRefreshToken('dqr_nonexistent'); + expect(result).toBeNull(); + }); +}); + +describe('blacklistRefreshToken', () => { + it('blacklists a refresh token', async () => { + const { generateRefreshToken, storeRefreshToken, validateRefreshToken, blacklistRefreshToken } = await import('../../utils/auth'); + const token = generateRefreshToken(); + await storeRefreshToken('testuser', token); + expect(await validateRefreshToken(token)).toBe('testuser'); + await blacklistRefreshToken(token); + expect(await validateRefreshToken(token)).toBeNull(); + }); + + it('does not affect other tokens when blacklisting one', async () => { + const { generateRefreshToken, storeRefreshToken, validateRefreshToken, blacklistRefreshToken } = await import('../../utils/auth'); + const tokenA = generateRefreshToken(); + const tokenB = generateRefreshToken(); + await storeRefreshToken('user1', tokenA); + await storeRefreshToken('user2', tokenB); + await blacklistRefreshToken(tokenA); + expect(await validateRefreshToken(tokenA)).toBeNull(); + expect(await validateRefreshToken(tokenB)).toBe('user2'); + }); +}); + +describe('cleanupExpiredTokens', () => { + it('removes expired tokens', async () => { + const { generateRefreshToken, storeRefreshToken, validateRefreshToken, cleanupExpiredTokens, hashToken } = await import('../../utils/auth'); + const token = generateRefreshToken(); + await storeRefreshToken('testuser', token); + const tokenHash = hashToken(token); + db.run(`UPDATE refresh_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE token_hash = ?`, [tokenHash]); + await cleanupExpiredTokens(); + const remaining = db.query('SELECT COUNT(*) as c FROM refresh_tokens').get() as { c: number }; + expect(remaining.c).toBe(0); + }); + + it('keeps non-expired tokens', async () => { + const { generateRefreshToken, storeRefreshToken, cleanupExpiredTokens } = await import('../../utils/auth'); + const token = generateRefreshToken(); + await storeRefreshToken('testuser', token); + await cleanupExpiredTokens(); + const remaining = db.query('SELECT COUNT(*) as c FROM refresh_tokens').get() as { c: number }; + expect(remaining.c).toBe(1); + }); +}); diff --git a/apps/api/src/db/migrations/0001_refresh_tokens.sql b/apps/api/src/db/migrations/0001_refresh_tokens.sql new file mode 100644 index 0000000..0c284c3 --- /dev/null +++ b/apps/api/src/db/migrations/0001_refresh_tokens.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id text PRIMARY KEY NOT NULL, + username text NOT NULL, + token_hash text NOT NULL UNIQUE, + expires_at text NOT NULL, + created_at text NOT NULL, + blacklisted_at text +); diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index df2ce5b..8d4d821 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -2,12 +2,19 @@ "version": "6", "dialect": "sqlite", "entries": [ -{ - "idx": 0, + { + "idx": 0, "version": "6", "when": 1718000000000, "tag": "0000_baseline", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1782100000000, + "tag": "0001_refresh_tokens", + "breakpoints": true } ] } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 1891d65..049e48a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -152,6 +152,15 @@ export const servers = sqliteTable("servers", { updatedAt: text("updated_at").notNull(), }); +export const refreshTokens = sqliteTable("refresh_tokens", { + id: text().primaryKey(), + username: text().notNull(), + tokenHash: text("token_hash").notNull().unique(), + expiresAt: text("expires_at").notNull(), + createdAt: text("created_at").notNull(), + blacklistedAt: text("blacklisted_at"), +}); + export const apiKeys = sqliteTable("api_keys", { id: text().primaryKey(), name: text().notNull(), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3906e06..5fad630 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,11 +12,16 @@ import { serverManager } from './servers/manager'; import { startGitWatcher } from './git/watcher'; import { startDomainPolling } from './utils/domain-verifier'; import { alertEvaluator } from './monitoring/evaluator'; +import { loadOrCreateJwtSecret } from './utils/secrets'; +import { initAuth, cleanupExpiredTokens } from './utils/auth'; const bootstrap = async () => { await mkdir(dirname(config.databasePath), { recursive: true }); await mkdir(config.workspaceRoot, { recursive: true }); await mkdir(config.caddyRoutesDir, { recursive: true }); + const jwtSecret = await loadOrCreateJwtSecret(dirname(config.databasePath)); + initAuth(jwtSecret); + await migrate(); await orchestrator.reconcileState(); orchestrator.startWorker(); @@ -25,6 +30,7 @@ const bootstrap = async () => { startGitWatcher(); startDomainPolling(); alertEvaluator.start(); + setInterval(() => { cleanupExpiredTokens().catch(() => {}); }, 60_000); const metrics = { requestsTotal: 0, diff --git a/apps/api/src/utils/__tests__/auth.test.ts b/apps/api/src/utils/__tests__/auth.test.ts new file mode 100644 index 0000000..af96521 --- /dev/null +++ b/apps/api/src/utils/__tests__/auth.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, mock, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { Database } from 'bun:sqlite'; + +const TEST_SECRET = 'test-jwt-secret-for-testing-purposes-only'; + +describe('JWT operations', () => { + beforeAll(async () => { + const { initAuth } = await import('../auth'); + initAuth(TEST_SECRET); + }); + + it('signs and verifies an access token', async () => { + const { signAccessToken, verifyAccessToken } = await import('../auth'); + const token = await signAccessToken('testuser'); + expect(typeof token).toBe('string'); + expect(token.split('.').length).toBe(3); + const payload = await verifyAccessToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('testuser'); + expect(payload!.iat).toBeGreaterThan(0); + expect(payload!.exp).toBe(payload!.iat + 900); + }); + + it('rejects a tampered token', async () => { + const { signAccessToken, verifyAccessToken } = await import('../auth'); + const token = await signAccessToken('testuser'); + const parts = token.split('.'); + const tampered = ['bad', parts[1], parts[2]].join('.'); + expect(await verifyAccessToken(tampered)).toBeNull(); + }); + + it('rejects token with wrong secret', async () => { + const { signAccessToken, verifyAccessToken, initAuth } = await import('../auth'); + const token = await signAccessToken('testuser'); + initAuth('different-secret'); + const result = await verifyAccessToken(token); + expect(result).toBeNull(); + initAuth(TEST_SECRET); + }); + + it('rejects malformed token', async () => { + const { verifyAccessToken } = await import('../auth'); + expect(await verifyAccessToken('not-a-jwt')).toBeNull(); + expect(await verifyAccessToken('only.two.parts.here')).toBeNull(); + expect(await verifyAccessToken('')).toBeNull(); + }); + + it('rejects expired token', async () => { + const { verifyAccessToken } = await import('../auth'); + const parts = ['header', btoa(JSON.stringify({ + sub: 'testuser', + iat: 1000, + exp: 1, + })).replace(/=/g, ''), 'fakesig']; + const result = await verifyAccessToken(parts.join('.')); + expect(result).toBeNull(); + }); +}); + +describe('hashToken', () => { + it('produces consistent hashes', async () => { + const { hashToken } = await import('../auth'); + const h1 = hashToken('test-token'); + const h2 = hashToken('test-token'); + expect(h1).toBe(h2); + expect(h1.length).toBe(64); + }); + + it('produces different hashes for different tokens', async () => { + const { hashToken } = await import('../auth'); + const h1 = hashToken('token-a'); + const h2 = hashToken('token-b'); + expect(h1).not.toBe(h2); + }); +}); + +describe('generateRefreshToken', () => { + it('generates token with correct prefix', async () => { + const { generateRefreshToken } = await import('../auth'); + const token = generateRefreshToken(); + expect(token.startsWith('dqr_')).toBe(true); + expect(token.length).toBe(4 + 64); + }); + + it('generates unique tokens', async () => { + const { generateRefreshToken } = await import('../auth'); + const t1 = generateRefreshToken(); + const t2 = generateRefreshToken(); + expect(t1).not.toBe(t2); + }); +}); diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts new file mode 100644 index 0000000..fadbba0 --- /dev/null +++ b/apps/api/src/utils/auth.ts @@ -0,0 +1,116 @@ +import { randomBytes } from 'node:crypto'; +import { getDrizzle } from '../db/drizzle'; +import { eq, and, sql } from 'drizzle-orm'; +import { refreshTokens } from '../db/schema'; + +let jwtSecret = ''; + +export const initAuth = (secret: string) => { + jwtSecret = secret; +}; + +const b64url = (buf: ArrayBuffer): string => + btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); + +const b64urlDecode = (str: string): ArrayBuffer => { + str = str.replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) str += '='; + return Uint8Array.from(atob(str), c => c.charCodeAt(0)).buffer; +}; + +const encoder = new TextEncoder(); + +const hmacSign = async (data: string): Promise => { + const key = await crypto.subtle.importKey( + 'raw', encoder.encode(jwtSecret), + { name: 'HMAC', hash: 'SHA-256' }, + false, ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + return b64url(sig); +}; + +export interface JwtPayload { + sub: string; + iat: number; + exp: number; +} + +export const signAccessToken = async (username: string): Promise => { + const header = b64url(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))); + const now = Math.floor(Date.now() / 1000); + const payload = b64url(encoder.encode(JSON.stringify({ + sub: username, + iat: now, + exp: now + 900, + }))); + const sig = await hmacSign(`${header}.${payload}`); + return `${header}.${payload}.${sig}`; +}; + +export const verifyAccessToken = async (token: string): Promise => { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [header, payload, sig] = parts; + const expected = await hmacSign(`${header}.${payload}`); + if (sig !== expected) return null; + try { + const data = JSON.parse(new TextDecoder().decode(b64urlDecode(payload))); + if (data.exp && data.exp < Math.floor(Date.now() / 1000)) return null; + return data; + } catch { + return null; + } +}; + +export const hashToken = (token: string): string => { + const hash = Bun.CryptoHasher.hash('sha256', token); + return Buffer.from(hash).toString('hex'); +}; + +export const generateRefreshToken = (): string => + `dqr_${randomBytes(32).toString('hex')}`; + +export const storeRefreshToken = async (username: string, token: string): Promise => { + const drizzle = await getDrizzle(); + const tokenHash = hashToken(token); + const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const id = randomBytes(16).toString('hex'); + drizzle.insert(refreshTokens).values({ + id, + username, + tokenHash, + expiresAt, + createdAt: now, + }).run(); +}; + +export const validateRefreshToken = async (token: string): Promise => { + const drizzle = await getDrizzle(); + const tokenHash = hashToken(token); + const row = drizzle.select().from(refreshTokens) + .where(and( + eq(refreshTokens.tokenHash, tokenHash), + sql`${refreshTokens.blacklistedAt} IS NULL`, + sql`${refreshTokens.expiresAt} > datetime('now')`, + )) + .get(); + return row?.username ?? null; +}; + +export const blacklistRefreshToken = async (token: string): Promise => { + const drizzle = await getDrizzle(); + const tokenHash = hashToken(token); + const now = new Date().toISOString(); + drizzle.update(refreshTokens) + .set({ blacklistedAt: now }) + .where(eq(refreshTokens.tokenHash, tokenHash)) + .run(); +}; + +export const cleanupExpiredTokens = async (): Promise => { + const drizzle = await getDrizzle(); + drizzle.run(sql`DELETE FROM refresh_tokens WHERE expires_at < datetime('now')`); +}; diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index f6c8dd4..d311de9 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -24,9 +24,6 @@ const withFile = ( }; const SYSTEM = { - databasePath: "/app/data/dequel.db", - workspaceRoot: "/app/workspace", - caddyRoutesDir: "/caddy/routes", dockerNetwork: "dequel_net", buildkitHost: "tcp://buildkit:1234", redisUrl: "redis://redis:6379", @@ -34,6 +31,9 @@ const SYSTEM = { export const config = { ...SYSTEM, + databasePath: withFile("DATABASE_PATH", "/app/data/dequel.db"), + workspaceRoot: withFile("WORKSPACE_ROOT", "/app/workspace"), + caddyRoutesDir: withFile("CADDY_ROUTES_DIR", "/caddy/routes"), port: withFile( "PORT", "17474", diff --git a/apps/api/src/utils/dns.ts b/apps/api/src/utils/dns.ts index 831d10d..0097c91 100644 --- a/apps/api/src/utils/dns.ts +++ b/apps/api/src/utils/dns.ts @@ -1,4 +1,5 @@ import { promises as dns } from 'node:dns'; +import { config } from './config'; export const resolveServerIp = async (): Promise => { try { @@ -9,6 +10,35 @@ export const resolveServerIp = async (): Promise => { } }; +export type BaseDomainStatus = { + ip: string; + baseDomain: string; + resolves: boolean; + url: string; +}; + +export const checkBaseDomainStatus = async (): Promise => { + const ip = await resolveServerIp(); + const baseDomain = config.caddyBaseDomain; + const isLocalhost = baseDomain === 'localhost'; + + let resolves = false; + if (!isLocalhost) { + try { + const addresses = await dns.resolve4(baseDomain); + resolves = addresses.includes(ip); + } catch {} + } else { + resolves = true; + } + + const url = isLocalhost || resolves + ? `${isLocalhost ? 'http' : 'https'}://${baseDomain}${isLocalhost ? ':80' : ''}` + : `http://${ip}`; + + return { ip, baseDomain, resolves, url }; +}; + export const validateDomain = async ( domain: string, serverIp: string, diff --git a/apps/api/src/utils/secrets.ts b/apps/api/src/utils/secrets.ts new file mode 100644 index 0000000..e37e112 --- /dev/null +++ b/apps/api/src/utils/secrets.ts @@ -0,0 +1,16 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +export const loadOrCreateJwtSecret = async (dataDir: string): Promise => { + const path = join(dataDir, '.jwt_secret'); + try { + const existing = await readFile(path, 'utf8'); + return existing.trim(); + } catch { + await mkdir(dirname(path), { recursive: true }); + const secret = randomBytes(32).toString('hex'); + await writeFile(path, secret + '\n', { mode: 0o600 }); + return secret; + } +}; diff --git a/apps/docs/src/content/docs/auth.md b/apps/docs/src/content/docs/auth.md new file mode 100644 index 0000000..7bb9fad --- /dev/null +++ b/apps/docs/src/content/docs/auth.md @@ -0,0 +1,53 @@ +--- +title: Authentication & Access Control +category: Networking & Security +description: Secure your Dequel dashboard with PAM-based authentication, session management, and API keys. +slug: auth +--- + +Dequel authenticates users against the **Linux system user database** via PAM (Pluggable Authentication Modules). Only users who are members of the `dequel` group can sign in. + +## User Setup + +Create a Linux user and add them to the `dequel` group: + +```bash +sudo useradd -m -s /bin/bash +sudo passwd +sudo usermod -aG dequel +``` + +The user signs into the Dequel dashboard with their Linux username and password. + +## Session Management + +- **Access tokens**: Short-lived (15 min) HMAC-SHA256 JWTs stored in an `httpOnly` cookie. +- **Refresh tokens**: Long-lived (7 days) random tokens stored in the database. Automatically rotated on refresh. +- **Token blacklisting**: Logging out or refreshing invalidates the old refresh token immediately. +- **JWT secret**: Auto-generated on first API startup, stored at `data/.jwt_secret` (0600 permissions). Deleting this file invalidates all sessions. + +## API Keys + +For programmatic access (CI/CD, CLI tools), Dequel uses pre-shared API keys: + +1. Go to **Settings → API Keys** in the dashboard. +2. Create a new key — the full token is shown once. +3. Use it as a Bearer token in the `Authorization` header: + +```bash +curl -H "Authorization: Bearer dqk_" https://dequel.example.com/api/projects +``` + +API keys are scoped to the entire platform with the same permissions as the user who created them. + +## Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `POST` | `/api/auth/login` | None | Sign in with username/password | +| `POST` | `/api/auth/logout` | Session | Sign out, blacklist refresh token | +| `POST` | `/api/auth/refresh` | Session | Rotate refresh token | +| `GET` | `/api/auth/me` | None | Check current session status | +| `GET` | `/api/health` | None | Health check | + +The web dashboard redirects to `/login` when no valid session is detected. diff --git a/apps/docs/src/content/docs/installation.md b/apps/docs/src/content/docs/installation.md index 5fda364..1dc0662 100644 --- a/apps/docs/src/content/docs/installation.md +++ b/apps/docs/src/content/docs/installation.md @@ -34,6 +34,18 @@ dequel start Open `http://localhost` to access the dashboard (or the configured `CADDY_BASE_DOMAIN` in production). +## Post-Installation: User Setup + +Dequel authenticates against Linux system users in the `dequel` group. After install, create a user: + +```bash +sudo useradd -m -s /bin/bash +sudo passwd +sudo usermod -aG dequel +``` + +See [Authentication & Access Control](/docs/auth) for details on session management and API keys. + ## Manual Setup (no install script) If the installer fails, set up the platform manually with just Docker Compose and the config files: diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 05e1c67..8438ae2 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -340,7 +340,23 @@ export const deleteScalingPolicy = ( // Server export const getServerIp = () => - apiFetch<{ ip: string }>("/server/ip"); + apiFetch<{ ip: string; baseDomain: string; resolves: boolean; url: string }>("/server/ip"); + +// Auth +export const login = (username: string, password: string) => + apiFetch<{ ok: boolean; username: string; error?: string }>("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + +export const logout = () => + apiFetch<{ ok: boolean }>("/auth/logout", { method: "POST" }); + +export const refreshSession = () => + apiFetch<{ ok: boolean; username: string }>("/auth/refresh", { method: "POST" }); + +export const getMe = () => + apiFetch<{ authenticated: boolean; username?: string }>("/auth/me"); // Prometheus export const queryPrometheus = (query: string) => diff --git a/apps/web/src/components/ConfigWarnings.tsx b/apps/web/src/components/ConfigWarnings.tsx index 3113b87..7a0b4f3 100644 --- a/apps/web/src/components/ConfigWarnings.tsx +++ b/apps/web/src/components/ConfigWarnings.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Button } from './ui/button'; -import { Mail, GitBranch, type LucideIcon } from 'lucide-react'; +import { Globe, Mail, GitBranch, type LucideIcon } from 'lucide-react'; import * as api from '../api/client'; export function ConfigWarnings() { @@ -13,6 +13,9 @@ export function ConfigWarnings() { const github = useQuery({ queryKey: ['github-integration'], queryFn: () => api.getGithubIntegration(), }); + const serverInfo = useQuery({ + queryKey: ['server-ip'], queryFn: () => api.getServerIp(), + }); const missing: { key: string; icon: LucideIcon; title: string; desc: string }[] = []; if (smtp.data && !smtp.data.configured) { @@ -31,6 +34,14 @@ export function ConfigWarnings() { desc: 'Connect a GitHub OAuth App to enable the repo picker and auto-deploy.', }); } + if (serverInfo.data && !serverInfo.data.resolves) { + missing.push({ + key: 'base-domain', + icon: Globe, + title: 'Base domain misconfigured', + desc: `The base domain ${serverInfo.data.baseDomain} does not resolve to this server (${serverInfo.data.ip}). Access the dashboard at ${serverInfo.data.url}.`, + }); + } if (missing.length === 0) return null; diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index 542aeae..b9b79e6 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -11,6 +11,26 @@ import { NotificationBanner } from "./layout/NotificationBanner"; export function Layout({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); + + const { data: me, isLoading: authLoading } = useQuery({ + queryKey: ["auth", "me"], + queryFn: () => api.getMe(), + retry: false, + }); + + useEffect(() => { + if (authLoading) return; + if (location.pathname === "/login") { + if (me?.authenticated) { + navigate({ to: "/" }); + } + return; + } + if (!me?.authenticated) { + navigate({ to: "/login" }); + } + }, [me, authLoading, location.pathname, navigate]); + const { data: projects = [] } = useProjects(); const [projectSelectorOpen, setProjectSelectorOpen] = useState(false); @@ -69,10 +89,16 @@ export function Layout({ children }: { children: React.ReactNode }) { return () => clearTimeout(t); }, [notification]); + const isLoginPage = location.pathname === "/login"; + const match = location.pathname.match(/\/project\/([^/]+)/); const currentProjectId = match ? match[1] : null; const currentProject = projects.find((p) => p.id === currentProjectId); + if (isLoginPage) { + return
{children}
; + } + return (
{ + e.preventDefault(); + setError(''); + setLoading(true); + try { + const res = await api.login(username, password); + if (res.ok) { + window.location.href = '/'; + } else { + setError(res.error || 'Login failed'); + } + } catch (err: any) { + setError(err.message || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Background glow using Dequel orange */} +
+ + {/* Center content container for closer alignment */} +
+ + {/* Left Column: Login Card & Heading */} +
+ {/* Logo Branding */} +
+ + + dequel + + + v0.1 + +
+ + {/* Form */} +
+
+

+ Sign in to Dequel +

+

+ Enter your credentials to access your self-hosted deployment engine. +

+
+ +
+
+ + + Security Gateway + + + + Active + +
+ +
+
+ +
+ + setUsername(e.target.value)} + className="w-full pl-9 pr-4 py-2 rounded-lg border border-[#222227] bg-[#121214] text-zinc-200 text-xs placeholder-zinc-700 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 transition-all" + placeholder="Linux username" + autoFocus + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="w-full pl-9 pr-4 py-2 rounded-lg border border-[#222227] bg-[#121214] text-zinc-200 text-xs placeholder-zinc-700 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 transition-all" + placeholder="Linux password" + required + /> +
+
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ + {/* Footer Info */} +
+ © {new Date().getFullYear()} Dequel. +
+
+ + {/* Right Column: Clean, Low-contrast CSS Mockup of Dequel Dashboard */} +
+ {/* Header Mockup */} +
+
+ + + +
+
+ dequel.local/dashboard +
+
+
+ + {/* Content Layout Mockup */} +
+ {/* Sidebar Mockup */} +
+
+
+ + dequel +
+ +
+
+ + Overview +
+
+ + Logs +
+
+
+ + {/* Sidebar Footer Widget Mockup */} +
+
+ STATUS + + + Live + +
+
+ Services + 3 +
+
+
+ + {/* Main Area Mockup */} +
+
+
+

Overview

+

Manage and monitor cluster resources.

+
+
+ + New Project +
+
+ + {/* Metric stats card */} +
+
+
+
API Traffic
+
148 reqs
+
+ +
+ +
+
+
Deployments
+
3 active
+
+ +
+
+ + {/* Projects list */} +
+
+
+
+ A +
+
+
api-service
+
github.com/lftobs/api
+
+
+ + running + +
+
+
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e33483f..ffbfab3 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,6 +1,7 @@ import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'; import { Layout } from '../components/Layout'; import { Dashboard } from './Dashboard'; +import { Login } from './Login'; import { Settings } from './Settings'; import { ProjectDetail } from './ProjectDetail'; @@ -18,6 +19,12 @@ const indexRoute = createRoute({ component: Dashboard, }); +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: Login, +}); + const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', @@ -38,7 +45,7 @@ const projectRoute = createRoute({ }), }); -const routeTree = rootRoute.addChildren([indexRoute, settingsRoute, projectRoute]); +const routeTree = rootRoute.addChildren([indexRoute, loginRoute, settingsRoute, projectRoute]); export const router = createRouter({ routeTree }); diff --git a/docker-compose.yml b/docker-compose.yml index 95f7051..6dbb4ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,9 @@ services: - ./infra/caddy/routes:/caddy/routes - /var/run/docker.sock:/var/run/docker.sock - railpack-cache:/tmp/railpack + - /etc/passwd:/etc/passwd:ro + - /etc/shadow:/etc/shadow:ro + - /etc/group:/etc/group:ro depends_on: buildkit: condition: service_started diff --git a/scripts/pam-verify.py b/scripts/pam-verify.py new file mode 100644 index 0000000..442b724 --- /dev/null +++ b/scripts/pam-verify.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Authenticate a Linux user via PAM and check dequel group membership. + +Reads JSON { username, password } from stdin. +Exits 0 with { "ok": true, "username": "..." } on success. +Exits 1 with { "ok": false, "error": "..." } on failure. +""" +import json +import sys +import ctypes +import ctypes.util +import subprocess + +GRP_NAME = "dequel" +SERVICE = "dequel" + +PAM_PROMPT_ECHO_OFF = 1 +PAM_SUCCESS = 0 +PAM_END = -1 + + +class PamMessage(ctypes.Structure): + _fields_ = [("msg_style", ctypes.c_int), ("msg", ctypes.c_char_p)] + + +class PamResponse(ctypes.Structure): + _fields_ = [("resp", ctypes.c_void_p), ("resp_retcode", ctypes.c_int)] + + +CONV_FUNC = ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.POINTER(PamMessage)), + ctypes.POINTER(ctypes.c_void_p), + ctypes.c_void_p, +) + + +class PamConv(ctypes.Structure): + _fields_ = [("conv", CONV_FUNC), ("appdata_ptr", ctypes.c_void_p)] + + +def verify(username: str, password: str) -> dict: + libpam = ctypes.cdll.LoadLibrary(ctypes.util.find_library("pam")) + libc = ctypes.cdll.LoadLibrary("libc.so.6") + libpam.pam_start.restype = ctypes.c_int + libpam.pam_authenticate.restype = ctypes.c_int + libpam.pam_acct_mgmt.restype = ctypes.c_int + libpam.pam_end.restype = ctypes.c_int + libpam.pam_strerror.restype = ctypes.c_char_p + libpam.pam_strerror.argtypes = [ctypes.c_void_p, ctypes.c_int] + + password_cpy = [password] + + def conv(nmsg, msg, out_resp, appdata): + count = nmsg + resp_size = ctypes.sizeof(PamResponse) * count + buf = libc.malloc(resp_size) + ctypes.memset(buf, 0, resp_size) + arr = ctypes.cast(buf, ctypes.POINTER(PamResponse)) + for i in range(count): + pm = ctypes.cast(msg[i], ctypes.POINTER(PamMessage))[0] + if pm.msg_style == PAM_PROMPT_ECHO_OFF: + pw = password_cpy[0] + pw_buf = libc.strdup(pw.encode()) + arr[i].resp = pw_buf + arr[i].resp_retcode = 0 + else: + arr[i].resp = None + arr[i].resp_retcode = PAM_END + out_resp[0] = buf + return PAM_SUCCESS + + cb = CONV_FUNC(conv) + conv_struct = PamConv(cb, None) + handle = ctypes.c_void_p() + + ret = libpam.pam_start(SERVICE.encode(), username.encode(), ctypes.byref(conv_struct), ctypes.byref(handle)) + if ret != PAM_SUCCESS: + err = libpam.pam_strerror(handle, ret) + return {"ok": False, "error": f"PAM start failed: {(err or b'').decode()}"} + + ret = libpam.pam_authenticate(handle, 0) + if ret != PAM_SUCCESS: + libpam.pam_end(handle, ret) + return {"ok": False, "error": "Authentication failed"} + + ret = libpam.pam_acct_mgmt(handle, 0) + libpam.pam_end(handle, ret) + if ret != PAM_SUCCESS: + return {"ok": False, "error": "Account expired or disabled"} + + result = subprocess.run( + ["getent", "group", GRP_NAME], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return {"ok": False, "error": f"Group '{GRP_NAME}' does not exist"} + + members = result.stdout.strip().split(":")[-1].split(",") if ":" in result.stdout else [] + if username not in members: + return {"ok": False, "error": f"User '{username}' is not in the '{GRP_NAME}' group"} + + return {"ok": True, "username": username} + + +def main(): + try: + data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(json.dumps({"ok": False, "error": f"Invalid input: {e}"})) + sys.exit(1) + + username = data.get("username", "").strip() + password = data.get("password", "") + + if not username or not password: + print(json.dumps({"ok": False, "error": "Username and password required"})) + sys.exit(1) + + result = verify(username, password) + print(json.dumps(result)) + sys.exit(0 if result["ok"] else 1) + + +if __name__ == "__main__": + main() From caf25f2f5945ec43723ecf75bcf86f5e74940cd1 Mon Sep 17 00:00:00 2001 From: lftobs Date: Tue, 23 Jun 2026 06:10:58 +0100 Subject: [PATCH 2/3] refactor(api): switch PAM auth to HTTP service - Replace in-process PAM verification with HTTP PAM service at pam-auth - Use pam-auth:4567 for authentication requests - Rename and relocate PAM server script to scripts/auth/pam-server.py - Update tests to reflect new PAM flow and token handling --- .dockerignore | 20 ++ apps/api/src/api/auth/index.ts | 53 +++--- apps/api/src/db/__tests__/auth.test.ts | 6 +- apps/api/src/utils/__tests__/auth.test.ts | 218 ++++++++++++++-------- apps/api/src/utils/auth.ts | 7 +- docker-compose.yml | 25 ++- scripts/auth/pam-server.py | 167 +++++++++++++++++ scripts/{ => auth}/pam-verify.py | 26 ++- 8 files changed, 408 insertions(+), 114 deletions(-) create mode 100644 .dockerignore create mode 100644 scripts/auth/pam-server.py rename scripts/{ => auth}/pam-verify.py (79%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ce11e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitignore +.gitattributes +AGENTS.md +CHANGELOG.md +CONTRIBUTING.md +VERSION +*.md +node_modules +apps/web +apps/docs +data +workspace +infra +dist +.env +__pycache__ +*.log +docker-compose.yml +CLI.md diff --git a/apps/api/src/api/auth/index.ts b/apps/api/src/api/auth/index.ts index cab9e18..518f7c8 100644 --- a/apps/api/src/api/auth/index.ts +++ b/apps/api/src/api/auth/index.ts @@ -1,31 +1,32 @@ import { Elysia } from "elysia"; -import { spawn } from "node:child_process"; import { signAccessToken, verifyAccessToken, generateRefreshToken, storeRefreshToken, validateRefreshToken, blacklistRefreshToken } from "../../utils/auth"; import { config } from "../../utils/config"; -import { join } from "node:path"; -const PAM_SCRIPT = "/app/scripts/pam-verify.py"; +const PAM_AUTH_URL = "http://pam-auth:4567"; -const callPam = (username: string, password: string): Promise<{ ok: boolean; username?: string; error?: string }> => - new Promise((resolve) => { - const proc = spawn("python3", [PAM_SCRIPT], { stdio: ["pipe", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - proc.stdout.on("data", (chunk) => { stdout += String(chunk); }); - proc.stderr.on("data", (chunk) => { stderr += String(chunk); }); - proc.on("close", (code) => { - if (code !== 0) { - try { resolve(JSON.parse(stdout)); } - catch { resolve({ ok: false, error: stderr.trim() || "Authentication failed" }); } - return; - } - try { resolve(JSON.parse(stdout)); } - catch { resolve({ ok: false, error: "Invalid response from auth helper" }); } +const callPam = async (username: string, password: string): Promise<{ ok: boolean; username?: string; error?: string }> => { + try { + const res = await fetch(`${PAM_AUTH_URL}/auth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), }); - proc.stdin!.end(JSON.stringify({ username, password })); - }); + const data = await res.json(); + return data; + } catch (err) { + return { ok: false, error: "Auth service unavailable" }; + } +}; + +const SESSION_COOKIE_OPTS = { + path: "/", + httpOnly: true, + sameSite: "strict" as const, + secure: config.caddyBaseDomain !== "localhost", + maxAge: 900, +}; -const COOKIE_OPTS = { +const REFRESH_COOKIE_OPTS = { path: "/", httpOnly: true, sameSite: "strict" as const, @@ -49,9 +50,9 @@ export const authRoutes = new Elysia() const refreshToken = generateRefreshToken(); await storeRefreshToken(username, refreshToken); dequel_session.value = accessToken; - dequel_session.set(COOKIE_OPTS); + dequel_session.set(SESSION_COOKIE_OPTS); dequel_refresh.value = refreshToken; - dequel_refresh.set(COOKIE_OPTS); + dequel_refresh.set(REFRESH_COOKIE_OPTS); return { ok: true, username }; }) .post("/auth/logout", async ({ cookie: { dequel_session, dequel_refresh } }) => { @@ -74,14 +75,14 @@ export const authRoutes = new Elysia() set.status = 401; return { error: "Invalid or expired refresh token" }; } - try { await blacklistRefreshToken(rt); } catch {} + await blacklistRefreshToken(rt); const accessToken = await signAccessToken(username); const newRefreshToken = generateRefreshToken(); await storeRefreshToken(username, newRefreshToken); dequel_session.value = accessToken; - dequel_session.set(COOKIE_OPTS); + dequel_session.set(SESSION_COOKIE_OPTS); dequel_refresh.value = newRefreshToken; - dequel_refresh.set(COOKIE_OPTS); + dequel_refresh.set(REFRESH_COOKIE_OPTS); return { ok: true, username }; }) .get("/auth/me", async ({ cookie: { dequel_session } }) => { diff --git a/apps/api/src/db/__tests__/auth.test.ts b/apps/api/src/db/__tests__/auth.test.ts index 5b1c675..22dc2ef 100644 --- a/apps/api/src/db/__tests__/auth.test.ts +++ b/apps/api/src/db/__tests__/auth.test.ts @@ -74,11 +74,11 @@ describe('blacklistRefreshToken', () => { describe('cleanupExpiredTokens', () => { it('removes expired tokens', async () => { - const { generateRefreshToken, storeRefreshToken, validateRefreshToken, cleanupExpiredTokens, hashToken } = await import('../../utils/auth'); + const { generateRefreshToken, storeRefreshToken, cleanupExpiredTokens } = await import('../../utils/auth'); const token = generateRefreshToken(); await storeRefreshToken('testuser', token); - const tokenHash = hashToken(token); - db.run(`UPDATE refresh_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE token_hash = ?`, [tokenHash]); + const row = db.query('SELECT token_hash FROM refresh_tokens ORDER BY created_at DESC LIMIT 1').get() as { token_hash: string }; + db.run(`UPDATE refresh_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE token_hash = ?`, [row.token_hash]); await cleanupExpiredTokens(); const remaining = db.query('SELECT COUNT(*) as c FROM refresh_tokens').get() as { c: number }; expect(remaining.c).toBe(0); diff --git a/apps/api/src/utils/__tests__/auth.test.ts b/apps/api/src/utils/__tests__/auth.test.ts index af96521..62cb317 100644 --- a/apps/api/src/utils/__tests__/auth.test.ts +++ b/apps/api/src/utils/__tests__/auth.test.ts @@ -1,91 +1,155 @@ -import { describe, it, expect, mock, beforeAll, beforeEach, afterAll } from 'bun:test'; -import { Database } from 'bun:sqlite'; +import { + describe, + it, + expect, + mock, + beforeAll, + beforeEach, + afterAll, +} from "bun:test"; +import { Database } from "bun:sqlite"; -const TEST_SECRET = 'test-jwt-secret-for-testing-purposes-only'; +const TEST_SECRET = + "test-jwt-secret-for-testing-purposes-only"; -describe('JWT operations', () => { - beforeAll(async () => { - const { initAuth } = await import('../auth'); - initAuth(TEST_SECRET); - }); +describe("JWT operations", () => { + beforeAll(async () => { + const { initAuth } = + await import("../auth"); + initAuth(TEST_SECRET); + }); - it('signs and verifies an access token', async () => { - const { signAccessToken, verifyAccessToken } = await import('../auth'); - const token = await signAccessToken('testuser'); - expect(typeof token).toBe('string'); - expect(token.split('.').length).toBe(3); - const payload = await verifyAccessToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('testuser'); - expect(payload!.iat).toBeGreaterThan(0); - expect(payload!.exp).toBe(payload!.iat + 900); - }); + it("signs and verifies an access token", async () => { + const { + signAccessToken, + verifyAccessToken, + } = await import("../auth"); + const token = + await signAccessToken("testuser"); + expect(typeof token).toBe("string"); + expect(token.split(".").length).toBe(3); + const payload = + await verifyAccessToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe("testuser"); + expect(payload!.iat).toBeGreaterThan(0); + expect(payload!.exp).toBe( + payload!.iat + 900, + ); + }); - it('rejects a tampered token', async () => { - const { signAccessToken, verifyAccessToken } = await import('../auth'); - const token = await signAccessToken('testuser'); - const parts = token.split('.'); - const tampered = ['bad', parts[1], parts[2]].join('.'); - expect(await verifyAccessToken(tampered)).toBeNull(); - }); + it("rejects a tampered token", async () => { + const { + signAccessToken, + verifyAccessToken, + } = await import("../auth"); + const token = + await signAccessToken("testuser"); + const parts = token.split("."); + const tampered = [ + "bad", + parts[1], + parts[2], + ].join("."); + expect( + await verifyAccessToken(tampered), + ).toBeNull(); + }); - it('rejects token with wrong secret', async () => { - const { signAccessToken, verifyAccessToken, initAuth } = await import('../auth'); - const token = await signAccessToken('testuser'); - initAuth('different-secret'); - const result = await verifyAccessToken(token); - expect(result).toBeNull(); - initAuth(TEST_SECRET); - }); + it("rejects token with wrong secret", async () => { + const { + signAccessToken, + verifyAccessToken, + initAuth, + } = await import("../auth"); + const token = + await signAccessToken("testuser"); + initAuth("different-secret"); + const result = + await verifyAccessToken(token); + expect(result).toBeNull(); + initAuth(TEST_SECRET); + }); - it('rejects malformed token', async () => { - const { verifyAccessToken } = await import('../auth'); - expect(await verifyAccessToken('not-a-jwt')).toBeNull(); - expect(await verifyAccessToken('only.two.parts.here')).toBeNull(); - expect(await verifyAccessToken('')).toBeNull(); - }); + it("rejects malformed token", async () => { + const { verifyAccessToken } = + await import("../auth"); + expect( + await verifyAccessToken("not-a-jwt"), + ).toBeNull(); + expect( + await verifyAccessToken( + "only.two.parts.here", + ), + ).toBeNull(); + expect( + await verifyAccessToken(""), + ).toBeNull(); + }); - it('rejects expired token', async () => { - const { verifyAccessToken } = await import('../auth'); - const parts = ['header', btoa(JSON.stringify({ - sub: 'testuser', - iat: 1000, - exp: 1, - })).replace(/=/g, ''), 'fakesig']; - const result = await verifyAccessToken(parts.join('.')); - expect(result).toBeNull(); - }); + it("rejects expired token", async () => { + const { signAccessToken, verifyAccessToken } = + await import("../auth"); + const token = await signAccessToken("testuser"); + const [, payloadPart] = token.split("."); + const padded = payloadPart + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd( + Math.ceil(payloadPart.length / 4) * 4, + "=", + ); + const payload = JSON.parse( + atob(padded), + ) as { exp: number }; + + const originalNow = Date.now; + Date.now = () => (payload.exp + 1) * 1000; + try { + expect( + await verifyAccessToken(token), + ).toBeNull(); + } finally { + Date.now = originalNow; + } + }); }); -describe('hashToken', () => { - it('produces consistent hashes', async () => { - const { hashToken } = await import('../auth'); - const h1 = hashToken('test-token'); - const h2 = hashToken('test-token'); - expect(h1).toBe(h2); - expect(h1.length).toBe(64); - }); +describe("hashToken", () => { + it("produces consistent hashes", async () => { + const { hashToken } = + await import("../auth"); + const h1 = hashToken("test-token"); + const h2 = hashToken("test-token"); + expect(h1).toBe(h2); + expect(h1.length).toBe(64); + }); - it('produces different hashes for different tokens', async () => { - const { hashToken } = await import('../auth'); - const h1 = hashToken('token-a'); - const h2 = hashToken('token-b'); - expect(h1).not.toBe(h2); - }); + it("produces different hashes for different tokens", async () => { + const { hashToken } = + await import("../auth"); + const h1 = hashToken("token-a"); + const h2 = hashToken("token-b"); + expect(h1).not.toBe(h2); + }); }); -describe('generateRefreshToken', () => { - it('generates token with correct prefix', async () => { - const { generateRefreshToken } = await import('../auth'); - const token = generateRefreshToken(); - expect(token.startsWith('dqr_')).toBe(true); - expect(token.length).toBe(4 + 64); - }); +describe("generateRefreshToken", () => { + it("generates token with correct prefix", async () => { + const { generateRefreshToken } = + await import("../auth"); + const token = generateRefreshToken(); + expect(token.startsWith("dqr_")).toBe( + true, + ); + expect(token.length).toBe(4 + 64); + }); - it('generates unique tokens', async () => { - const { generateRefreshToken } = await import('../auth'); - const t1 = generateRefreshToken(); - const t2 = generateRefreshToken(); - expect(t1).not.toBe(t2); - }); + it("generates unique tokens", async () => { + const { generateRefreshToken } = + await import("../auth"); + const t1 = generateRefreshToken(); + const t2 = generateRefreshToken(); + expect(t1).not.toBe(t2); + }); }); diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index fadbba0..31fc480 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; import { getDrizzle } from '../db/drizzle'; +import { getDb } from '../db/client'; import { eq, and, sql } from 'drizzle-orm'; import { refreshTokens } from '../db/schema'; @@ -94,7 +95,7 @@ export const validateRefreshToken = async (token: string): Promise datetime('now')`, + sql`datetime(${refreshTokens.expiresAt}) > datetime('now')`, )) .get(); return row?.username ?? null; @@ -111,6 +112,6 @@ export const blacklistRefreshToken = async (token: string): Promise => { }; export const cleanupExpiredTokens = async (): Promise => { - const drizzle = await getDrizzle(); - drizzle.run(sql`DELETE FROM refresh_tokens WHERE expires_at < datetime('now')`); + const db = await getDb(); + db.run(`DELETE FROM refresh_tokens WHERE expires_at < datetime('now')`); }; diff --git a/docker-compose.yml b/docker-compose.yml index 6dbb4ca..a6a5f82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,7 +49,6 @@ services: - /var/run/docker.sock:/var/run/docker.sock - railpack-cache:/tmp/railpack - /etc/passwd:/etc/passwd:ro - - /etc/shadow:/etc/shadow:ro - /etc/group:/etc/group:ro depends_on: buildkit: @@ -71,6 +70,30 @@ services: aliases: - api + pam-auth: + image: python:3-slim + entrypoint: [] + command: + - sh + - -c + - | + apt-get update -qq > /dev/null 2>&1 && apt-get install -y -qq libpam-modules > /dev/null 2>&1 && exec python3 -u /app/pam-server.py + volumes: + - ./scripts/auth/pam-server.py:/app/pam-server.py:ro + - /etc/passwd:/etc/passwd:ro + - /etc/shadow:/etc/shadow:ro + - /etc/group:/etc/group:ro + networks: + dequel: + aliases: + - pam-auth + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:4567/health').status == 200 else 1)"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + web: image: ghcr.io/lftobs/dequel/web:latest # build: diff --git a/scripts/auth/pam-server.py b/scripts/auth/pam-server.py new file mode 100644 index 0000000..98acd03 --- /dev/null +++ b/scripts/auth/pam-server.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""HTTP sidecar for PAM authentication. Replaces direct /etc/shadow access from the API container.""" +import json +import os +import ctypes +import ctypes.util +import subprocess +from http.server import HTTPServer, BaseHTTPRequestHandler + +GRP_NAME = "dequel" +SERVICE = "dequel" + +PAM_PROMPT_ECHO_OFF = 1 +PAM_SUCCESS = 0 +PAM_END = -1 + + +class PamMessage(ctypes.Structure): + _fields_ = [("msg_style", ctypes.c_int), ("msg", ctypes.c_char_p)] + + +class PamResponse(ctypes.Structure): + _fields_ = [("resp", ctypes.c_void_p), ("resp_retcode", ctypes.c_int)] + + +CONV_FUNC = ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.POINTER(PamMessage)), + ctypes.POINTER(ctypes.c_void_p), + ctypes.c_void_p, +) + + +class PamConv(ctypes.Structure): + _fields_ = [("conv", CONV_FUNC), ("appdata_ptr", ctypes.c_void_p)] + + +def verify(username: str, password: str) -> dict: + lib_path = ctypes.util.find_library("pam") + if not lib_path: + return {"ok": False, "error": "PAM library not found on system"} + libpam = ctypes.cdll.LoadLibrary(lib_path) + libc = ctypes.cdll.LoadLibrary("libc.so.6") + libpam.pam_start.restype = ctypes.c_int + libpam.pam_authenticate.restype = ctypes.c_int + libpam.pam_acct_mgmt.restype = ctypes.c_int + libpam.pam_end.restype = ctypes.c_int + libpam.pam_strerror.restype = ctypes.c_char_p + libpam.pam_strerror.argtypes = [ctypes.c_void_p, ctypes.c_int] + + password_cpy = [password] + + def conv(nmsg, msg, out_resp, appdata): + count = nmsg + resp_size = ctypes.sizeof(PamResponse) * count + buf = libc.malloc(resp_size) + ctypes.memset(buf, 0, resp_size) + arr = ctypes.cast(buf, ctypes.POINTER(PamResponse)) + for i in range(count): + pm = ctypes.cast(msg[i], ctypes.POINTER(PamMessage))[0] + if pm.msg_style == PAM_PROMPT_ECHO_OFF: + pw = password_cpy[0] + pw_buf = libc.strdup(pw.encode()) + arr[i].resp = pw_buf + arr[i].resp_retcode = 0 + else: + arr[i].resp = None + arr[i].resp_retcode = PAM_END + out_resp[0] = buf + return PAM_SUCCESS + + cb = CONV_FUNC(conv) + conv_struct = PamConv(cb, None) + handle = ctypes.c_void_p() + + ret = libpam.pam_start(SERVICE.encode(), username.encode(), ctypes.byref(conv_struct), ctypes.byref(handle)) + if ret != PAM_SUCCESS: + err = libpam.pam_strerror(handle, ret) + return {"ok": False, "error": f"PAM start failed: {(err or b'').decode()}"} + + ret = libpam.pam_authenticate(handle, 0) + if ret != PAM_SUCCESS: + libpam.pam_end(handle, ret) + return {"ok": False, "error": "Authentication failed"} + + ret = libpam.pam_acct_mgmt(handle, 0) + libpam.pam_end(handle, ret) + if ret != PAM_SUCCESS: + return {"ok": False, "error": "Account expired or disabled"} + + result = subprocess.run( + ["getent", "group", GRP_NAME], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return {"ok": False, "error": f"Group '{GRP_NAME}' does not exist"} + + group_parts = result.stdout.strip().split(":") + members = group_parts[-1].split(",") if len(group_parts) >= 4 else [] + gid = group_parts[2] if len(group_parts) >= 3 else None + + if username in members: + return {"ok": True, "username": username} + + if gid: + pw_result = subprocess.run( + ["getent", "passwd", username], + capture_output=True, text=True, timeout=10, + ) + if pw_result.returncode == 0: + user_gid = pw_result.stdout.strip().split(":")[3] if ":" in pw_result.stdout else None + if user_gid == gid: + return {"ok": True, "username": username} + + return {"ok": False, "error": f"User '{username}' is not in the '{GRP_NAME}' group"} + + +class PamHandler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + data = json.loads(body) + except json.JSONDecodeError: + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode()) + return + + username = data.get("username", "").strip() + password = data.get("password", "") + + if not username or not password: + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "Username and password required"}).encode()) + return + + result = verify(username, password) + status = 200 if result.get("ok") else 401 + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(result).encode()) + + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"ok": True}).encode()) + return + self.send_response(404) + self.end_headers() + + +def main(): + port = int(os.environ.get("PORT", "4567")) + server = HTTPServer(("0.0.0.0", port), PamHandler) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/pam-verify.py b/scripts/auth/pam-verify.py similarity index 79% rename from scripts/pam-verify.py rename to scripts/auth/pam-verify.py index 442b724..14e5e5e 100644 --- a/scripts/pam-verify.py +++ b/scripts/auth/pam-verify.py @@ -41,7 +41,10 @@ class PamConv(ctypes.Structure): def verify(username: str, password: str) -> dict: - libpam = ctypes.cdll.LoadLibrary(ctypes.util.find_library("pam")) + lib_path = ctypes.util.find_library("pam") + if not lib_path: + return {"ok": False, "error": "PAM library not found on system"} + libpam = ctypes.cdll.LoadLibrary(lib_path) libc = ctypes.cdll.LoadLibrary("libc.so.6") libpam.pam_start.restype = ctypes.c_int libpam.pam_authenticate.restype = ctypes.c_int @@ -97,9 +100,24 @@ def conv(nmsg, msg, out_resp, appdata): if result.returncode != 0: return {"ok": False, "error": f"Group '{GRP_NAME}' does not exist"} - members = result.stdout.strip().split(":")[-1].split(",") if ":" in result.stdout else [] - if username not in members: - return {"ok": False, "error": f"User '{username}' is not in the '{GRP_NAME}' group"} + group_parts = result.stdout.strip().split(":") + members = group_parts[-1].split(",") if len(group_parts) >= 4 else [] + gid = group_parts[2] if len(group_parts) >= 3 else None + + if username in members: + return {"ok": True, "username": username} + + if gid: + pw_result = subprocess.run( + ["getent", "passwd", username], + capture_output=True, text=True, timeout=10, + ) + if pw_result.returncode == 0: + user_gid = pw_result.stdout.strip().split(":")[3] if ":" in pw_result.stdout else None + if user_gid == gid: + return {"ok": True, "username": username} + + return {"ok": False, "error": f"User '{username}' is not in the '{GRP_NAME}' group"} return {"ok": True, "username": username} From afc28aa5032823f233471cdd7db2696feca22077 Mon Sep 17 00:00:00 2001 From: lftobs Date: Sat, 27 Jun 2026 03:08:03 +0100 Subject: [PATCH 3/3] refactor(auth): add timeout and libc fixes - Add AbortSignal.timeout(5000) to PAM authentication request in apps/api/src/api/auth/index.ts - Fetch projects only when authenticated and not on /login in apps/web/src/components/Layout.tsx - Update useProjects to accept options and pass through to useQuery in apps/web/src/hooks/useProjects.ts - Harden PAM Python scripts by loading libc via find_library and setting malloc/strdup restype in scripts/auth/pam-server.py - Symmetrically update pam-verify.py to use find_library for libc and set malloc/strdup restype - Remove premature success return from pam-verify.py to fix flow --- apps/api/src/api/auth/index.ts | 1 + apps/web/src/components/Layout.tsx | 2 +- apps/web/src/hooks/useProjects.ts | 4 ++-- scripts/auth/pam-server.py | 4 +++- scripts/auth/pam-verify.py | 6 +++--- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/auth/index.ts b/apps/api/src/api/auth/index.ts index 518f7c8..8581577 100644 --- a/apps/api/src/api/auth/index.ts +++ b/apps/api/src/api/auth/index.ts @@ -10,6 +10,7 @@ const callPam = async (username: string, password: string): Promise<{ ok: boolea method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), + signal: AbortSignal.timeout(5000), }); const data = await res.json(); return data; diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index b9b79e6..d47c4a9 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -31,7 +31,7 @@ export function Layout({ children }: { children: React.ReactNode }) { } }, [me, authLoading, location.pathname, navigate]); - const { data: projects = [] } = useProjects(); + const { data: projects = [] } = useProjects({ enabled: !!me?.authenticated && location.pathname !== "/login" }); const [projectSelectorOpen, setProjectSelectorOpen] = useState(false); const { data: metricsText } = useQuery({ diff --git a/apps/web/src/hooks/useProjects.ts b/apps/web/src/hooks/useProjects.ts index 23bd2ac..55bf629 100644 --- a/apps/web/src/hooks/useProjects.ts +++ b/apps/web/src/hooks/useProjects.ts @@ -2,8 +2,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { listProjects, createProject, deleteProject } from '../api/client'; import type { Project } from '../types'; -export function useProjects() { - return useQuery({ queryKey: ['projects'], queryFn: listProjects, refetchInterval: 10_000 }); +export function useProjects(options?: { enabled?: boolean }) { + return useQuery({ queryKey: ['projects'], queryFn: listProjects, refetchInterval: 10_000, ...options }); } export function useProject(id: string) { diff --git a/scripts/auth/pam-server.py b/scripts/auth/pam-server.py index 98acd03..4bb96d8 100644 --- a/scripts/auth/pam-server.py +++ b/scripts/auth/pam-server.py @@ -41,13 +41,15 @@ def verify(username: str, password: str) -> dict: if not lib_path: return {"ok": False, "error": "PAM library not found on system"} libpam = ctypes.cdll.LoadLibrary(lib_path) - libc = ctypes.cdll.LoadLibrary("libc.so.6") + libc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("c") or "libc.so.6") libpam.pam_start.restype = ctypes.c_int libpam.pam_authenticate.restype = ctypes.c_int libpam.pam_acct_mgmt.restype = ctypes.c_int libpam.pam_end.restype = ctypes.c_int libpam.pam_strerror.restype = ctypes.c_char_p libpam.pam_strerror.argtypes = [ctypes.c_void_p, ctypes.c_int] + libc.malloc.restype = ctypes.c_void_p + libc.strdup.restype = ctypes.c_void_p password_cpy = [password] diff --git a/scripts/auth/pam-verify.py b/scripts/auth/pam-verify.py index 14e5e5e..e7ca80b 100644 --- a/scripts/auth/pam-verify.py +++ b/scripts/auth/pam-verify.py @@ -45,13 +45,15 @@ def verify(username: str, password: str) -> dict: if not lib_path: return {"ok": False, "error": "PAM library not found on system"} libpam = ctypes.cdll.LoadLibrary(lib_path) - libc = ctypes.cdll.LoadLibrary("libc.so.6") + libc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("c") or "libc.so.6") libpam.pam_start.restype = ctypes.c_int libpam.pam_authenticate.restype = ctypes.c_int libpam.pam_acct_mgmt.restype = ctypes.c_int libpam.pam_end.restype = ctypes.c_int libpam.pam_strerror.restype = ctypes.c_char_p libpam.pam_strerror.argtypes = [ctypes.c_void_p, ctypes.c_int] + libc.malloc.restype = ctypes.c_void_p + libc.strdup.restype = ctypes.c_void_p password_cpy = [password] @@ -119,8 +121,6 @@ def conv(nmsg, msg, out_resp, appdata): return {"ok": False, "error": f"User '{username}' is not in the '{GRP_NAME}' group"} - return {"ok": True, "username": username} - def main(): try: