PWA: Service Worker ohne Session erreichbar, nie HTTP-gecacht; authentifizierter Smoke
- proxy: /sw.js vom Session-Gate ausgenommen (Update-Prüfung auch bei abgelaufener Sitzung; enthält keine Mandantendaten) – gemeldet von L7 - next.config: /sw.js mit Cache-Control no-cache/no-store, Service-Worker-Allowed / - scripts/smoke-auth.ts: Session-Cookie über finalizeIdentityLogin + next-auth/jwt encode (ohne Passworteingabe), prüft Backoffice- und Monteur-Seiten Nachweis: /sw.js anonym 200 + no-cache; Smoke 19/19 Seiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,15 @@ const nextConfig: NextConfig = {
|
|||||||
// Later entries override same-named headers of earlier matches (Next.js header semantics).
|
// Later entries override same-named headers of earlier matches (Next.js header semantics).
|
||||||
return [
|
return [
|
||||||
{ source: "/:path*", headers: securityHeaders },
|
{ source: "/:path*", headers: securityHeaders },
|
||||||
|
{
|
||||||
|
// Browsers must revalidate the service worker on every navigation to pick up updates.
|
||||||
|
source: "/sw.js",
|
||||||
|
headers: [
|
||||||
|
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
|
||||||
|
{ key: "Service-Worker-Allowed", value: "/" },
|
||||||
|
{ key: "Content-Type", value: "application/javascript; charset=utf-8" },
|
||||||
|
],
|
||||||
|
},
|
||||||
...EMBEDDABLE_FILE_ROUTES.map((source) => ({
|
...EMBEDDABLE_FILE_ROUTES.map((source) => ({
|
||||||
source,
|
source,
|
||||||
headers: [
|
headers: [
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Authenticated HTTP smoke against a running dev server — WITHOUT typing passwords:
|
||||||
|
* builds the session user via finalizeIdentityLogin (same code path as a real login after
|
||||||
|
* password/MFA checks) and encodes the Auth.js session cookie with AUTH_SECRET.
|
||||||
|
*
|
||||||
|
* Usage (from repo root, dev server running):
|
||||||
|
* BASE=http://localhost:3099 npx tsx scripts/smoke-auth.ts
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { encode } from "next-auth/jwt";
|
||||||
|
import { prisma } from "../src/server/db";
|
||||||
|
import { finalizeIdentityLogin } from "../src/server/auth";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE ?? "http://localhost:3099";
|
||||||
|
const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token";
|
||||||
|
|
||||||
|
type Check = { path: string; expect?: number[]; mustContain?: string };
|
||||||
|
|
||||||
|
const PLAN: Record<string, Check[]> = {
|
||||||
|
"backoffice@demo.example": [
|
||||||
|
{ path: "/dashboard", expect: [200] },
|
||||||
|
{ path: "/work-orders", expect: [200] },
|
||||||
|
{ path: "/imports", expect: [200] },
|
||||||
|
{ path: "/customers", expect: [200] },
|
||||||
|
{ path: "/sites", expect: [200] },
|
||||||
|
{ path: "/teams", expect: [200] },
|
||||||
|
{ path: "/reports", expect: [200] },
|
||||||
|
{ path: "/documents", expect: [200] },
|
||||||
|
{ path: "/notifications", expect: [200] },
|
||||||
|
{ path: "/search?q=a", expect: [200] },
|
||||||
|
{ path: "/work-orders/emergency-review", expect: [200] },
|
||||||
|
{ path: "/settings/order-types", expect: [200] },
|
||||||
|
{ path: "/settings/audit", expect: [200, 403, 307] },
|
||||||
|
],
|
||||||
|
"monteur@demo.example": [
|
||||||
|
{ path: "/", expect: [307, 308] },
|
||||||
|
{ path: "/m", expect: [200] },
|
||||||
|
{ path: "/m/orders", expect: [200] },
|
||||||
|
{ path: "/m/emergency", expect: [200] },
|
||||||
|
{ path: "/m/sync", expect: [200] },
|
||||||
|
{ path: "/m/profile", expect: [200] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cookieFor(email: string): Promise<string> {
|
||||||
|
const identity = await prisma.identity.findUnique({ where: { email } });
|
||||||
|
if (!identity) throw new Error(`identity ${email} not found (seed?)`);
|
||||||
|
const user = await finalizeIdentityLogin(identity.id, "demo");
|
||||||
|
if (!user) throw new Error(`no active membership for ${email}`);
|
||||||
|
const token = {
|
||||||
|
sub: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
userId: user.id,
|
||||||
|
identityId: user.identityId,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
tenantSlug: user.tenantSlug,
|
||||||
|
activeMembershipId: user.activeMembershipId,
|
||||||
|
memberships: user.memberships,
|
||||||
|
roles: user.roles,
|
||||||
|
permissions: user.permissions,
|
||||||
|
isPlatformAdmin: user.isPlatformAdmin,
|
||||||
|
mfaEnrolled: user.mfaEnrolled,
|
||||||
|
};
|
||||||
|
const value = await encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 60 * 30 });
|
||||||
|
return `${COOKIE}=${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let failures = 0;
|
||||||
|
for (const [email, checks] of Object.entries(PLAN)) {
|
||||||
|
const cookie = await cookieFor(email);
|
||||||
|
console.log(`\n== ${email}`);
|
||||||
|
for (const c of checks) {
|
||||||
|
const res = await fetch(BASE + c.path, { headers: { cookie }, redirect: "manual" });
|
||||||
|
const body = res.status === 200 ? await res.text() : "";
|
||||||
|
const errorPage = /Application error|Internal Server Error|NEXT_REDIRECT|Unhandled Runtime Error/i.test(body);
|
||||||
|
const okStatus = !c.expect || c.expect.includes(res.status);
|
||||||
|
const okText = !c.mustContain || body.includes(c.mustContain);
|
||||||
|
const ok = okStatus && okText && !errorPage;
|
||||||
|
if (!ok) failures++;
|
||||||
|
const loc = res.headers.get("location");
|
||||||
|
console.log(`${ok ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.path}${loc ? ` → ${loc}` : ""}${errorPage ? " [error page]" : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log(failures ? `\n${failures} Fehler` : "\nOK");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+3
-1
@@ -61,12 +61,14 @@ export function proxy(request: NextRequest) {
|
|||||||
export const config = {
|
export const config = {
|
||||||
// Everything except static assets.
|
// Everything except static assets.
|
||||||
//
|
//
|
||||||
|
// `sw.js` (PWA Service Worker): muss auch mit abgelaufener Sitzung für die Update-Prüfung
|
||||||
|
// erreichbar sein; enthält keine Mandantendaten.
|
||||||
// Ergänzt um `webmanifest`: das PWA-Manifest muss auch ohne Session ausgeliefert
|
// Ergänzt um `webmanifest`: das PWA-Manifest muss auch ohne Session ausgeliefert
|
||||||
// werden, sonst bekommt der Browser für /site.webmanifest die Login-Seite statt
|
// werden, sonst bekommt der Browser für /site.webmanifest die Login-Seite statt
|
||||||
// JSON. Die Logo-/Favicon-Dateien sind über die Endungen bereits abgedeckt.
|
// JSON. Die Logo-/Favicon-Dateien sind über die Endungen bereits abgedeckt.
|
||||||
// Bewusst KEINE Verzeichnis-Ausnahme für `assets/` — das ist zugleich die
|
// Bewusst KEINE Verzeichnis-Ausnahme für `assets/` — das ist zugleich die
|
||||||
// App-Route des Asset-Inventars und muss hinter dem Gate bleiben.
|
// App-Route des Asset-Inventars und muss hinter dem Gate bleiben.
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!_next/static|_next/image|favicon.ico|site.webmanifest|.*\\.(?:svg|png|jpg|ico|webmanifest)).*)",
|
"/((?!_next/static|_next/image|favicon.ico|site.webmanifest|sw\\.js|.*\\.(?:svg|png|jpg|ico|webmanifest)).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user