diff --git a/web/portal/app/ModuleGrid.render.test.tsx b/web/portal/app/ModuleGrid.render.test.tsx index 26cb5f51..007c9a1b 100644 --- a/web/portal/app/ModuleGrid.render.test.tsx +++ b/web/portal/app/ModuleGrid.render.test.tsx @@ -13,6 +13,7 @@ vi.mock("../lib/http", () => ({ identity: () => ({ userId: "u-1", tenant: "acme", role: "user" }), fetch: (...a: unknown[]) => fetchMock(...a), }), + getWorkflowSession: () => ({ token: () => Promise.reject(new Error("kein workflow-token")) }), })); import { ModuleGrid } from "./ModuleGrid"; diff --git a/web/portal/app/ModuleGrid.tsx b/web/portal/app/ModuleGrid.tsx index 749b40e8..e323e266 100644 --- a/web/portal/app/ModuleGrid.tsx +++ b/web/portal/app/ModuleGrid.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useI18n, resolveModuleHref } from "@nexarch/shl"; import type { ModuleEntry } from "../lib/modules"; import type { Worklist } from "../lib/worklist"; -import { getSession } from "../lib/http"; +import { getSession, getWorkflowSession } from "../lib/http"; import { ModuleTile, StatTile } from "./Tile"; import { Skeleton } from "./Skeleton"; import { CommandPalette } from "./CommandPalette"; @@ -23,9 +23,9 @@ export async function fetchVisibleModules(fetchImpl: typeof fetch): Promise { +export async function fetchWorklist(fetchImpl: typeof fetch, workflowToken?: string): Promise { try { - const res = await fetchImpl("/api/worklist"); + const res = await fetchImpl("/api/worklist", workflowToken ? { headers: { "X-Workflow-Token": workflowToken } } : undefined); return res.ok ? ((await res.json()) as Worklist) : null; } catch { return null; @@ -87,7 +87,8 @@ export function ModuleGrid() { return; } const call = (input: RequestInfo | URL, init?: RequestInit) => session.fetch(input, init); - const [modules, worklist] = await Promise.all([fetchVisibleModules(call), fetchWorklist(call)]); + const workflowToken = await getWorkflowSession().token().catch(() => undefined); + const [modules, worklist] = await Promise.all([fetchVisibleModules(call), fetchWorklist(call, workflowToken)]); if (!cancelled) setState({ status: "ready", modules, worklist, who: { userId: identity.userId, tenant: identity.tenant } }); } catch { if (!cancelled) setState({ status: "error" }); diff --git a/web/portal/app/api/worklist/route.test.ts b/web/portal/app/api/worklist/route.test.ts index bb7db597..adefcbf5 100644 --- a/web/portal/app/api/worklist/route.test.ts +++ b/web/portal/app/api/worklist/route.test.ts @@ -15,10 +15,12 @@ function mint(claims: Record): string { } describe("GET /api/worklist", () => { - let wfCalls: { url: string; user: string }[]; + let wfCalls: { url: string; user: string; auth?: string }[]; - const req = (token?: string, query = "") => - new NextRequest(`http://portal.example/api/worklist${query}`, { headers: token ? { authorization: `Bearer ${token}` } : {} }); + const req = (token?: string, query = "", wfToken?: string) => + new NextRequest(`http://portal.example/api/worklist${query}`, { + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(wfToken ? { "x-workflow-token": wfToken } : {}) }, + }); beforeEach(async () => { process.env.PORTAL_WORKFLOW_API_URL = "http://wf.example"; @@ -31,7 +33,8 @@ describe("GET /api/worklist", () => { if (u.endsWith("/auth/module-jwks")) { return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 }); } - wfCalls.push({ url: u, user: (init?.headers as Record)["X-User-Id"] }); + const h = init?.headers as Record; + wfCalls.push({ url: u, user: h["X-User-Id"], auth: h.Authorization } as never); return new Response(JSON.stringify([]), { status: 200 }); }) as typeof fetch; }); @@ -57,6 +60,23 @@ describe("GET /api/worklist", () => { expect(wfCalls.every((c) => c.user === "u1")).toBe(true); }); + it("reicht ein gueltiges Workflow-Token als Bearer weiter und sendet keine X-User-Id", async () => { + const { GET } = await import("./route"); + const wf = mint({ sub: "u1", tenant: "acme", aud: "workflow" }); + const res = await GET(req(mint({ sub: "u1", tenant: "acme", aud: "portal" }), "", wf)); + expect(res.status).toBe(200); + expect(wfCalls.length).toBe(3); + expect(wfCalls.every((c) => c.auth === `Bearer ${wf}` && c.user === undefined)).toBe(true); + }); + + it("lehnt Workflow-Token eines anderen Benutzers oder falschen Zielmoduls ab", async () => { + const { GET } = await import("./route"); + const portal = mint({ sub: "u1", tenant: "acme", aud: "portal" }); + expect((await GET(req(portal, "", mint({ sub: "u2", tenant: "acme", aud: "workflow" })))).status).toBe(401); + expect((await GET(req(portal, "", mint({ sub: "u1", tenant: "acme", aud: "portal" })))).status).toBe(401); + expect(wfCalls.length).toBe(0); + }); + it("cached je Mandant und Benutzer", async () => { const { GET } = await import("./route"); await GET(req(mint({ sub: "u1", tenant: "acme" }))); diff --git a/web/portal/app/api/worklist/route.ts b/web/portal/app/api/worklist/route.ts index 97920865..b4adbdfb 100644 --- a/web/portal/app/api/worklist/route.ts +++ b/web/portal/app/api/worklist/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { authenticate, coreConfigured } from "../../../lib/auth"; +import { authenticate, coreConfigured, verifyWorkflowToken } from "../../../lib/auth"; import { worklistCache } from "../../../lib/caches"; import { loadWorklist } from "../../../lib/worklist"; @@ -7,7 +7,8 @@ export const dynamic = "force-dynamic"; const CACHE_TTL_MS = Number(process.env.PORTAL_WORKLIST_CACHE_SECONDS ?? "15") * 1000; // Nur Zaehler, keine Inhalte. Benutzer und Mandant stammen aus dem verifizierten Token; -// die Workflow-API kennt bisher nur X-User-Id (Uebergangsmodus), der Header wird hier serverseitig gesetzt. +// mit X-Workflow-Token (Zielmodul workflow, gleicher Benutzer und Mandant) geht der Aufruf per Bearer an die Workflow-API, +// ohne ihn als X-User-Id (Uebergangsmodus). export async function GET(request: NextRequest) { const base = process.env.PORTAL_WORKFLOW_API_URL; if (!base || !coreConfigured()) { @@ -16,12 +17,22 @@ export async function GET(request: NextRequest) { const claims = await authenticate(request); if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 }); + const wfHeader = request.headers.get("x-workflow-token"); + let workflowToken: string | undefined; + if (wfHeader) { + const wfClaims = await verifyWorkflowToken(wfHeader); + if (!wfClaims || wfClaims.sub !== claims.sub || wfClaims.tenant !== claims.tenant) { + return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 }); + } + workflowToken = wfHeader; + } + const noStore = { "Cache-Control": "no-store" }; const key = `${claims.tenant}\u0000${claims.sub}`; const hit = worklistCache.get(key, CACHE_TTL_MS); if (hit) return NextResponse.json(hit, { headers: noStore }); - const data = await loadWorklist(base, claims.tenant, claims.sub); + const data = await loadWorklist(base, claims.tenant, claims.sub, fetch, workflowToken); worklistCache.set(key, data); return NextResponse.json(data, { headers: noStore }); } diff --git a/web/portal/lib/auth.ts b/web/portal/lib/auth.ts index 0d379b8e..f9e57b36 100644 --- a/web/portal/lib/auth.ts +++ b/web/portal/lib/auth.ts @@ -17,3 +17,13 @@ export async function authenticate(request: NextRequest): Promise { + const coreBase = process.env.PORTAL_CORE_API_URL ?? process.env.NEXT_PUBLIC_CORE_API_BASE; + if (!coreBase) return null; + return verifyModuleToken(token, { + jwksUrl: `${coreBase.replace(/\/$/, "")}/auth/module-jwks`, + issuer: process.env.PORTAL_TOKEN_ISSUER, + audience: process.env.PORTAL_WORKFLOW_TOKEN_AUDIENCE || "workflow", + }); +} diff --git a/web/portal/lib/http.ts b/web/portal/lib/http.ts index 255a8c5a..43f959a3 100644 --- a/web/portal/lib/http.ts +++ b/web/portal/lib/http.ts @@ -1,6 +1,7 @@ import { createSession, redirectToLogin, type Session } from "@nexarch/shl"; let session: Session | undefined; +let workflowSession: Session | undefined; export function getSession(): Session { if (!session) { @@ -17,6 +18,18 @@ export function getSession(): Session { return session; } +export function getWorkflowSession(): Session { + if (!workflowSession) { + const coreBase = process.env.NEXT_PUBLIC_CORE_API_BASE; + if (!coreBase) { + throw new Error("NEXT_PUBLIC_CORE_API_BASE ist nicht gesetzt (Umgebungsvariable erforderlich)"); + } + workflowSession = createSession({ coreBase, module: "workflow" }); + } + return workflowSession; +} + export function resetSessionForTests(): void { session = undefined; + workflowSession = undefined; } diff --git a/web/portal/lib/worklist.test.ts b/web/portal/lib/worklist.test.ts index 62d1ca70..d7099389 100644 --- a/web/portal/lib/worklist.test.ts +++ b/web/portal/lib/worklist.test.ts @@ -46,6 +46,17 @@ describe("loadWorklist", () => { expect(calls.every((c) => c.headers["X-User-Id"] === "u1" && c.headers["X-Tenant-Slug"] === "acme")).toBe(true); }); + it("sendet mit Workflow-Token nur Authorization und keine X-User-Id", async () => { + const seen: Record[] = []; + const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { + seen.push(init.headers as Record); + return new Response(JSON.stringify([]), { status: 200 }); + }) as unknown as typeof fetch; + await loadWorklist("http://wf.example", "acme", "u1", fetchImpl, "tok"); + expect(seen).toHaveLength(3); + expect(seen.every((h) => h.Authorization === "Bearer tok" && !("X-User-Id" in h) && !("X-Tenant-Slug" in h))).toBe(true); + }); + it("markiert ausgefallene Quellen mit null, ohne die anderen zu verlieren", async () => { const fetchImpl = vi.fn(async (url: string) => { if (url.endsWith("/tasks")) throw new Error("down"); diff --git a/web/portal/lib/worklist.ts b/web/portal/lib/worklist.ts index d9c4ad7a..e53485f5 100644 --- a/web/portal/lib/worklist.ts +++ b/web/portal/lib/worklist.ts @@ -39,10 +39,13 @@ export async function loadWorklist( base: string, tenant: string, userId: string, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = fetch, + workflowToken?: string ): Promise { const root = base.replace(/\/$/, ""); - const headers = { "X-User-Id": userId, "X-Tenant-Slug": tenant, Accept: "application/json" }; + const headers: Record = workflowToken + ? { Authorization: `Bearer ${workflowToken}`, Accept: "application/json" } + : { "X-User-Id": userId, "X-Tenant-Slug": tenant, Accept: "application/json" }; const get = async (path: string): Promise => { try { const res = await fetchImpl(`${root}${path}`, { headers, cache: "no-store" });