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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -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:
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 38 additions & 6 deletions scripts/check-server-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand 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) {
Expand All @@ -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.",
);
}
21 changes: 20 additions & 1 deletion scripts/check-server-bundle.test.mjs
Original file line number Diff line number Diff line change
@@ -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...");`;
Expand Down Expand Up @@ -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`.",
]);
});
});
28 changes: 26 additions & 2 deletions scripts/security-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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 [
Expand All @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions src/auth/security-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
74 changes: 74 additions & 0 deletions src/components/dev-login.tsx
Original file line number Diff line number Diff line change
@@ -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<DevPersonaSummary[]>([]);

useEffect(() => {
const controller = new AbortController();
fetch(DEV_LOGIN_PATH, { signal: controller.signal })
.then((response) => (response.ok ? (response.json() as Promise<DevLoginListing>) : 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 (
<section
aria-labelledby="dev-login-title"
className="space-y-3 rounded-xl border border-dashed border-amber-500/60 bg-amber-500/5 p-4"
>
<div>
<h2 id="dev-login-title" className="flex items-center gap-1.5 text-sm font-semibold">
<FlaskConical className="size-4 text-amber-600 dark:text-amber-400" aria-hidden="true" />
Dev sign-in
</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Local development only. Signs you in as a test person, no account needed.
</p>
</div>
<ul className="space-y-2">
{personas.map((persona) => (
<li key={persona.key}>
<a
href={devLoginHref(persona.key, redirect)}
className={cn(
buttonVariants({ variant: "outline" }),
"h-auto w-full flex-col items-start gap-0.5 bg-card px-3 py-2 text-left whitespace-normal",
)}
>
<span className="text-sm font-medium">{persona.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{persona.description}
</span>
{persona.warning && (
<span className="flex items-start gap-1 text-xs font-normal text-amber-700 dark:text-amber-300">
<TriangleAlert className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
{persona.warning}
</span>
)}
</a>
</li>
))}
</ul>
</section>
);
}
8 changes: 8 additions & 0 deletions src/components/login-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -256,6 +257,13 @@ export function LoginPage({ callbackURL = "/" }: { callbackURL?: string }) {
</p>
</CardContent>
</Card>
{import.meta.env.DEV && (
<DevLoginPanel
redirect={
oauthFlow ? `/api/auth/oauth2/authorize?${searchStr.replace(/^\?/, "")}` : callbackURL

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Resume OIDC through the authenticated continuation.

This redirect replays the original authorization query, including prompt=login or max_age=0. The dev endpoint creates a session outside Better Auth's normal OAuth continuation hook, which normally clears the satisfied reauthentication requirement.

I reproduced both over HTTP with a registered PKCE client: an anonymous authorize request redirects to login; selecting the student persona creates a valid session; the replayed authorize request redirects back to login instead of /consent. A request without either parameter reaches consent.

Please resume through the provider's authenticated continuation and preserve validation of the signed request. Add integration coverage for prompt=login and max_age=0 so a successful persona sign-in reaches consent.

}
/>
)}
</LoginLayout>
);
}
Loading
Loading