@efa-one/sdk 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,9 +12,9 @@ sub-paths — this keeps `express` out of the frontend and `react` out of the ba
12
12
 
13
13
  | Import | Runs in | Contains |
14
14
  |---|---|---|
15
- | `@efa-one/sdk/backend` | Node/Express | Auth + token exchange (`requireAuth`, `createExchangeRouter`, `requireInternalOrAuth` …), health router, service discovery + gateway client (`serviceClient`, `resolveService`), clients for audit/reporting/mail/notifications, permission resolution/registration (`getUserPermissions`, `registerPermissions`, `checkPermission`), capability registry (`registerApiMetadata`), backend i18n |
16
- | `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`registerAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), `DevHeader` |
17
- | `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog`, and the **`DataTable`** (sortable/filterable/groupable list with draggable, resizable columns and a per-user persisted view; reflows to stacked cards below 640px). Also exports `useIsMobile` — the platform-wide 640px breakpoint hook. Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
15
+ | `@efa-one/sdk/backend` | Node/Express | Auth + token exchange (`requireAuth`, `createExchangeRouter`, `requireInternalOrAuth` …), health router, service discovery + gateway client (`serviceClient`, `resolveService`), clients for audit/reporting/mail/notifications, permission resolution/registration (`getUserPermissions`, `registerPermissions`, `checkPermission`), platform-admin check (`isPlatformAdmin` — top permission `efa-all`, legacy `converge-admin` accepted until Q1-2027; `requireAdmin`/`requireAdminOrPermission` use it), capability registry (`registerApiMetadata`), backend i18n |
16
+ | `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`registerAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), platform-admin check (`isPlatformAdmin`, `PLATFORM_ADMIN_PERMISSIONS`), `DevHeader` |
17
+ | `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog`, and the **`DataTable`** (sortable/filterable/groupable list with draggable, resizable columns, a per-user persisted view and CSV/Excel export of the current view; reflows to stacked cards below 640px). Also exports `useIsMobile` — the platform-wide 640px breakpoint hook. Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
18
18
  | `@efa-one/sdk/frontend/format` | Browser | Platform-wide date/time display — `formatDate` (`01.09.2026`), `formatDateTime` (`01.09.2026, 11:05`), `formatTime`, `formatFileStamp`, `localeForLanguage`. Always use these instead of `toLocaleDateString`/`toLocaleString`: without an options object those render `1.9.2026`, and without a locale argument they follow the browser language (`9/1/2026`) |
19
19
  | `@efa-one/sdk/frontend/viewPreferences` | Browser/React | Persistence seam for `DataTable` — `createViewPreferencesClient()` (standard `/api/view-preferences/:listId` adapter) + `useViewPreferences()` |
20
20
 
@@ -53,6 +53,10 @@ npm install @efa-one/sdk react i18next i18next-http-backend react-i18next
53
53
 
54
54
  The runtime libraries are **optional peer dependencies** — install only the ones
55
55
  matching the entry point you use. The SDK itself does not bundle them.
56
+ `i18next-http-backend` requires **4.0.2 or later**: older releases are covered by
57
+ published security advisories (`npm audit` reports GHSA-xvq9-wjp8-hwqf for every
58
+ version below 4.0.2). The range stays open upwards like the other peers, so a new
59
+ major does not need an SDK release first.
56
60
 
57
61
  ## Usage
58
62
 
@@ -223,6 +227,77 @@ before, it was missing until the user reset the view. New prop `groupsDefaultExp
223
227
  groups start expanded; groups the user collapses stay collapsed. No backend or kernel
224
228
  change.
225
229
 
230
+ **1.22.0 — export the current view.** The DataTable's gear menu now has „Excel" and
231
+ „CSV" buttons. Both export exactly what the user sees: filtered and sorted rows (in
232
+ group order when grouped, collapsed groups included), visible columns in their order.
233
+ CSV uses `;` as separator, `,` as decimal mark and a UTF-8 BOM, so German Excel opens
234
+ it by double-click; text that starts like a formula is prefixed with `'`. The .xlsx is
235
+ written without a dependency (stored ZIP): bold, light-grey header row with thin
236
+ borders, frozen, with autofilter; numbers and dates are real Excel values
237
+ (`dd.mm.yyyy`). Values come from `accessor`, not from `cell`; multi-select filter
238
+ codes are exported as their labels. New `ColumnDef` fields `exportValue` (own export
239
+ value) and `exportable: false` (leave out, e.g. action columns), new props
240
+ `exportFileName` (default: `listId`, plus a timestamp) and `exportable={false}` (hide
241
+ the export, e.g. without an export permission). `toCsv`/`toXlsx`/`downloadFile` are
242
+ exported for app-side use. CSV is CSV: Excel turns `01067` into `1067` and long digit
243
+ strings into `1,23E+18`, and shows the `'` in front of `+49 …` — point users to the
244
+ Excel export for such data. No backend or kernel change.
245
+
246
+ **1.23.0 — Q4-2026 security bundle, `efa-all`.** Needs **Kernel ≥ v1.1.0**.
247
+ - `efa-all` is the top permission (was `converge-admin`). `isPlatformAdmin(keys)`,
248
+ `PLATFORM_ADMIN_PERMISSION(S)` and the admin guards accept `efa-all` **or**
249
+ `converge-admin` until Q1-2027.
250
+ - Exchange rejects purpose-bound tokens (`purpose`/`serviceKey`), `gw` other than
251
+ `'app'`, `forcePasswordChange` and `tenant !== 'default'` (`classifyPlatformToken`);
252
+ `gw: 'app'` only with gateway provenance for this app.
253
+ - In production (`NODE_ENV=production`) `app_session` lives on `/apps/<SERVICE_KEY>/`
254
+ instead of `/`; the exchange removes an old `/` cookie unless the name is the generic
255
+ `app_session`. Apps still on the generic name set `APP_SESSION_COOKIE_NAME=<key>_app_session`.
256
+ - `requireRegistryKey`/`requireInternalOrAuth`/`requireInternalOrAdminOrPermission` take
257
+ `{ allowedCallers }`, set `req.callerService`, accept provenance tokens for 60 s and only
258
+ once (`jti`).
259
+ - `APP_SESSION_SECRET` missing or a placeholder aborts the start in production, shorter
260
+ than 32 bytes warns (`APP_SESSION_SECRET_STRICT=true` aborts).
261
+ - `serviceClient` no longer retries 4xx (`ServiceClientError`), has `timeoutMs`;
262
+ permission lookups time out after 5 s. `isFromPlatformParent` also checks `event.origin`.
263
+ - Peer range `i18next-http-backend >=4.0.2`; DataTable bulk bar says „Auswahl aufheben".
264
+
265
+ **Next minor (planned 1.23.0, not yet released) — exchange, cookie and S2S hardening.**
266
+ Needs a kernel with efaware/efa_kernel#106 and #107 first (gateway exchange token
267
+ `gw: 'app'`, provenance `jti`, cookie filter). Then:
268
+
269
+ - `POST /api/auth/exchange` rejects tokens with `purpose` or `serviceKey` (delegation,
270
+ RAG, run-as), with a `gw` other than `'app'`, with `forcePasswordChange: true` and with a
271
+ tenant other than `default` (`classifyPlatformToken`). The gateway exchange token
272
+ (`gw: 'app'`) is accepted only together with the gateway's provenance token for this app
273
+ (`sub=converge_kernel`, `aud=SERVICE_KEY`), so an app that received one cannot replay it
274
+ at another app. Kernel first: before efaware/efa_kernel#106 tokens carry no `gw`.
275
+ - `app_session` lives on `/apps/<SERVICE_KEY>/` instead of `/` when `NODE_ENV=production`
276
+ (every app image sets it); `/` otherwise, `APP_SESSION_COOKIE_PATH` overrides (set it to
277
+ `/` to run a built image locally without the gateway). With an app-specific cookie name
278
+ the exchange deletes a legacy cookie on `/`; the generic `app_session` is left alone,
279
+ it may belong to another app.
280
+ - `requireRegistryKey({ allowedCallers })`, `requireInternalOrAuth({ allowedCallers })`,
281
+ `requireInternalOrAdminOrPermission({ allowedCallers }, ...keys)`; the plain forms keep
282
+ working. `req.callerService` carries the verified caller. Provenance tokens older than
283
+ 60 s are rejected, each `jti` is accepted once per app (in-memory, bounded); tokens
284
+ without `jti` (older kernel) still pass.
285
+ - `APP_SESSION_SECRET`: missing or placeholder → `createExchangeRouter` throws at startup
286
+ in production (outside production it logs, so test suites that build the router before
287
+ setting env keep working); shorter than 32 bytes → warning, start abort with
288
+ `APP_SESSION_SECRET_STRICT=true`.
289
+ - `requireInternalOrAdminOrPermission` throws when an options object is not the first
290
+ argument (it would otherwise be ignored silently).
291
+ - `serviceClient` no longer retries 4xx (`ServiceClientError` with `status`), new
292
+ `call(…, body, { timeoutMs })`; permission lookups time out after 5 s.
293
+ - `registerPermissions` no longer falls back to `CONVERGE_REGISTRY_URL` +
294
+ `/api/registry/services/:key/permissions` (that kernel route always answered 500 and is
295
+ removed in efaware/efa_kernel for efa #287); without `CONVERGE_GATEWAY_URL` it logs and
296
+ skips. `options.registryBaseUrl` is ignored.
297
+ - `isFromPlatformParent` also checks `event.origin === window.location.origin`. The kernel
298
+ embeds SDK apps same-origin under `/apps/<key>/`; an app embedded from a foreign origin
299
+ no longer accepts platform messages.
300
+
226
301
  ## License
227
302
 
228
303
  [Apache-2.0](./LICENSE) — permissive with a patent grant, so customers and partners
package/backend/auth.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Router, Request, Response, NextFunction } from 'express';
2
2
  import { Pool } from 'pg';
3
+ import { type ProvenanceGuardOptions } from './serviceProvenance';
4
+ export type { ProvenanceGuardOptions } from './serviceProvenance';
3
5
  export interface JwtPayload {
4
6
  sub: string;
5
7
  name: string;
@@ -10,6 +12,7 @@ export interface JwtPayload {
10
12
  exp: number;
11
13
  forcePasswordChange?: boolean;
12
14
  jti?: string;
15
+ gw?: string;
13
16
  }
14
17
  export interface SessionPayload {
15
18
  sub: string;
@@ -23,12 +26,48 @@ export interface SessionPayload {
23
26
  }
24
27
  export interface AuthRequest extends Request {
25
28
  user?: SessionPayload;
29
+ callerService?: string;
26
30
  }
31
+ export declare const APP_SESSION_SECRET_MIN_BYTES = 32;
32
+ export type SessionSecretVerdict = {
33
+ ok: true;
34
+ } | {
35
+ ok: false;
36
+ level: 'error' | 'warn';
37
+ reason: 'missing' | 'placeholder' | 'too_short';
38
+ message: string;
39
+ };
40
+ export declare function checkSessionSecret(secret: string | undefined): SessionSecretVerdict;
41
+ export declare function assertSessionSecret(): void;
42
+ export declare function getSessionCookiePath(): string;
27
43
  export declare function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void;
28
44
  export declare function requireAdmin(req: AuthRequest, res: Response, next: NextFunction): void;
29
45
  export declare function requirePermission(permission: string): (req: AuthRequest, res: Response, next: NextFunction) => void;
30
46
  export declare function requireAdminOrPermission(...permissionKeys: string[]): (req: AuthRequest, res: Response, next: NextFunction) => void;
47
+ type Guard = (req: AuthRequest, res: Response, next: NextFunction) => void;
48
+ export declare function requireRegistryKey(options: ProvenanceGuardOptions): Guard;
31
49
  export declare function requireRegistryKey(req: Request, res: Response, next: NextFunction): void;
50
+ export declare function requireInternalOrAuth(options: ProvenanceGuardOptions): Guard;
32
51
  export declare function requireInternalOrAuth(req: AuthRequest, res: Response, next: NextFunction): void;
33
- export declare function requireInternalOrAdminOrPermission(...permissionKeys: string[]): (req: AuthRequest, res: Response, next: NextFunction) => void;
52
+ export declare function requireInternalOrAdminOrPermission(...permissionKeys: string[]): Guard;
53
+ export declare function requireInternalOrAdminOrPermission(options: ProvenanceGuardOptions, ...permissionKeys: string[]): Guard;
54
+ export type PlatformTokenVerdict = {
55
+ ok: true;
56
+ } | {
57
+ ok: false;
58
+ status: 401;
59
+ error: string;
60
+ code: 'delegation_token_not_accepted';
61
+ } | {
62
+ ok: false;
63
+ status: 403;
64
+ error: 'Password change required';
65
+ code: 'FORCE_PASSWORD_CHANGE';
66
+ } | {
67
+ ok: false;
68
+ status: 403;
69
+ error: 'tenant_not_supported';
70
+ code: 'tenant_not_supported';
71
+ };
72
+ export declare function classifyPlatformToken(payload: Record<string, unknown>): PlatformTokenVerdict;
34
73
  export declare function createExchangeRouter(pool: Pool): Router;
package/backend/auth.js CHANGED
@@ -3,6 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.APP_SESSION_SECRET_MIN_BYTES = void 0;
7
+ exports.checkSessionSecret = checkSessionSecret;
8
+ exports.assertSessionSecret = assertSessionSecret;
9
+ exports.getSessionCookiePath = getSessionCookiePath;
6
10
  exports.requireAuth = requireAuth;
7
11
  exports.requireAdmin = requireAdmin;
8
12
  exports.requirePermission = requirePermission;
@@ -10,42 +14,106 @@ exports.requireAdminOrPermission = requireAdminOrPermission;
10
14
  exports.requireRegistryKey = requireRegistryKey;
11
15
  exports.requireInternalOrAuth = requireInternalOrAuth;
12
16
  exports.requireInternalOrAdminOrPermission = requireInternalOrAdminOrPermission;
17
+ exports.classifyPlatformToken = classifyPlatformToken;
13
18
  exports.createExchangeRouter = createExchangeRouter;
14
19
  const express_1 = require("express");
15
20
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
16
21
  const permissionClient_1 = require("./permissionClient");
17
22
  const sessionStatusClient_1 = require("./sessionStatusClient");
23
+ const platformAdmin_1 = require("./platformAdmin");
24
+ const serviceProvenance_1 = require("./serviceProvenance");
18
25
  const JWT_ISSUER = 'converge';
19
26
  const JWT_AUDIENCE = 'converge';
20
27
  const KERNEL_JTI_RE = /^[A-Za-z0-9._:-]{1,128}$/;
21
28
  const KERNEL_SUB_RE = /^[A-Za-z0-9._:@-]{1,128}$/;
22
- function getPlatformPublicKey() {
23
- const s = process.env.JWT_PUBLIC_KEY;
24
- if (!s)
25
- throw new Error('JWT_PUBLIC_KEY environment variable is required');
26
- return Buffer.from(s, 'base64').toString('utf8');
27
- }
28
29
  function getSessionSecret() {
29
30
  const s = process.env.APP_SESSION_SECRET;
30
31
  if (!s)
31
32
  throw new Error('APP_SESSION_SECRET environment variable is required');
32
33
  return s;
33
34
  }
35
+ exports.APP_SESSION_SECRET_MIN_BYTES = 32;
36
+ const SESSION_SECRET_PLACEHOLDER_PREFIXES = ['changeme', 'change-me', 'change_me', 'please_change', 'replace_me', 'your-'];
37
+ const SESSION_SECRET_PLACEHOLDERS = new Set(['secret', 'password', 'test', 'dev', 'development', 'app_session_secret']);
38
+ function checkSessionSecret(secret) {
39
+ if (!secret) {
40
+ return { ok: false, level: 'error', reason: 'missing', message: 'APP_SESSION_SECRET ist nicht gesetzt. Erzeugen mit: openssl rand -hex 32' };
41
+ }
42
+ const lower = secret.trim().toLowerCase();
43
+ if (SESSION_SECRET_PLACEHOLDERS.has(lower) || SESSION_SECRET_PLACEHOLDER_PREFIXES.some((p) => lower.startsWith(p))) {
44
+ return {
45
+ ok: false,
46
+ level: 'error',
47
+ reason: 'placeholder',
48
+ message: 'APP_SESSION_SECRET ist ein Platzhalter aus einer Beispiel-Konfiguration. Echten Wert setzen: openssl rand -hex 32',
49
+ };
50
+ }
51
+ const bytes = Buffer.byteLength(secret, 'utf8');
52
+ if (bytes < exports.APP_SESSION_SECRET_MIN_BYTES) {
53
+ return {
54
+ ok: false,
55
+ level: (0, serviceProvenance_1.isTruthyEnv)(process.env.APP_SESSION_SECRET_STRICT) ? 'error' : 'warn',
56
+ reason: 'too_short',
57
+ message: `APP_SESSION_SECRET ist ${bytes} Byte lang, verlangt sind mindestens ${exports.APP_SESSION_SECRET_MIN_BYTES}. Neu erzeugen mit: openssl rand -hex 32`,
58
+ };
59
+ }
60
+ return { ok: true };
61
+ }
62
+ function assertSessionSecret() {
63
+ const verdict = checkSessionSecret(process.env.APP_SESSION_SECRET);
64
+ if (verdict.ok)
65
+ return;
66
+ const fatal = verdict.level === 'error'
67
+ && (process.env.NODE_ENV === 'production' || (0, serviceProvenance_1.isTruthyEnv)(process.env.APP_SESSION_SECRET_STRICT));
68
+ if (fatal)
69
+ throw new Error(verdict.message);
70
+ const level = verdict.level === 'error' ? 'error' : 'warn';
71
+ console[level](JSON.stringify({ level, msg: verdict.message, reason: verdict.reason }));
72
+ }
73
+ const DEFAULT_COOKIE_NAME = 'app_session';
34
74
  function getCookieName() {
35
- return process.env.APP_SESSION_COOKIE_NAME || 'app_session';
75
+ return process.env.APP_SESSION_COOKIE_NAME || DEFAULT_COOKIE_NAME;
36
76
  }
37
- function setSessionCookie(res, token, maxAgeMs) {
38
- const secure = process.env.COOKIE_SECURE
39
- ? ['1', 'true', 'yes', 'on'].includes(process.env.COOKIE_SECURE.toLowerCase())
77
+ function getSessionCookiePath() {
78
+ const explicit = process.env.APP_SESSION_COOKIE_PATH?.trim();
79
+ if (explicit)
80
+ return explicit;
81
+ if (process.env.NODE_ENV !== 'production')
82
+ return '/';
83
+ const serviceKey = process.env.SERVICE_KEY;
84
+ if (serviceKey && serviceProvenance_1.SERVICE_KEY_RE.test(serviceKey))
85
+ return `/apps/${serviceKey}/`;
86
+ return '/';
87
+ }
88
+ function assertSessionCookiePath() {
89
+ const path = getSessionCookiePath();
90
+ if (!path.startsWith('/') || /[;,\s]/.test(path)) {
91
+ throw new Error(`APP_SESSION_COOKIE_PATH ungültig: ${JSON.stringify(path)} (muss mit / beginnen, ohne ; , oder Leerzeichen)`);
92
+ }
93
+ }
94
+ function isSecureCookie() {
95
+ return process.env.COOKIE_SECURE
96
+ ? (0, serviceProvenance_1.isTruthyEnv)(process.env.COOKIE_SECURE)
40
97
  : process.env.NODE_ENV === 'production';
98
+ }
99
+ function setSessionCookie(res, token, maxAgeMs) {
41
100
  res.cookie(getCookieName(), token, {
42
101
  httpOnly: true,
43
102
  sameSite: 'lax',
44
103
  maxAge: maxAgeMs,
45
- secure,
46
- path: '/',
104
+ secure: isSecureCookie(),
105
+ path: getSessionCookiePath(),
47
106
  });
48
107
  }
108
+ function clearLegacySessionCookie(req, res) {
109
+ if (getSessionCookiePath() === '/')
110
+ return;
111
+ if (getCookieName() === DEFAULT_COOKIE_NAME)
112
+ return;
113
+ if (req.cookies?.[getCookieName()] === undefined)
114
+ return;
115
+ res.clearCookie(getCookieName(), { httpOnly: true, sameSite: 'lax', secure: isSecureCookie(), path: '/' });
116
+ }
49
117
  function requireAuth(req, res, next) {
50
118
  const token = req.cookies?.[getCookieName()];
51
119
  if (!token) {
@@ -87,7 +155,7 @@ function requireAdmin(req, res, next) {
87
155
  requireAuth(req, res, () => {
88
156
  (0, permissionClient_1.getUserPermissions)(req.user.convergeId)
89
157
  .then((keys) => {
90
- if (!keys.includes('converge-admin')) {
158
+ if (!(0, platformAdmin_1.isPlatformAdmin)(keys)) {
91
159
  res.status(403).json({ error: 'Admin access required' });
92
160
  return;
93
161
  }
@@ -104,7 +172,10 @@ function requirePermission(permission) {
104
172
  requireAuth(req, res, () => {
105
173
  (0, permissionClient_1.getUserPermissions)(req.user.convergeId)
106
174
  .then((keys) => {
107
- if (!keys.includes(permission)) {
175
+ const granted = (0, platformAdmin_1.isPlatformAdminPermission)(permission)
176
+ ? (0, platformAdmin_1.isPlatformAdmin)(keys)
177
+ : keys.includes(permission);
178
+ if (!granted) {
108
179
  res.status(403).json({ error: `Permission required: ${permission}` });
109
180
  return;
110
181
  }
@@ -122,7 +193,7 @@ function requireAdminOrPermission(...permissionKeys) {
122
193
  requireAuth(req, res, () => {
123
194
  (0, permissionClient_1.getUserPermissions)(req.user.convergeId)
124
195
  .then((keys) => {
125
- if (keys.includes('converge-admin')) {
196
+ if ((0, platformAdmin_1.isPlatformAdmin)(keys)) {
126
197
  next();
127
198
  return;
128
199
  }
@@ -139,50 +210,99 @@ function requireAdminOrPermission(...permissionKeys) {
139
210
  });
140
211
  };
141
212
  }
142
- const PROVENANCE_ISSUER = 'converge-gateway';
143
- function hasValidServiceProvenance(req) {
144
- const token = req.headers['x-service-token'];
145
- if (!token)
146
- return false;
147
- const audience = process.env.SERVICE_KEY;
148
- if (!audience)
149
- return false;
150
- try {
151
- jsonwebtoken_1.default.verify(token, getPlatformPublicKey(), {
152
- algorithms: ['RS256'],
153
- issuer: PROVENANCE_ISSUER,
154
- audience,
155
- });
156
- return true;
157
- }
158
- catch {
159
- return false;
160
- }
213
+ function isMiddlewareCall(res, next) {
214
+ return res !== undefined && typeof next === 'function';
161
215
  }
162
- function requireRegistryKey(req, res, next) {
163
- if (hasValidServiceProvenance(req)) {
164
- next();
216
+ function rejectProvenance(res, result) {
217
+ if (!result.ok && result.reason === 'caller_not_allowed') {
218
+ res.status(403).json({ error: 'Service caller not allowed' });
165
219
  return;
166
220
  }
167
221
  res.status(401).json({ error: 'Service provenance token required' });
168
222
  }
169
- function requireInternalOrAuth(req, res, next) {
170
- if (hasValidServiceProvenance(req)) {
171
- next();
223
+ function registryKeyGuard(options) {
224
+ return (req, res, next) => {
225
+ const result = (0, serviceProvenance_1.verifyServiceProvenance)(req, options);
226
+ if (result.ok) {
227
+ next();
228
+ return;
229
+ }
230
+ rejectProvenance(res, result);
231
+ };
232
+ }
233
+ function internalOrAuthGuard(options) {
234
+ return (req, res, next) => {
235
+ if ((0, serviceProvenance_1.verifyServiceProvenance)(req, options).ok) {
236
+ next();
237
+ return;
238
+ }
239
+ requireAuth(req, res, next);
240
+ };
241
+ }
242
+ function dualGuard(factory, a, res, next) {
243
+ if (isMiddlewareCall(res, next)) {
244
+ factory({})(a, res, next);
172
245
  return;
173
246
  }
174
- requireAuth(req, res, next);
247
+ const options = (a ?? {});
248
+ (0, serviceProvenance_1.assertProvenanceOptions)(options);
249
+ return factory(options);
250
+ }
251
+ function requireRegistryKey(a, res, next) {
252
+ return dualGuard(registryKeyGuard, a, res, next);
253
+ }
254
+ function requireInternalOrAuth(a, res, next) {
255
+ return dualGuard(internalOrAuthGuard, a, res, next);
175
256
  }
176
- function requireInternalOrAdminOrPermission(...permissionKeys) {
257
+ function requireInternalOrAdminOrPermission(...args) {
258
+ const [first, ...rest] = args;
259
+ const options = typeof first === 'object' && first !== null ? first : {};
260
+ const keys = typeof first === 'string' ? args : rest;
261
+ if (!keys.every((a) => typeof a === 'string')) {
262
+ throw new Error('requireInternalOrAdminOrPermission: Optionen nur als erstes Argument, danach Permission-Keys');
263
+ }
264
+ const permissionKeys = keys;
265
+ (0, serviceProvenance_1.assertProvenanceOptions)(options);
266
+ const userGuard = requireAdminOrPermission(...permissionKeys);
177
267
  return (req, res, next) => {
178
- if (hasValidServiceProvenance(req)) {
268
+ if ((0, serviceProvenance_1.verifyServiceProvenance)(req, options).ok) {
179
269
  next();
180
270
  return;
181
271
  }
182
- requireAdminOrPermission(...permissionKeys)(req, res, next);
272
+ userGuard(req, res, next);
183
273
  };
184
274
  }
275
+ const PLATFORM_TENANT = 'default';
276
+ const GATEWAY_EXCHANGE_CALLER = 'converge_kernel';
277
+ function classifyPlatformToken(payload) {
278
+ const serviceKey = payload.serviceKey;
279
+ if (serviceKey !== undefined && serviceKey !== null && serviceKey !== '') {
280
+ return { ok: false, status: 401, error: 'Service-Token begründet keine Sitzung', code: 'delegation_token_not_accepted' };
281
+ }
282
+ const purpose = payload.purpose;
283
+ if (purpose !== undefined && purpose !== null && purpose !== '') {
284
+ const label = typeof purpose === 'string' ? purpose.slice(0, 64) : typeof purpose;
285
+ return {
286
+ ok: false,
287
+ status: 401,
288
+ error: `Zweckgebundenes Token (purpose=${label}) begründet keine Sitzung`,
289
+ code: 'delegation_token_not_accepted',
290
+ };
291
+ }
292
+ if (payload.gw !== undefined && payload.gw !== 'app') {
293
+ return { ok: false, status: 401, error: 'Gateway-Token für ein anderes Ziel begründet keine Sitzung', code: 'delegation_token_not_accepted' };
294
+ }
295
+ if (payload.forcePasswordChange === true) {
296
+ return { ok: false, status: 403, error: 'Password change required', code: 'FORCE_PASSWORD_CHANGE' };
297
+ }
298
+ if (payload.tenant !== PLATFORM_TENANT) {
299
+ return { ok: false, status: 403, error: 'tenant_not_supported', code: 'tenant_not_supported' };
300
+ }
301
+ return { ok: true };
302
+ }
185
303
  function createExchangeRouter(pool) {
304
+ assertSessionSecret();
305
+ assertSessionCookiePath();
186
306
  const router = (0, express_1.Router)();
187
307
  router.post('/exchange', async (req, res) => {
188
308
  const { token } = req.body;
@@ -192,7 +312,7 @@ function createExchangeRouter(pool) {
192
312
  }
193
313
  let convergePayload;
194
314
  try {
195
- convergePayload = jsonwebtoken_1.default.verify(token, getPlatformPublicKey(), {
315
+ convergePayload = jsonwebtoken_1.default.verify(token, (0, serviceProvenance_1.getPlatformPublicKey)(), {
196
316
  algorithms: ['RS256'],
197
317
  issuer: JWT_ISSUER,
198
318
  audience: JWT_AUDIENCE,
@@ -202,6 +322,29 @@ function createExchangeRouter(pool) {
202
322
  res.status(401).json({ error: 'Invalid or expired platform token' });
203
323
  return;
204
324
  }
325
+ const verdict = classifyPlatformToken(convergePayload);
326
+ if (!verdict.ok) {
327
+ console.warn(JSON.stringify({
328
+ level: 'warn',
329
+ msg: 'Token-Exchange abgelehnt',
330
+ code: verdict.code,
331
+ sub: typeof convergePayload.sub === 'string' ? convergePayload.sub : null,
332
+ }));
333
+ res.status(verdict.status).json({ error: verdict.error, code: verdict.code });
334
+ return;
335
+ }
336
+ if (convergePayload.gw === 'app') {
337
+ const provenance = (0, serviceProvenance_1.verifyServiceProvenance)(req, { allowedCallers: [GATEWAY_EXCHANGE_CALLER] });
338
+ if (!provenance.ok) {
339
+ console.warn(JSON.stringify({
340
+ level: 'warn',
341
+ msg: 'Token-Exchange abgelehnt: Gateway-Tausch-Token ohne Herkunftsnachweis',
342
+ reason: provenance.reason,
343
+ }));
344
+ res.status(401).json({ error: 'Gateway-Tausch-Token nur auf dem Tausch-Aufruf des Gateways', code: 'gateway_provenance_required' });
345
+ return;
346
+ }
347
+ }
205
348
  if (typeof convergePayload.jti !== 'string' || !KERNEL_JTI_RE.test(convergePayload.jti) ||
206
349
  typeof convergePayload.sub !== 'string' || !KERNEL_SUB_RE.test(convergePayload.sub) ||
207
350
  !Number.isInteger(convergePayload.iat) || !Number.isInteger(convergePayload.exp)) {
@@ -238,6 +381,7 @@ function createExchangeRouter(pool) {
238
381
  };
239
382
  const sessionToken = jsonwebtoken_1.default.sign(sessionPayload, getSessionSecret(), { algorithm: 'HS256', expiresIn: remainingSec });
240
383
  setSessionCookie(res, sessionToken, remainingSec * 1000);
384
+ clearLegacySessionCookie(req, res);
241
385
  res.json({
242
386
  user: {
243
387
  id: appUser.id,
@@ -254,7 +398,11 @@ function createExchangeRouter(pool) {
254
398
  }
255
399
  });
256
400
  router.post('/logout', (_req, res) => {
257
- res.clearCookie(getCookieName());
401
+ const opts = { httpOnly: true, sameSite: 'lax', secure: isSecureCookie() };
402
+ res.clearCookie(getCookieName(), { ...opts, path: getSessionCookiePath() });
403
+ if (getSessionCookiePath() !== '/' && getCookieName() !== DEFAULT_COOKIE_NAME) {
404
+ res.clearCookie(getCookieName(), { ...opts, path: '/' });
405
+ }
258
406
  res.json({ ok: true });
259
407
  });
260
408
  router.get('/permissions', requireAuth, async (req, res) => {
@@ -8,8 +8,10 @@ export * from './permissions';
8
8
  export * from './permissionClient';
9
9
  export * from './sessionStatusClient';
10
10
  export * from './permissionCheck';
11
+ export * from './platformAdmin';
11
12
  export * from './customPermissions';
12
13
  export * from './serviceClient';
13
14
  export * from './serviceDiscovery';
14
15
  export * from './apiRegistry';
15
16
  export * from './i18n-backend';
17
+ export * from './serviceProvenance';
package/backend/index.js CHANGED
@@ -24,8 +24,10 @@ __exportStar(require("./permissions"), exports);
24
24
  __exportStar(require("./permissionClient"), exports);
25
25
  __exportStar(require("./sessionStatusClient"), exports);
26
26
  __exportStar(require("./permissionCheck"), exports);
27
+ __exportStar(require("./platformAdmin"), exports);
27
28
  __exportStar(require("./customPermissions"), exports);
28
29
  __exportStar(require("./serviceClient"), exports);
29
30
  __exportStar(require("./serviceDiscovery"), exports);
30
31
  __exportStar(require("./apiRegistry"), exports);
31
32
  __exportStar(require("./i18n-backend"), exports);
33
+ __exportStar(require("./serviceProvenance"), exports);
@@ -2,11 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.checkPermission = checkPermission;
4
4
  const serviceClient_1 = require("./serviceClient");
5
+ const permissionClient_1 = require("./permissionClient");
5
6
  async function checkPermission(convergeId, key, sourceApp) {
6
7
  if (!convergeId)
7
8
  throw new Error('checkPermission: convergeId required');
8
9
  if (!key)
9
10
  throw new Error('checkPermission: key required');
10
- const { granted } = await serviceClient_1.serviceClient.call('converge_access', 'POST', '/api/internal/check-permission', { convergeId, key, sourceApp: sourceApp ?? process.env.APP_NAME ?? null });
11
+ const { granted } = await serviceClient_1.serviceClient.call('converge_access', 'POST', '/api/internal/check-permission', { convergeId, key, sourceApp: sourceApp ?? process.env.APP_NAME ?? null }, { timeoutMs: permissionClient_1.PERMISSION_LOOKUP_TIMEOUT_MS });
11
12
  return Boolean(granted);
12
13
  }
@@ -1 +1,2 @@
1
+ export declare const PERMISSION_LOOKUP_TIMEOUT_MS = 5000;
1
2
  export declare function getUserPermissions(convergeId: string): Promise<string[]>;
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PERMISSION_LOOKUP_TIMEOUT_MS = void 0;
3
4
  exports.getUserPermissions = getUserPermissions;
4
5
  const serviceClient_1 = require("./serviceClient");
6
+ exports.PERMISSION_LOOKUP_TIMEOUT_MS = 5000;
5
7
  async function getUserPermissions(convergeId) {
6
8
  if (!convergeId) {
7
9
  throw new Error('getUserPermissions: convergeId required');
8
10
  }
9
- const { keys } = await serviceClient_1.serviceClient.call('converge_access', 'GET', `/api/internal/users/${encodeURIComponent(convergeId)}/permissions`);
11
+ const { keys } = await serviceClient_1.serviceClient.call('converge_access', 'GET', `/api/internal/users/${encodeURIComponent(convergeId)}/permissions`, undefined, { timeoutMs: exports.PERMISSION_LOOKUP_TIMEOUT_MS });
10
12
  return keys ?? [];
11
13
  }
@@ -8,18 +8,11 @@ async function registerPermissions(serviceKey, permissions, options) {
8
8
  if (permissions.length === 0)
9
9
  return;
10
10
  const gatewayUrl = (process.env.CONVERGE_GATEWAY_URL ?? '').replace(/\/$/, '');
11
- const legacyUrl = (options?.registryBaseUrl ?? process.env.CONVERGE_REGISTRY_URL ?? '').replace(/\/$/, '');
12
- let url;
13
- if (gatewayUrl) {
14
- url = `${gatewayUrl}/api/gateway/converge_access/api/internal/register`;
15
- }
16
- else if (legacyUrl) {
17
- url = `${legacyUrl}/api/registry/services/${encodeURIComponent(serviceKey)}/permissions`;
18
- }
19
- else {
20
- log('warn', 'registerPermissions: Weder CONVERGE_GATEWAY_URL noch CONVERGE_REGISTRY_URL gesetzt – übersprungen');
11
+ if (!gatewayUrl) {
12
+ log('warn', 'registerPermissions: CONVERGE_GATEWAY_URL nicht gesetzt – übersprungen');
21
13
  return;
22
14
  }
15
+ const url = `${gatewayUrl}/api/gateway/converge_access/api/internal/register`;
23
16
  const maxRetries = options?.retries ?? 3;
24
17
  const retryDelay = options?.retryDelayMs ?? 5000;
25
18
  const fetchImpl = options?.fetchImpl ?? fetch;
@@ -0,0 +1,4 @@
1
+ export declare const PLATFORM_ADMIN_PERMISSION = "efa-all";
2
+ export declare const PLATFORM_ADMIN_PERMISSIONS: readonly string[];
3
+ export declare function isPlatformAdminPermission(key: string): boolean;
4
+ export declare function isPlatformAdmin(permissionKeys: readonly string[] | null | undefined): boolean;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLATFORM_ADMIN_PERMISSIONS = exports.PLATFORM_ADMIN_PERMISSION = void 0;
4
+ exports.isPlatformAdminPermission = isPlatformAdminPermission;
5
+ exports.isPlatformAdmin = isPlatformAdmin;
6
+ exports.PLATFORM_ADMIN_PERMISSION = 'efa-all';
7
+ exports.PLATFORM_ADMIN_PERMISSIONS = Object.freeze([
8
+ exports.PLATFORM_ADMIN_PERMISSION,
9
+ 'converge-admin',
10
+ ]);
11
+ function isPlatformAdminPermission(key) {
12
+ return exports.PLATFORM_ADMIN_PERMISSIONS.includes(key);
13
+ }
14
+ function isPlatformAdmin(permissionKeys) {
15
+ return !!permissionKeys && permissionKeys.some(isPlatformAdminPermission);
16
+ }
@@ -5,6 +5,15 @@ export interface ServiceClientOptions {
5
5
  retries?: number;
6
6
  retryDelayMs?: number;
7
7
  }
8
+ export interface ServiceCallOptions {
9
+ timeoutMs?: number;
10
+ }
11
+ export declare class ServiceClientError extends Error {
12
+ readonly status: number | null;
13
+ readonly serviceKey: string;
14
+ readonly timedOut: boolean;
15
+ constructor(message: string, serviceKey: string, status: number | null, timedOut?: boolean);
16
+ }
8
17
  export declare class ServiceClient {
9
18
  private gatewayUrl;
10
19
  private registryKey;
@@ -12,6 +21,6 @@ export declare class ServiceClient {
12
21
  private retries;
13
22
  private retryDelay;
14
23
  constructor(options?: ServiceClientOptions);
15
- call<T = unknown>(serviceKey: string, method: string, path: string, body?: unknown): Promise<T>;
24
+ call<T = unknown>(serviceKey: string, method: string, path: string, body?: unknown, options?: ServiceCallOptions): Promise<T>;
16
25
  }
17
26
  export declare const serviceClient: ServiceClient;