From 07002f5426a6bb0e44d4de5ee3555a9787774bb4 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 1 Oct 2026 16:03:33 +0200 Subject: [PATCH] feat: add a dev-only sign-in with test personas and a local database Local testing needed real provider credentials and a hand-rolled database. `vp run dev:setup` now starts PostgreSQL with Docker Compose, migrates it, and seeds four personas plus 60 fake people. In `vp dev` with DEV_LOGIN=1, the login page offers one-click sign-in as a persona, and GET /api/dev/login?as=&redirect= does the same for scripts and agents. It creates a real Better Auth session, so RBAC and the API guards run unchanged, and it resumes OpenID Connect sign-ins. The dev code is imported only behind import.meta.env.DEV and the build fails if it ever reaches a production bundle. Startup refuses DEV_LOGIN, and the public example secret, unless BETTER_AUTH_URL is localhost. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 4 + .env.local.example | 21 +++ README.md | 12 ++ compose.yaml | 23 +++ package.json | 3 + scripts/check-server-bundle.mjs | 44 +++++- scripts/check-server-bundle.test.mjs | 21 ++- scripts/security-config.mjs | 28 +++- src/auth/security-config.test.ts | 36 +++++ src/components/dev-login.tsx | 74 ++++++++++ src/components/login-page.tsx | 8 ++ src/dev/login.ts | 69 +++++++++ src/dev/personas.ts | 205 +++++++++++++++++++++++++++ src/dev/seed.ts | 61 ++++++++ src/dev/shared.test.ts | 30 ++++ src/dev/shared.ts | 34 +++++ src/routeTree.gen.ts | 21 +++ src/routes/api/dev/login.ts | 15 ++ 18 files changed, 700 insertions(+), 9 deletions(-) create mode 100644 .env.local.example create mode 100644 compose.yaml create mode 100644 src/components/dev-login.tsx create mode 100644 src/dev/login.ts create mode 100644 src/dev/personas.ts create mode 100644 src/dev/seed.ts create mode 100644 src/dev/shared.test.ts create mode 100644 src/dev/shared.ts create mode 100644 src/routes/api/dev/login.ts diff --git a/.env.example b/.env.example index 04c77ef..9dc1277 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,7 @@ STUDENT_VERIFICATION_TTL_DAYS=365 # PN_ENTRA_OIDC_ADMIN_GROUP_ID= # Comma-separated local user IDs that are always Master Admin (break-glass). IDP_ADMIN_USER_IDS= + +# Local development only: see `.env.local.example`. DEV_LOGIN=1 adds the dev sign-in to +# `vp dev`; startup refuses it unless BETTER_AUTH_URL is localhost. +# DEV_LOGIN=1 diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..5d6ffb3 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,21 @@ +# Local development against the Docker Compose database. Copy to `.env.local`, then run +# `vp run dev:setup` once and `vp run dev`. Everything here is for your machine only. +DB_HOST=localhost +DB_PORT=55432 +DB_USER=postgres +DB_PASS=postgres +DB_NAME=polinetwork_auth + +BETTER_AUTH_URL=http://localhost:3000 +# Public on purpose: startup refuses this value anywhere but localhost. +BETTER_AUTH_SECRET=local-development-only-secret-never-deploy-this + +# The dev admin persona holds Master Admin through this allowlist. +IDP_ADMIN_USER_IDS=dev-admin + +# One-click test personas on the login page and at /api/dev/login. Development builds +# only; startup refuses it unless BETTER_AUTH_URL is localhost. +DEV_LOGIN=1 + +# Provider credentials are optional locally: copy them from `.env.example` if you need a +# real sign-in, otherwise the dev personas cover it. diff --git a/README.md b/README.md index 54fa76b..0442d18 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,18 @@ A standalone TanStack Start and Better Auth identity provider. The backend remai Use Node and pnpm through Vite+. +The quickest way in needs only Docker and no provider credentials: + +1. Run `vp install` and copy `.env.local.example` to `.env.local`. +2. Run `vp run dev:setup`. It starts PostgreSQL with `compose.yaml` (on `localhost:55432`), applies the migrations, and seeds four test personas plus 60 fake people. Run `vp run dev:seed` again whenever you want them back; it updates them in place. +3. Run `vp run dev`, open `http://localhost:3000`, and pick a persona under **Dev sign-in** on the login page. + +The personas are Ada Admin (Master Admin, through `IDP_ADMIN_USER_IDS=dev-admin`), Sam Staff (a role with only `idp:users:read` and `idp:roles:read`), Stella Student (a verified Polimi student with Telegram linked) and Nico Newcomer (Google only, nothing else). Socio and Direttivo cannot be personas: they are always checked live against Entra, never trusted from the database. Scripts and agents can skip the page: opening `/api/dev/login?as=staff&redirect=/users` signs in and redirects, `/api/dev/login` alone lists the personas, and with curl `curl -c jar 'localhost:3000/api/dev/login?as=admin'` stores the session cookie. An OpenID Connect sign-in started from an application resumes after picking a persona. + +The dev sign-in exists only in `vp dev`. Production builds do not contain it, and `vp run build` fails if they ever do. It also stays off unless `.env.local` sets `DEV_LOGIN=1`, which startup refuses unless `BETTER_AUTH_URL` is localhost. Startup likewise refuses the public example secret from `.env.local.example` anywhere but localhost. + +To use your own database or real providers instead: + 1. Run `vp install`. 2. Copy `.env.example` to `.env.local`, set a random secret, point `DB_*` at a **new, separate PostgreSQL database**, and configure an explicit admin group or `IDP_ADMIN_USER_IDS` bootstrap allowlist. 3. Set `BETTER_AUTH_URL=http://localhost:3000` for local development. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f91ebf4 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,23 @@ +# Local development database. `vp run dev:setup` starts it, then migrates and seeds it. +# It listens on localhost only; never point anything but local development at it. +name: polinetwork-auth + +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: polinetwork_auth + ports: + - "127.0.0.1:55432:5432" + volumes: + - db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d polinetwork_auth"] + interval: 2s + timeout: 3s + retries: 15 + +volumes: + db: diff --git a/package.json b/package.json index fac29b7..3956486 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ }, "scripts": { "dev": "dotenv -e .env.local -- env NODE_OPTIONS='--import ./instrument.server.mjs' vp dev", + "dev:db": "docker compose up -d --wait", + "dev:setup": "docker compose up -d --wait && drizzle-kit migrate && vp run dev:seed", + "dev:seed": "dotenv -e .env.local -- tsx src/dev/seed.ts", "generate-routes": "tsr generate", "build": "vp build && node scripts/check-server-bundle.mjs && cp instrument.server.mjs .output/server", "preview": "vp preview", diff --git a/scripts/check-server-bundle.mjs b/scripts/check-server-bundle.mjs index 91bc43f..0e9300b 100644 --- a/scripts/check-server-bundle.mjs +++ b/scripts/check-server-bundle.mjs @@ -57,6 +57,24 @@ export function findBundleProblems(sources) { return problems; } +/** + * The dev sign-in (`src/dev/`) is imported only behind `import.meta.env.DEV`, so a + * production build must not contain it. Its shared marker, `DEV_LOGIN_KIND` in + * `src/dev/shared.ts`, is how a leak would show: anything that pulls the dev code in + * pulls the marker in with it. + */ +export const DEV_LOGIN_MARKER = "pn-dev-login"; + +/** @param {{ path: string, code: string }[]} sources */ +export function findDevLoginLeaks(sources) { + return sources + .filter(({ code }) => code.includes(DEV_LOGIN_MARKER)) + .map( + ({ path }) => + `the development-only sign-in is bundled into ${path}. Import \`src/dev/\` only behind \`import.meta.env.DEV\`.`, + ); +} + async function serverChunks(directory) { const entries = await readdir(directory, { withFileTypes: true }); const files = await Promise.all( @@ -69,15 +87,27 @@ async function serverChunks(directory) { return files.flat(); } -if (process.argv[1] === fileURLToPath(import.meta.url)) { - const serverDir = fileURLToPath(new URL("../.output/server", import.meta.url)); - const chunks = await serverChunks(serverDir); - const sources = await Promise.all( +async function readChunks(directory) { + const chunks = await serverChunks(directory); + return Promise.all( chunks.map(async (path) => ({ - path: path.slice(serverDir.length + 1), + path: path.slice(directory.length + 1), code: await readFile(path, "utf8"), })), ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const sources = await readChunks(fileURLToPath(new URL("../.output/server", import.meta.url))); + const browserSources = await readChunks( + fileURLToPath(new URL("../.output/public", import.meta.url)), + ); + + const leaks = findDevLoginLeaks([...sources, ...browserSources]); + if (leaks.length) { + console.error(`Production build check failed:\n- ${leaks.join("\n- ")}`); + process.exit(1); + } const problems = findBundleProblems(sources); if (problems.length) { @@ -89,5 +119,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { process.exit(1); } - console.info("Server bundle check passed: per-request state is not duplicated."); + console.info( + "Server bundle check passed: per-request state is not duplicated and the dev sign-in is absent.", + ); } diff --git a/scripts/check-server-bundle.test.mjs b/scripts/check-server-bundle.test.mjs index 5ab04e8..0d8c435 100644 --- a/scripts/check-server-bundle.test.mjs +++ b/scripts/check-server-bundle.test.mjs @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; -import { findBundleProblems } from "./check-server-bundle.mjs"; +import { DEV_LOGIN_KIND } from "../src/dev/shared.ts"; +import { DEV_LOGIN_MARKER, findBundleProblems, findDevLoginLeaks } from "./check-server-bundle.mjs"; const core = `function defineRequestState(initFn) {} throw new Error("No request state found. Please make sure...");`; @@ -39,4 +40,22 @@ describe("server bundle check", () => { it("fails when it can no longer find what it guards", () => { expect(findBundleProblems([{ path: "_ssr/router.mjs", code: "" }])).toHaveLength(2); }); + + it("looks for the same marker the dev sign-in carries", () => { + expect(DEV_LOGIN_MARKER).toBe(DEV_LOGIN_KIND); + }); + + it("fails when the dev sign-in reaches a production bundle", () => { + const sources = [ + { path: "_ssr/router.mjs", code: core }, + { + path: "assets/index.js", + code: `fetch("/api/dev/login").then(r => r.kind === "${DEV_LOGIN_KIND}")`, + }, + ]; + + expect(findDevLoginLeaks(sources)).toEqual([ + "the development-only sign-in is bundled into assets/index.js. Import `src/dev/` only behind `import.meta.env.DEV`.", + ]); + }); }); diff --git a/scripts/security-config.mjs b/scripts/security-config.mjs index 7db582f..e3c3da8 100644 --- a/scripts/security-config.mjs +++ b/scripts/security-config.mjs @@ -2,6 +2,16 @@ import { z } from "zod"; const optional = (schema) => z.preprocess((value) => (value === "" ? undefined : value), schema.optional()); +const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"]; +// Published in `.env.local.example` so a fresh clone runs as is; worthless as a secret. +const LOCAL_EXAMPLE_SECRET = "local-development-only-secret-never-deploy-this"; +const isLoopback = (value) => { + try { + return LOOPBACK_HOSTS.includes(new URL(value).hostname); + } catch { + return false; + } +}; const schema = z .object({ BETTER_AUTH_URL: z @@ -12,8 +22,7 @@ const schema = z if (url.username || url.password) return false; // Cleartext HTTP would expose sessions and identity claims, so it is only ever // tolerated for local development. - if (url.protocol === "http:") - return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.protocol === "http:") return LOOPBACK_HOSTS.includes(url.hostname); return url.protocol === "https:"; }, "BETTER_AUTH_URL must be an HTTPS URL without credentials, or HTTP on localhost."), BETTER_AUTH_SECRET: z.string().trim().min(32), @@ -38,6 +47,10 @@ const schema = z .default("") .transform((value) => (value.trim() === "" ? [] : value.split(",").map((id) => id.trim()))) .pipe(z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/))), + // Turns on the dev sign-in in `vp dev`. Production builds do not contain it at all; + // this only keeps a stray setting from ever pairing with a public origin. + DEV_LOGIN: optional(z.literal("1", { error: "DEV_LOGIN must be 1 or unset." })), + NODE_ENV: z.string().optional(), }) .superRefine((config, context) => { for (const keys of [ @@ -63,6 +76,17 @@ const schema = z code: "custom", message: "PN Entra requires tenant, client ID and client secret together.", }); + const local = config.NODE_ENV !== "production" && isLoopback(config.BETTER_AUTH_URL); + if (config.BETTER_AUTH_SECRET === LOCAL_EXAMPLE_SECRET && !local) + context.addIssue({ + code: "custom", + message: "BETTER_AUTH_SECRET is the public example from .env.local.example.", + }); + if (config.DEV_LOGIN && !local) + context.addIssue({ + code: "custom", + message: "DEV_LOGIN is only allowed outside production, with BETTER_AUTH_URL on localhost.", + }); if (!config.PN_ENTRA_OIDC_ADMIN_GROUP_ID && config.IDP_ADMIN_USER_IDS.length === 0) context.addIssue({ code: "custom", diff --git a/src/auth/security-config.test.ts b/src/auth/security-config.test.ts index c62be0b..9e8ee5f 100644 --- a/src/auth/security-config.test.ts +++ b/src/auth/security-config.test.ts @@ -62,4 +62,40 @@ describe("security configuration startup validation", () => { ).not.toThrow(); }, ); + it("accepts the dev sign-in on a local development origin", () => { + expect(() => + validateSecurityConfiguration({ + IDP_ADMIN_USER_IDS: "dev-admin", + BETTER_AUTH_URL: "http://localhost:3000", + DEV_LOGIN: "1", + NODE_ENV: "development", + }), + ).not.toThrow(); + }); + it("refuses the public example secret anywhere but local development", () => { + const secret = { BETTER_AUTH_SECRET: "local-development-only-secret-never-deploy-this" }; + expect(() => + validate({ IDP_ADMIN_USER_IDS: "root", BETTER_AUTH_URL: "http://localhost:3000", ...secret }), + ).not.toThrow(); + expect(() => validate({ IDP_ADMIN_USER_IDS: "root", ...secret })).toThrow(); + expect(() => + validate({ + IDP_ADMIN_USER_IDS: "root", + BETTER_AUTH_URL: "http://localhost:3000", + NODE_ENV: "production", + ...secret, + }), + ).toThrow(); + }); + it.each([ + { DEV_LOGIN: "1" }, + { DEV_LOGIN: "1", BETTER_AUTH_URL: "https://auth.polinetwork.org" }, + { DEV_LOGIN: "1", BETTER_AUTH_URL: "https://localhost.attacker.example" }, + { DEV_LOGIN: "1", BETTER_AUTH_URL: "http://localhost:3000", NODE_ENV: "production" }, + { DEV_LOGIN: "true", BETTER_AUTH_URL: "http://localhost:3000" }, + ])("refuses the dev sign-in anywhere but local development: %j", (invalid) => { + expect(() => + validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: "root", ...invalid }), + ).toThrow(); + }); }); diff --git a/src/components/dev-login.tsx b/src/components/dev-login.tsx new file mode 100644 index 0000000..03f7353 --- /dev/null +++ b/src/components/dev-login.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { FlaskConical, TriangleAlert } from "lucide-react"; +import { cn } from "cn"; +import { buttonVariants } from "@/components/ui/button"; +import { + DEV_LOGIN_KIND, + DEV_LOGIN_PATH, + devLoginHref, + type DevLoginListing, + type DevPersonaSummary, +} from "@/dev/shared"; + +/** + * One-click sign-in as a test persona. Rendered only behind `import.meta.env.DEV`, and only + * once the server confirms `DEV_LOGIN=1`, so it never appears in production. + */ +export function DevLoginPanel({ redirect }: { redirect: string }) { + const [personas, setPersonas] = useState([]); + + useEffect(() => { + const controller = new AbortController(); + fetch(DEV_LOGIN_PATH, { signal: controller.signal }) + .then((response) => (response.ok ? (response.json() as Promise) : null)) + .then((listing) => { + if (listing?.kind === DEV_LOGIN_KIND) setPersonas(listing.personas); + }) + .catch(() => { + /* Dev sign-in is off: show nothing. */ + }); + return () => controller.abort(); + }, []); + + if (!personas.length) return null; + return ( +
+
+

+

+

+ Local development only. Signs you in as a test person, no account needed. +

+
+ +
+ ); +} diff --git a/src/components/login-page.tsx b/src/components/login-page.tsx index c7917bb..bdeb62f 100644 --- a/src/components/login-page.tsx +++ b/src/components/login-page.tsx @@ -3,6 +3,7 @@ import { useLocation } from "@tanstack/react-router"; import { cn } from "cn"; import { Building2, Fingerprint, Link2, LoaderCircle } from "lucide-react"; import { authClient } from "@/auth/client"; +import { DevLoginPanel } from "@/components/dev-login"; import { GoogleIcon } from "@/components/google-icon"; import { AppLogo } from "@/components/oidc/app-logo"; import { ThemeSwitch } from "@/components/theme-switch"; @@ -256,6 +257,13 @@ export function LoginPage({ callbackURL = "/" }: { callbackURL?: string }) {

+ {import.meta.env.DEV && ( + + )} ); } diff --git a/src/dev/login.ts b/src/dev/login.ts new file mode 100644 index 0000000..60a8175 --- /dev/null +++ b/src/dev/login.ts @@ -0,0 +1,69 @@ +/** + * Signs the browser in as a fixed test persona, for local development. Reached only through + * `src/routes/api/dev/login.ts`, which imports this module behind `import.meta.env.DEV` + * so production builds never contain it. On top of that it only answers when `.env.local` + * sets `DEV_LOGIN=1`, which `scripts/security-config.mjs` refuses outside localhost. + */ +import { makeSignature } from "better-auth/crypto"; +import { auth } from "../auth"; +import { findPersona, listPersonas, saveDevUser } from "./personas"; +import { DEV_LOGIN_KIND, localRedirect, type DevLoginListing } from "./shared"; + +const noStore = { "Cache-Control": "no-store" }; + +function serializeCookie( + name: string, + value: string, + attributes: { path?: string; secure?: boolean; sameSite?: string; maxAge?: number }, +) { + const sameSite = attributes.sameSite ?? "lax"; + return [ + `${name}=${encodeURIComponent(value)}`, + `Path=${attributes.path ?? "/"}`, + "HttpOnly", + `SameSite=${sameSite[0]!.toUpperCase()}${sameSite.slice(1).toLowerCase()}`, + ...(attributes.secure ? ["Secure"] : []), + ...(attributes.maxAge ? [`Max-Age=${attributes.maxAge}`] : []), + ].join("; "); +} + +/** + * `GET ?as=&redirect=` signs in and redirects; without `as` it lists the + * personas. A real session is created through Better Auth, so everything downstream (the + * session hook, RBAC, API guards) behaves exactly as after a normal sign-in. + */ +export async function handleDevLogin(request: Request): Promise { + if (process.env.DEV_LOGIN !== "1") + return new Response("Not found", { status: 404, headers: noStore }); + + const url = new URL(request.url); + const key = url.searchParams.get("as"); + if (!key) { + const listing: DevLoginListing = { kind: DEV_LOGIN_KIND, personas: listPersonas() }; + return Response.json(listing, { headers: noStore }); + } + + const persona = findPersona(key); + if (!persona) + return Response.json( + { error: `Unknown persona "${key}".`, personas: listPersonas().map((entry) => entry.key) }, + { status: 400, headers: noStore }, + ); + + await saveDevUser(persona); + const context = await auth.$context; + const session = await context.internalAdapter.createSession(persona.id); + const cookie = context.authCookies.sessionToken; + const signed = `${session.token}.${await makeSignature(session.token, context.secret)}`; + return new Response(null, { + status: 303, + headers: { + ...noStore, + Location: localRedirect(url.searchParams.get("redirect")), + "Set-Cookie": serializeCookie(cookie.name, signed, { + ...cookie.attributes, + maxAge: context.sessionConfig.expiresIn, + }), + }, + }); +} diff --git a/src/dev/personas.ts b/src/dev/personas.ts new file mode 100644 index 0000000..5ea7f7a --- /dev/null +++ b/src/dev/personas.ts @@ -0,0 +1,205 @@ +/** + * Test people for local development, written straight into the database. Only the dev + * sign-in endpoint and `src/dev/seed.ts` import this module, and production builds include + * neither, so nothing here can run against production. + */ +import { inArray, eq } from "drizzle-orm"; +import { db } from "../db"; +import { + account, + identityEvidence, + permission, + role, + rolePermission, + user, + userRole, +} from "../db/schema"; +import { env } from "../env"; +import type { DevPersonaSummary } from "./shared"; + +type Provider = "google" | "pn-entra" | "telegram" | "polimi-email"; + +export type DevAccount = { + providerId: Provider; + /** The provider's subject: an opaque ID, a Telegram user ID, or the Polimi address. */ + accountId: string; + /** Identity evidence states, such as `student`. Only Polimi evidence is trusted from the + * database; Socio and Direttivo are always rechecked against Entra, so here they are + * display data only. */ + states?: string[]; + /** The address shown for a PoliNetwork account. */ + email?: string; +}; + +export type DevRole = { key: string; name: string; description: string; permissions: string[] }; + +export type DevUser = { + id: string; + name: string; + email: string; + createdAt?: Date; + accounts: DevAccount[]; + roles?: DevRole[]; +}; + +type DevPersona = DevUser & { key: string; description: string }; + +const ISSUERS: Record = { + google: "https://accounts.google.com", + // Without a configured tenant no Entra evidence is trusted anyway, so any well-formed + // issuer will do for display. + "pn-entra": `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID ?? "00000000-0000-0000-0000-000000000000"}/v2.0`, + telegram: "https://oauth.telegram.org", + "polimi-email": "https://mail.polimi.it", +}; + +/** The persona that holds Master Admin, when `.env.local` lists it in `IDP_ADMIN_USER_IDS`. */ +export const DEV_ADMIN_ID = "dev-admin"; + +export const DEV_PERSONAS: DevPersona[] = [ + { + key: "admin", + id: DEV_ADMIN_ID, + name: "Ada Admin", + email: "admin@polinetwork.test", + description: "Master Admin: every permission", + accounts: [{ providerId: "google", accountId: "dev-admin" }], + }, + { + key: "staff", + id: "dev-staff", + name: "Sam Staff", + email: "staff@polinetwork.test", + description: "Can read the user directory and roles, nothing else", + accounts: [{ providerId: "google", accountId: "dev-staff" }], + roles: [ + { + key: "dev-staff", + name: "Dev staff", + description: "Created by the dev sign-in for the staff persona.", + permissions: ["idp:users:read", "idp:roles:read"], + }, + ], + }, + { + key: "student", + id: "dev-student", + name: "Stella Student", + email: "student@polinetwork.test", + description: "Verified Polimi student with Telegram linked, no admin access", + accounts: [ + { providerId: "google", accountId: "dev-student" }, + { + providerId: "polimi-email", + accountId: "stella.student@mail.polimi.it", + states: ["student"], + }, + { providerId: "telegram", accountId: "100000001" }, + ], + }, + { + key: "member", + id: "dev-member", + name: "Nico Newcomer", + email: "member@polinetwork.test", + description: "Just signed up with Google: no statuses, no roles", + accounts: [{ providerId: "google", accountId: "dev-member" }], + }, +]; + +export function findPersona(key: string) { + return DEV_PERSONAS.find((persona) => persona.key === key); +} + +export function listPersonas(): DevPersonaSummary[] { + return DEV_PERSONAS.map(({ key, name, description, id }) => ({ + key, + name, + description, + ...(id === DEV_ADMIN_ID && !env.IDP_ADMIN_USER_IDS.includes(DEV_ADMIN_ID) + ? { + warning: `Add ${DEV_ADMIN_ID} to IDP_ADMIN_USER_IDS in .env.local to make this Master Admin.`, + } + : {}), + })); +} + +type Writer = Parameters[0]>[0]; + +async function saveRole(transaction: Writer, userId: string, entry: DevRole) { + await transaction + .insert(role) + .values({ id: entry.key, key: entry.key, name: entry.name, description: entry.description }) + .onConflictDoNothing({ target: role.key }); + const [saved] = await transaction + .select({ id: role.id }) + .from(role) + .where(eq(role.key, entry.key)); + const permissions = await transaction + .select({ id: permission.id }) + .from(permission) + .where(inArray(permission.key, entry.permissions)); + // Only adds what is missing, so edits made through the UI while testing survive. + if (permissions.length) + await transaction + .insert(rolePermission) + .values(permissions.map(({ id }) => ({ roleId: saved!.id, permissionId: id }))) + .onConflictDoNothing(); + await transaction + .insert(userRole) + .values({ userId, roleId: saved!.id, assignedBy: "dev-login" }) + .onConflictDoNothing(); +} + +/** Creates or refreshes a test person, their linked accounts, evidence, and roles. */ +export async function saveDevUser(entry: DevUser) { + const now = new Date(); + const validUntil = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1_000); + await db.transaction(async (transaction) => { + await transaction + .insert(user) + .values({ + id: entry.id, + name: entry.name, + email: entry.email, + emailVerified: false, + ...(entry.createdAt ? { createdAt: entry.createdAt, updatedAt: entry.createdAt } : {}), + }) + .onConflictDoUpdate({ target: user.id, set: { name: entry.name, email: entry.email } }); + + for (const linked of entry.accounts) { + const issuer = ISSUERS[linked.providerId]; + await transaction + .insert(account) + .values({ + id: `${entry.id}:${linked.providerId}`, + accountId: linked.accountId, + providerId: linked.providerId, + issuer, + userId: entry.id, + updatedAt: now, + }) + .onConflictDoNothing(); + if (linked.providerId === "google") continue; + // Evidence is refreshed on every save so it never expires mid-session. + const proof = { + issuer, + subject: linked.accountId, + providerId: linked.providerId, + states: linked.states ?? [], + validUntil, + telegramId: linked.providerId === "telegram" ? linked.accountId : null, + email: linked.email ?? null, + }; + await transaction + .insert(identityEvidence) + .values(proof) + .onConflictDoUpdate({ + target: [identityEvidence.issuer, identityEvidence.subject], + set: proof, + }); + } + + for (const entryRole of entry.roles ?? []) await saveRole(transaction, entry.id, entryRole); + }); +} diff --git a/src/dev/seed.ts b/src/dev/seed.ts new file mode 100644 index 0000000..b98dc04 --- /dev/null +++ b/src/dev/seed.ts @@ -0,0 +1,61 @@ +/** + * Fills a local development database with the dev personas and a few dozen fake people, + * so the user directory has something to show. `vp run dev:seed`; safe to run again, since + * every fake person has a fixed ID and is updated in place. + */ +import { faker } from "@faker-js/faker"; +import { env } from "../env"; +import { DEV_PERSONAS, saveDevUser, type DevAccount } from "./personas"; + +const FAKE_PEOPLE = 60; + +const local = ["localhost", "127.0.0.1", "[::1]"]; +if (!local.includes(new URL(env.BETTER_AUTH_URL).hostname)) { + console.error("Refusing to seed: BETTER_AUTH_URL is not a local development origin."); + process.exit(1); +} + +// A fixed seed keeps the same people across runs, so links to them keep working. +faker.seed(20_260_930); + +for (const persona of DEV_PERSONAS) await saveDevUser(persona); + +for (let index = 1; index <= FAKE_PEOPLE; index++) { + const id = `dev-seed-${String(index).padStart(3, "0")}`; + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const dotted = `${firstName}.${lastName}`.toLowerCase().replace(/[^a-z.]/g, ""); + const accounts: DevAccount[] = []; + const polinetwork = faker.datatype.boolean(0.4); + if (polinetwork) + accounts.push({ + providerId: "pn-entra", + accountId: faker.string.uuid(), + email: `${dotted}@polinetwork.org`, + states: faker.datatype.boolean(0.6) ? ["socio"] : [], + }); + if (!polinetwork || faker.datatype.boolean(0.3)) + accounts.push({ providerId: "google", accountId: faker.string.numeric(21) }); + if (faker.datatype.boolean(0.5)) + accounts.push({ + providerId: "polimi-email", + accountId: `${dotted}@mail.polimi.it`, + states: ["student"], + }); + if (faker.datatype.boolean(0.6)) + accounts.push({ + providerId: "telegram", + accountId: faker.string.numeric({ length: 9, allowLeadingZeros: false }), + }); + + await saveDevUser({ + id, + name: `${firstName} ${lastName}`, + email: `${dotted}.${index}@example.com`, + createdAt: faker.date.past({ years: 2 }), + accounts, + }); +} + +console.info(`Seeded ${DEV_PERSONAS.length} dev personas and ${FAKE_PEOPLE} fake people.`); +process.exit(0); diff --git a/src/dev/shared.test.ts b/src/dev/shared.test.ts new file mode 100644 index 0000000..29a8d3c --- /dev/null +++ b/src/dev/shared.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; +import { devLoginHref, localRedirect } from "./shared"; + +describe("dev sign-in redirect", () => { + it.each(["/", "/users", "/users?socio=yes#top", "/api/auth/oauth2/authorize?client_id=x"])( + "keeps the local path %s", + (path) => { + expect(localRedirect(path)).toBe(path); + }, + ); + + it.each([ + null, + "", + "users", + "https://evil.example/", + "//evil.example/", + "/\\evil.example/", + "javascript:alert(1)", + ])("falls back to the home page for %j", (value) => { + expect(localRedirect(value)).toBe("/"); + }); + + it("builds a link that survives the round trip", () => { + const url = new URL(devLoginHref("staff", "/users?socio=yes&page=2"), "http://localhost"); + expect(url.pathname).toBe("/api/dev/login"); + expect(url.searchParams.get("as")).toBe("staff"); + expect(localRedirect(url.searchParams.get("redirect"))).toBe("/users?socio=yes&page=2"); + }); +}); diff --git a/src/dev/shared.ts b/src/dev/shared.ts new file mode 100644 index 0000000..e678e50 --- /dev/null +++ b/src/dev/shared.ts @@ -0,0 +1,34 @@ +/** + * Shared by the dev sign-in endpoint and the login page panel. Development builds only: + * `scripts/check-server-bundle.mjs` fails a production build that still contains + * `DEV_LOGIN_KIND`, which is how a leak of this code into production would show up. + */ +export const DEV_LOGIN_KIND = "pn-dev-login"; + +export const DEV_LOGIN_PATH = "/api/dev/login"; + +export type DevPersonaSummary = { + key: string; + name: string; + description: string; + /** Set when the persona is missing something it needs from `.env.local`. */ + warning?: string; +}; + +export type DevLoginListing = { kind: typeof DEV_LOGIN_KIND; personas: DevPersonaSummary[] }; + +/** + * Keeps the post-sign-in redirect on this origin. Anything that is not a plain local path, + * including protocol-relative and backslash tricks, falls back to the home page. + */ +export function localRedirect(value: string | null | undefined) { + if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) + return "/"; + const base = "http://dev.invalid"; + const url = new URL(value, base); + return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : "/"; +} + +export function devLoginHref(key: string, redirect: string) { + return `${DEV_LOGIN_PATH}?${new URLSearchParams({ as: key, redirect })}`; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4f9d434..3127e33 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -31,6 +31,7 @@ import { Route as AccessRolesRoleIdRouteImport } from './routes/access/roles/$ro import { Route as AccessRolesNewRouteImport } from './routes/access/roles/new' import { Route as ApiAccountsUnlinkRouteImport } from './routes/api/accounts/unlink' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +import { Route as ApiDevLoginRouteImport } from './routes/api/dev/login' import { Route as ApiIdpAccessRouteImport } from './routes/api/idp/access' import { Route as ApiOidcClientUpdateRouteImport } from './routes/api/oidc/client-update' import { Route as ApiOidcClientsRouteImport } from './routes/api/oidc/clients' @@ -153,6 +154,11 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) +const ApiDevLoginRoute = ApiDevLoginRouteImport.update({ + id: '/api/dev/login', + path: '/api/dev/login', + getParentRoute: () => rootRouteImport, +} as any) const ApiIdpAccessRoute = ApiIdpAccessRouteImport.update({ id: '/api/idp/access', path: '/api/idp/access', @@ -225,6 +231,7 @@ export interface FileRoutesByFullPath { '/access/roles/new': typeof AccessRolesNewRoute '/api/accounts/unlink': typeof ApiAccountsUnlinkRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/dev/login': typeof ApiDevLoginRoute '/api/idp/access': typeof ApiIdpAccessRoute '/api/oidc/client-update': typeof ApiOidcClientUpdateRoute '/api/oidc/clients': typeof ApiOidcClientsRoute @@ -256,6 +263,7 @@ export interface FileRoutesByTo { '/access/roles/new': typeof AccessRolesNewRoute '/api/accounts/unlink': typeof ApiAccountsUnlinkRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/dev/login': typeof ApiDevLoginRoute '/api/idp/access': typeof ApiIdpAccessRoute '/api/oidc/client-update': typeof ApiOidcClientUpdateRoute '/api/oidc/clients': typeof ApiOidcClientsRoute @@ -291,6 +299,7 @@ export interface FileRoutesById { '/access/roles/new': typeof AccessRolesNewRoute '/api/accounts/unlink': typeof ApiAccountsUnlinkRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/dev/login': typeof ApiDevLoginRoute '/api/idp/access': typeof ApiIdpAccessRoute '/api/oidc/client-update': typeof ApiOidcClientUpdateRoute '/api/oidc/clients': typeof ApiOidcClientsRoute @@ -327,6 +336,7 @@ export interface FileRouteTypes { | '/access/roles/new' | '/api/accounts/unlink' | '/api/auth/$' + | '/api/dev/login' | '/api/idp/access' | '/api/oidc/client-update' | '/api/oidc/clients' @@ -358,6 +368,7 @@ export interface FileRouteTypes { | '/access/roles/new' | '/api/accounts/unlink' | '/api/auth/$' + | '/api/dev/login' | '/api/idp/access' | '/api/oidc/client-update' | '/api/oidc/clients' @@ -392,6 +403,7 @@ export interface FileRouteTypes { | '/access/roles/new' | '/api/accounts/unlink' | '/api/auth/$' + | '/api/dev/login' | '/api/idp/access' | '/api/oidc/client-update' | '/api/oidc/clients' @@ -417,6 +429,7 @@ export interface RootRouteChildren { ApiStudentVerificationRoute: typeof ApiStudentVerificationRoute ApiAccountsUnlinkRoute: typeof ApiAccountsUnlinkRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute + ApiDevLoginRoute: typeof ApiDevLoginRoute ApiIdpAccessRoute: typeof ApiIdpAccessRoute ApiOidcClientUpdateRoute: typeof ApiOidcClientUpdateRoute ApiOidcClientsRoute: typeof ApiOidcClientsRoute @@ -585,6 +598,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/dev/login': { + id: '/api/dev/login' + path: '/api/dev/login' + fullPath: '/api/dev/login' + preLoaderRoute: typeof ApiDevLoginRouteImport + parentRoute: typeof rootRouteImport + } '/api/idp/access': { id: '/api/idp/access' path: '/api/idp/access' @@ -722,6 +742,7 @@ const rootRouteChildren: RootRouteChildren = { ApiStudentVerificationRoute: ApiStudentVerificationRoute, ApiAccountsUnlinkRoute: ApiAccountsUnlinkRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, + ApiDevLoginRoute: ApiDevLoginRoute, ApiIdpAccessRoute: ApiIdpAccessRoute, ApiOidcClientUpdateRoute: ApiOidcClientUpdateRoute, ApiOidcClientsRoute: ApiOidcClientsRoute, diff --git a/src/routes/api/dev/login.ts b/src/routes/api/dev/login.ts new file mode 100644 index 0000000..798472c --- /dev/null +++ b/src/routes/api/dev/login.ts @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/api/dev/login")({ + server: { + handlers: { + GET: async ({ request }) => { + // `import.meta.env.DEV` is false in `vp build`, so the import below is dead code + // there and the dev sign-in never reaches a production bundle. + if (!import.meta.env.DEV) return new Response("Not found", { status: 404 }); + const { handleDevLogin } = await import("@/dev/login"); + return handleDevLogin(request); + }, + }, + }, +});