fix(auth): use token.sid to avoid Auth.js jti claim conflict
Auth.js v5 manages token.jti internally and overwrites it after the jwt callback. Storing our session UUID in token.sid ensures the value we persist in active_sessions matches what the signed cookie carries. - jwt callback: token.sid = jti (was token.jti) - session callback: read from token.sid - signOut event: falls back to token.jti for backward compat with any sessions created before this change Also adds Playwright dev-system test suite (playwright.dev.config.ts + e2e/dev-system/) that validates login, session registry health, and RBAC enforcement against the running localhost:3100 dev server. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Auth & Session Registry tests — dev system
|
||||
*
|
||||
* Validates that:
|
||||
* - Login works with the dev-DB seed users
|
||||
* - After login, all tRPC calls succeed (no 401 "Session revoked")
|
||||
* - After logout, protected routes redirect to sign-in
|
||||
* - Invalid credentials are rejected
|
||||
*
|
||||
* These tests guard against regressions in the active-session registry
|
||||
* (token.sid / active_sessions table) introduced in the auth hardening work.
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { assertNoTrpc401s, DEV_USERS, signIn, signOut } from "./helpers.js";
|
||||
|
||||
test.describe("Auth — login / logout", () => {
|
||||
test("admin login succeeds and lands on a protected page", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
await expect(page).not.toHaveURL(/\/auth\/signin/);
|
||||
});
|
||||
|
||||
test("manager login succeeds", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.manager.email, DEV_USERS.manager.password);
|
||||
await expect(page).not.toHaveURL(/\/auth\/signin/);
|
||||
});
|
||||
|
||||
test("viewer login succeeds", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.viewer.email, DEV_USERS.viewer.password);
|
||||
await expect(page).not.toHaveURL(/\/auth\/signin/);
|
||||
});
|
||||
|
||||
test("invalid credentials show an error and stay on sign-in", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "nobody@example.com");
|
||||
await page.fill('input[type="password"]', "wrong");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
|
||||
// Error message visible
|
||||
await expect(
|
||||
page.locator("text=/invalid|incorrect|wrong|credentials/i"),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
await signOut(page);
|
||||
await page.goto("/dashboard");
|
||||
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Session registry — no tRPC 401s after login", () => {
|
||||
test("admin login produces a valid session: dashboard loads without 401s", async ({ page }) => {
|
||||
await assertNoTrpc401s(page, async () => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
});
|
||||
|
||||
test("admin navigating to /admin/users fires no 401s and loads user rows", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
|
||||
await assertNoTrpc401s(page, async () => {
|
||||
await page.goto("/admin/users");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
// At least one user row should be visible
|
||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("text=/planarchy\.dev|capakraken\.dev/")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator("text=No users found")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("admin navigating to /admin/system-roles fires no 401s", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
|
||||
await assertNoTrpc401s(page, async () => {
|
||||
await page.goto("/admin/system-roles");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
// Page should render something (not blank)
|
||||
await expect(page.locator("h1,h2").first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("viewer login produces a valid session: no 401s on dashboard", async ({ page }) => {
|
||||
await assertNoTrpc401s(page, async () => {
|
||||
await signIn(page, DEV_USERS.viewer.email, DEV_USERS.viewer.password);
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/** Dev-system credentials — these exist in the planarchy.dev seed data */
|
||||
export const DEV_USERS = {
|
||||
admin: { email: "admin@planarchy.dev", password: "admin123" },
|
||||
manager: { email: "manager@planarchy.dev", password: "manager123" },
|
||||
viewer: { email: "viewer@planarchy.dev", password: "viewer123" },
|
||||
} as const;
|
||||
|
||||
export async function signIn(page: Page, email: string, password: string) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', email);
|
||||
await page.fill('input[type="password"]', password);
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15000 });
|
||||
}
|
||||
|
||||
export async function signOut(page: Page) {
|
||||
await page.goto("/auth/signout");
|
||||
// Wait for redirect back to signin
|
||||
await page.waitForURL(/\/auth\/signin/, { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept all tRPC batch responses and assert none return HTTP 401.
|
||||
* Returns a list of intercepted tRPC paths that were called.
|
||||
*/
|
||||
export async function assertNoTrpc401s(page: Page, action: () => Promise<void>) {
|
||||
const failures: string[] = [];
|
||||
|
||||
page.on("response", (response) => {
|
||||
if (response.url().includes("/api/trpc/") && response.status() === 401) {
|
||||
const url = new URL(response.url());
|
||||
failures.push(url.pathname + url.search.slice(0, 80));
|
||||
}
|
||||
});
|
||||
|
||||
await action();
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`tRPC 401 responses detected (session registry / auth broken):\n${failures.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* RBAC / permissions tests — dev system
|
||||
*
|
||||
* Validates that role-based access control is enforced correctly
|
||||
* against the running dev server with real seed data.
|
||||
*
|
||||
* Role hierarchy:
|
||||
* ADMIN — full access including all /admin/* routes
|
||||
* MANAGER — can view allocations, cannot access /admin/users etc.
|
||||
* VIEWER — limited read-only; allocations are forbidden (TRPC FORBIDDEN)
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { DEV_USERS, signIn } from "./helpers.js";
|
||||
|
||||
test.describe("RBAC — admin routes", () => {
|
||||
test("admin can access /admin/users and sees user rows", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
await page.goto("/admin/users");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||
// Seed users have planarchy.dev or capakraken.dev email domains
|
||||
await expect(
|
||||
page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("admin can access /admin/system-roles without errors", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
await page.goto("/admin/system-roles");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Page should render a heading, not a blank screen or error
|
||||
await expect(page.locator("h1,h2").first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("admin can access /admin/blueprints without errors", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
await page.goto("/admin/blueprints");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.locator("h1,h2,h3").first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("manager is redirected or sees an error on /admin/users", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.manager.email, DEV_USERS.manager.password);
|
||||
await page.goto("/admin/users");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Expect either: redirect away from /admin/users, or a permission-denied message
|
||||
const isRedirected = !page.url().includes("/admin/users");
|
||||
const hasForbiddenText = await page
|
||||
.locator("text=/forbidden|permission|access denied|not authorized|unauthorized/i")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(isRedirected || hasForbiddenText).toBe(true);
|
||||
});
|
||||
|
||||
test("viewer is redirected or sees an error on /admin/users", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.viewer.email, DEV_USERS.viewer.password);
|
||||
await page.goto("/admin/users");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const isRedirected = !page.url().includes("/admin/users");
|
||||
const hasForbiddenText = await page
|
||||
.locator("text=/forbidden|permission|access denied|not authorized|unauthorized/i")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(isRedirected || hasForbiddenText).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("RBAC — allocations access", () => {
|
||||
test("admin can view /allocations without permission errors", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password);
|
||||
await page.goto("/allocations");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should NOT show the FORBIDDEN/permission-denied overlay
|
||||
await expect(
|
||||
page.locator("text=/do not have permission to view allocations/i"),
|
||||
).toHaveCount(0, { timeout: 8000 });
|
||||
});
|
||||
|
||||
test("manager can view /allocations without permission errors", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.manager.email, DEV_USERS.manager.password);
|
||||
await page.goto("/allocations");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(
|
||||
page.locator("text=/do not have permission to view allocations/i"),
|
||||
).toHaveCount(0, { timeout: 8000 });
|
||||
});
|
||||
|
||||
test("viewer sees a permission error on /allocations", async ({ page }) => {
|
||||
await signIn(page, DEV_USERS.viewer.email, DEV_USERS.viewer.password);
|
||||
await page.goto("/allocations");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// AllocationsClient renders a permission-denied message when tRPC returns FORBIDDEN
|
||||
await expect(
|
||||
page.locator("text=/permission|forbidden|not have permission/i"),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("RBAC — dashboard loads for all roles", () => {
|
||||
for (const [role, creds] of Object.entries(DEV_USERS)) {
|
||||
test(`${role} dashboard loads without errors`, async ({ page }) => {
|
||||
await signIn(page, creds.email, creds.password);
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should be on dashboard, not bounced to sign-in
|
||||
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 5000 });
|
||||
// No unhandled error overlay
|
||||
await expect(page.locator("text=/500|Internal Server Error/i")).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Playwright configuration for running E2E tests against the LIVE dev server.
|
||||
*
|
||||
* Unlike the default playwright.config.ts (which spins up a dedicated test
|
||||
* server with isolated test data), this config targets the already-running
|
||||
* dev server at localhost:3100 and exercises real dev-DB data.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @capakraken/web exec playwright test --config playwright.dev.config.ts
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Dev server running: pnpm run dev (or docker compose up)
|
||||
* - Dev DB seeded with planarchy.dev seed users
|
||||
*/
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/dev-system",
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env["CI"],
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3100",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
// No webServer block — the dev server must already be running
|
||||
});
|
||||
@@ -154,8 +154,9 @@ const authConfig = {
|
||||
if (token.role) {
|
||||
(session.user as typeof session.user & { role: string }).role = token.role as string;
|
||||
}
|
||||
if (token.jti) {
|
||||
(session.user as typeof session.user & { jti: string }).jti = token.jti as string;
|
||||
// Use token.sid (not token.jti) to avoid conflict with Auth.js's internal JWT ID claim
|
||||
if (token.sid) {
|
||||
(session.user as typeof session.user & { jti: string }).jti = token.sid as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
@@ -163,9 +164,11 @@ const authConfig = {
|
||||
if (user) {
|
||||
token.role = (user as typeof user & { role: string }).role;
|
||||
|
||||
// Generate a unique JWT ID for session tracking
|
||||
// Generate a unique session ID for tracking.
|
||||
// We use token.sid (not token.jti) because Auth.js manages token.jti
|
||||
// internally and may overwrite it after the jwt callback returns.
|
||||
const jti = crypto.randomUUID();
|
||||
token.jti = jti;
|
||||
token.sid = jti;
|
||||
|
||||
// Enforce concurrent session limit (kick-oldest strategy)
|
||||
try {
|
||||
@@ -208,7 +211,7 @@ const authConfig = {
|
||||
const token = "token" in message ? message.token : null;
|
||||
const userId = token?.sub ?? null;
|
||||
const email = token?.email ?? "unknown";
|
||||
const jti = token?.jti as string | undefined;
|
||||
const jti = (token?.sid ?? token?.jti) as string | undefined;
|
||||
|
||||
// Remove from active session registry
|
||||
if (jti) {
|
||||
|
||||
Reference in New Issue
Block a user