@efa-one/sdk 1.22.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 +61 -2
- package/backend/auth.d.ts +40 -1
- package/backend/auth.js +194 -46
- package/backend/index.d.ts +2 -0
- package/backend/index.js +2 -0
- package/backend/permissionCheck.js +2 -1
- package/backend/permissionClient.d.ts +1 -0
- package/backend/permissionClient.js +3 -1
- package/backend/permissions.js +3 -10
- package/backend/platformAdmin.d.ts +4 -0
- package/backend/platformAdmin.js +16 -0
- package/backend/serviceClient.d.ts +10 -1
- package/backend/serviceClient.js +67 -34
- package/backend/serviceProvenance.d.ts +31 -0
- package/backend/serviceProvenance.js +137 -0
- package/frontend/index.d.ts +1 -0
- package/frontend/index.js +1 -0
- package/frontend/ipc.js +3 -1
- package/frontend/platformAdmin.d.ts +4 -0
- package/frontend/platformAdmin.js +11 -0
- package/frontend/ui/DataTable.js +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ 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` |
|
|
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
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()` |
|
|
@@ -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
|
|
|
@@ -239,6 +243,61 @@ exported for app-side use. CSV is CSV: Excel turns `01067` into `1067` and long
|
|
|
239
243
|
strings into `1,23E+18`, and shows the `'` in front of `+49 …` — point users to the
|
|
240
244
|
Excel export for such data. No backend or kernel change.
|
|
241
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
|
+
|
|
242
301
|
## License
|
|
243
302
|
|
|
244
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[]):
|
|
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 ||
|
|
75
|
+
return process.env.APP_SESSION_COOKIE_NAME || DEFAULT_COOKIE_NAME;
|
|
36
76
|
}
|
|
37
|
-
function
|
|
38
|
-
const
|
|
39
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
163
|
-
if (
|
|
164
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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(...
|
|
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 (
|
|
268
|
+
if ((0, serviceProvenance_1.verifyServiceProvenance)(req, options).ok) {
|
|
179
269
|
next();
|
|
180
270
|
return;
|
|
181
271
|
}
|
|
182
|
-
|
|
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
|
-
|
|
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) => {
|
package/backend/index.d.ts
CHANGED
|
@@ -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,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
|
}
|
package/backend/permissions.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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;
|
package/backend/serviceClient.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.serviceClient = exports.ServiceClient = void 0;
|
|
3
|
+
exports.serviceClient = exports.ServiceClient = exports.ServiceClientError = void 0;
|
|
4
4
|
function log(level, msg, extra) {
|
|
5
5
|
console.log(JSON.stringify({ level, msg, timestamp: new Date().toISOString(), ...extra }));
|
|
6
6
|
}
|
|
7
|
+
class ServiceClientError extends Error {
|
|
8
|
+
constructor(message, serviceKey, status, timedOut = false) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'ServiceClientError';
|
|
11
|
+
this.serviceKey = serviceKey;
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.timedOut = timedOut;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.ServiceClientError = ServiceClientError;
|
|
17
|
+
function isAbortError(err) {
|
|
18
|
+
const name = err?.name;
|
|
19
|
+
return name === 'AbortError' || name === 'TimeoutError';
|
|
20
|
+
}
|
|
7
21
|
class ServiceClient {
|
|
8
22
|
constructor(options) {
|
|
9
23
|
this.gatewayUrl = (options?.gatewayUrl ?? process.env.CONVERGE_GATEWAY_URL ?? '').replace(/\/$/, '');
|
|
@@ -12,7 +26,7 @@ class ServiceClient {
|
|
|
12
26
|
this.retries = options?.retries ?? 2;
|
|
13
27
|
this.retryDelay = options?.retryDelayMs ?? 1000;
|
|
14
28
|
}
|
|
15
|
-
async call(serviceKey, method, path, body) {
|
|
29
|
+
async call(serviceKey, method, path, body, options = {}) {
|
|
16
30
|
if (!this.gatewayUrl) {
|
|
17
31
|
throw new Error('ServiceClient: CONVERGE_GATEWAY_URL nicht gesetzt');
|
|
18
32
|
}
|
|
@@ -29,44 +43,63 @@ class ServiceClient {
|
|
|
29
43
|
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
30
44
|
fetchOptions.body = JSON.stringify(body);
|
|
31
45
|
}
|
|
46
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
47
|
+
fetchOptions.signal = AbortSignal.timeout(options.timeoutMs);
|
|
48
|
+
}
|
|
49
|
+
const timedOut = () => new ServiceClientError(`ServiceClient: ${serviceKey} ${method} ${path} → Timeout nach ${options.timeoutMs} ms`, serviceKey, null, true);
|
|
32
50
|
for (let attempt = 1; attempt <= this.retries + 1; attempt++) {
|
|
51
|
+
let res;
|
|
33
52
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
53
|
+
res = await this.fetchImpl(url, fetchOptions);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (isAbortError(err) || fetchOptions.signal?.aborted)
|
|
57
|
+
throw timedOut();
|
|
58
|
+
if (attempt <= this.retries) {
|
|
59
|
+
log('warn', 'ServiceClient: Netzwerkfehler, Retry', {
|
|
60
|
+
serviceKey, method, path, err: String(err), attempt,
|
|
61
|
+
});
|
|
62
|
+
await new Promise((r) => setTimeout(r, this.retryDelay));
|
|
63
|
+
if (fetchOptions.signal?.aborted)
|
|
64
|
+
throw timedOut();
|
|
65
|
+
continue;
|
|
37
66
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
if (res.status === 204) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
let errorMessage = `ServiceClient: ${serviceKey} ${method} ${path} → ${res.status}`;
|
|
74
|
+
try {
|
|
75
|
+
const errBody = await res.json();
|
|
76
|
+
if (errBody.error)
|
|
77
|
+
errorMessage = errBody.error;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (isAbortError(err))
|
|
81
|
+
throw timedOut();
|
|
82
|
+
}
|
|
83
|
+
if (res.status >= 400 && res.status < 500) {
|
|
84
|
+
throw new ServiceClientError(errorMessage, serviceKey, res.status);
|
|
85
|
+
}
|
|
86
|
+
if (attempt <= this.retries) {
|
|
87
|
+
log('warn', 'ServiceClient: Retry nach Server-Fehler', {
|
|
88
|
+
serviceKey, method, path, status: res.status, attempt,
|
|
89
|
+
});
|
|
90
|
+
await new Promise((r) => setTimeout(r, this.retryDelay));
|
|
91
|
+
if (fetchOptions.signal?.aborted)
|
|
92
|
+
throw timedOut();
|
|
93
|
+
continue;
|
|
57
94
|
}
|
|
58
|
-
|
|
95
|
+
throw new ServiceClientError(errorMessage, serviceKey, res.status);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return await res.json();
|
|
59
99
|
}
|
|
60
100
|
catch (err) {
|
|
61
|
-
if (err
|
|
62
|
-
|
|
63
|
-
log('warn', 'ServiceClient: Netzwerkfehler, Retry', {
|
|
64
|
-
serviceKey, method, path, err: String(err), attempt,
|
|
65
|
-
});
|
|
66
|
-
await new Promise((r) => setTimeout(r, this.retryDelay));
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
101
|
+
if (isAbortError(err))
|
|
102
|
+
throw timedOut();
|
|
70
103
|
throw err;
|
|
71
104
|
}
|
|
72
105
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
export declare const PROVENANCE_ISSUER = "converge-gateway";
|
|
3
|
+
export declare const PROVENANCE_MAX_AGE_SEC = 60;
|
|
4
|
+
export declare const SERVICE_KEY_RE: RegExp;
|
|
5
|
+
export declare const PROVENANCE_REPLAY_CACHE_MAX = 20000;
|
|
6
|
+
export interface ProvenanceGuardOptions {
|
|
7
|
+
allowedCallers?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
export type ProvenanceResult = {
|
|
10
|
+
ok: true;
|
|
11
|
+
caller: string;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
reason: 'missing';
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
reason: 'invalid';
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: 'replayed';
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
reason: 'caller_not_allowed';
|
|
24
|
+
caller: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function getPlatformPublicKey(): string;
|
|
27
|
+
export declare function isTruthyEnv(value: string | undefined): boolean;
|
|
28
|
+
export declare function resetProvenanceReplayCache(max?: number): void;
|
|
29
|
+
export declare function provenanceReplayCacheSize(): number;
|
|
30
|
+
export declare function verifyServiceProvenance(req: Request, options?: ProvenanceGuardOptions): ProvenanceResult;
|
|
31
|
+
export declare function assertProvenanceOptions(options: ProvenanceGuardOptions): void;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PROVENANCE_REPLAY_CACHE_MAX = exports.SERVICE_KEY_RE = exports.PROVENANCE_MAX_AGE_SEC = exports.PROVENANCE_ISSUER = void 0;
|
|
7
|
+
exports.getPlatformPublicKey = getPlatformPublicKey;
|
|
8
|
+
exports.isTruthyEnv = isTruthyEnv;
|
|
9
|
+
exports.resetProvenanceReplayCache = resetProvenanceReplayCache;
|
|
10
|
+
exports.provenanceReplayCacheSize = provenanceReplayCacheSize;
|
|
11
|
+
exports.verifyServiceProvenance = verifyServiceProvenance;
|
|
12
|
+
exports.assertProvenanceOptions = assertProvenanceOptions;
|
|
13
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
14
|
+
exports.PROVENANCE_ISSUER = 'converge-gateway';
|
|
15
|
+
exports.PROVENANCE_MAX_AGE_SEC = 60;
|
|
16
|
+
const CLOCK_TOLERANCE_SEC = 5;
|
|
17
|
+
exports.SERVICE_KEY_RE = /^[a-z][a-z0-9_]{1,63}$/;
|
|
18
|
+
exports.PROVENANCE_REPLAY_CACHE_MAX = 20000;
|
|
19
|
+
let decodedKey = null;
|
|
20
|
+
function getPlatformPublicKey() {
|
|
21
|
+
const s = process.env.JWT_PUBLIC_KEY;
|
|
22
|
+
if (!s)
|
|
23
|
+
throw new Error('JWT_PUBLIC_KEY environment variable is required');
|
|
24
|
+
if (decodedKey?.b64 !== s)
|
|
25
|
+
decodedKey = { b64: s, pem: Buffer.from(s, 'base64').toString('utf8') };
|
|
26
|
+
return decodedKey.pem;
|
|
27
|
+
}
|
|
28
|
+
function isTruthyEnv(value) {
|
|
29
|
+
return !!value && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
const usedJtis = new Map();
|
|
32
|
+
const SWEEP_INTERVAL_MS = 5000;
|
|
33
|
+
let lastSweep = 0;
|
|
34
|
+
let capacityWarned = false;
|
|
35
|
+
let replayCacheMax = exports.PROVENANCE_REPLAY_CACHE_MAX;
|
|
36
|
+
function consumeJti(jti, expSec) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (now - lastSweep >= SWEEP_INTERVAL_MS) {
|
|
39
|
+
lastSweep = now;
|
|
40
|
+
for (const [k, exp] of usedJtis) {
|
|
41
|
+
if (exp >= now)
|
|
42
|
+
break;
|
|
43
|
+
usedJtis.delete(k);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (usedJtis.has(jti))
|
|
47
|
+
return false;
|
|
48
|
+
while (usedJtis.size >= replayCacheMax) {
|
|
49
|
+
const oldest = usedJtis.keys().next().value;
|
|
50
|
+
usedJtis.delete(oldest);
|
|
51
|
+
if (!capacityWarned) {
|
|
52
|
+
capacityWarned = true;
|
|
53
|
+
console.warn(JSON.stringify({
|
|
54
|
+
level: 'warn',
|
|
55
|
+
msg: 'Provenance-Replay-Liste voll – älteste Einträge fallen heraus',
|
|
56
|
+
max: replayCacheMax,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
usedJtis.set(jti, (expSec + CLOCK_TOLERANCE_SEC) * 1000);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function resetProvenanceReplayCache(max = exports.PROVENANCE_REPLAY_CACHE_MAX) {
|
|
64
|
+
usedJtis.clear();
|
|
65
|
+
lastSweep = 0;
|
|
66
|
+
capacityWarned = false;
|
|
67
|
+
replayCacheMax = max;
|
|
68
|
+
}
|
|
69
|
+
function provenanceReplayCacheSize() {
|
|
70
|
+
return usedJtis.size;
|
|
71
|
+
}
|
|
72
|
+
const verifiedInRequest = new WeakMap();
|
|
73
|
+
function readToken(req) {
|
|
74
|
+
const raw = req.headers?.['x-service-token'];
|
|
75
|
+
const token = Array.isArray(raw) ? raw[0] : raw;
|
|
76
|
+
return typeof token === 'string' && token ? token : undefined;
|
|
77
|
+
}
|
|
78
|
+
function verifyToken(req) {
|
|
79
|
+
const token = readToken(req);
|
|
80
|
+
if (!token)
|
|
81
|
+
return { ok: false, reason: 'missing' };
|
|
82
|
+
const memo = verifiedInRequest.get(req);
|
|
83
|
+
if (memo && memo.token === token)
|
|
84
|
+
return { ok: true, caller: memo.caller };
|
|
85
|
+
const audience = process.env.SERVICE_KEY;
|
|
86
|
+
if (!audience)
|
|
87
|
+
return { ok: false, reason: 'invalid' };
|
|
88
|
+
let payload;
|
|
89
|
+
try {
|
|
90
|
+
payload = jsonwebtoken_1.default.verify(token, getPlatformPublicKey(), {
|
|
91
|
+
algorithms: ['RS256'],
|
|
92
|
+
issuer: exports.PROVENANCE_ISSUER,
|
|
93
|
+
audience,
|
|
94
|
+
maxAge: exports.PROVENANCE_MAX_AGE_SEC,
|
|
95
|
+
clockTolerance: CLOCK_TOLERANCE_SEC,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return { ok: false, reason: 'invalid' };
|
|
100
|
+
}
|
|
101
|
+
const caller = payload.sub;
|
|
102
|
+
if (typeof caller !== 'string' || !exports.SERVICE_KEY_RE.test(caller))
|
|
103
|
+
return { ok: false, reason: 'invalid' };
|
|
104
|
+
if (payload.jti !== undefined) {
|
|
105
|
+
if (typeof payload.jti !== 'string' || !payload.jti || typeof payload.exp !== 'number') {
|
|
106
|
+
return { ok: false, reason: 'invalid' };
|
|
107
|
+
}
|
|
108
|
+
if (!consumeJti(payload.jti, payload.exp))
|
|
109
|
+
return { ok: false, reason: 'replayed' };
|
|
110
|
+
}
|
|
111
|
+
verifiedInRequest.set(req, { token, caller });
|
|
112
|
+
return { ok: true, caller };
|
|
113
|
+
}
|
|
114
|
+
function verifyServiceProvenance(req, options = {}) {
|
|
115
|
+
const result = verifyToken(req);
|
|
116
|
+
if (!result.ok)
|
|
117
|
+
return result;
|
|
118
|
+
if (options.allowedCallers && !options.allowedCallers.includes(result.caller)) {
|
|
119
|
+
delete req.callerService;
|
|
120
|
+
return { ok: false, reason: 'caller_not_allowed', caller: result.caller };
|
|
121
|
+
}
|
|
122
|
+
req.callerService = result.caller;
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
function assertProvenanceOptions(options) {
|
|
126
|
+
const callers = options.allowedCallers;
|
|
127
|
+
if (callers === undefined)
|
|
128
|
+
return;
|
|
129
|
+
if (!Array.isArray(callers) || callers.length === 0) {
|
|
130
|
+
throw new Error('allowedCallers: mindestens ein Service-Key angeben (leere Liste sperrt jeden Aufrufer)');
|
|
131
|
+
}
|
|
132
|
+
for (const c of callers) {
|
|
133
|
+
if (typeof c !== 'string' || !exports.SERVICE_KEY_RE.test(c)) {
|
|
134
|
+
throw new Error(`allowedCallers: ungültiger Service-Key ${JSON.stringify(c)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
package/frontend/index.d.ts
CHANGED
package/frontend/index.js
CHANGED
package/frontend/ipc.js
CHANGED
|
@@ -15,7 +15,9 @@ export function getParentOrigin() {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
export function isFromPlatformParent(event) {
|
|
18
|
-
|
|
18
|
+
if (event.source !== window.parent)
|
|
19
|
+
return false;
|
|
20
|
+
return event.origin === window.location.origin;
|
|
19
21
|
}
|
|
20
22
|
export function sendAtStart() {
|
|
21
23
|
if (!isEmbedded())
|
|
@@ -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,11 @@
|
|
|
1
|
+
export const PLATFORM_ADMIN_PERMISSION = 'efa-all';
|
|
2
|
+
export const PLATFORM_ADMIN_PERMISSIONS = Object.freeze([
|
|
3
|
+
PLATFORM_ADMIN_PERMISSION,
|
|
4
|
+
'converge-admin',
|
|
5
|
+
]);
|
|
6
|
+
export function isPlatformAdminPermission(key) {
|
|
7
|
+
return PLATFORM_ADMIN_PERMISSIONS.includes(key);
|
|
8
|
+
}
|
|
9
|
+
export function isPlatformAdmin(permissionKeys) {
|
|
10
|
+
return !!permissionKeys && permissionKeys.some(isPlatformAdminPermission);
|
|
11
|
+
}
|
package/frontend/ui/DataTable.js
CHANGED
|
@@ -433,7 +433,7 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
433
433
|
hasColumns: visibleColumns.some((c) => c.exportable !== false),
|
|
434
434
|
run: runExport,
|
|
435
435
|
} : undefined, columns: columns, prefs: prefs, setColumnVisible: setColumnVisible, moveColumn: moveColumn, reset: reset, groupBy: groupBy, addGroupBy: addGroupBy, removeGroupBy: removeGroupBy, moveGroupBy: moveGroupBy, clearGroupBy: clearGroupBy }));
|
|
436
|
-
return (_jsxs("div", { ref: containerRef, className: "border border-[var(--color-border)] overflow-x-auto", style: { '--dt-cols': gridTemplate }, children: [selection && selection.selected.size > 0 && (_jsxs("div", { className: "flex items-center gap-2 px-3 py-2 text-sm border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [_jsxs("span", { children: [selection.selected.size, " ausgew\u00E4hlt"] }), _jsx("button", { type: "button", className: "text-xs underline text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]", onClick: () => selection.onChange(new Set()), children: "Auswahl
|
|
436
|
+
return (_jsxs("div", { ref: containerRef, className: "border border-[var(--color-border)] overflow-x-auto", style: { '--dt-cols': gridTemplate }, children: [selection && selection.selected.size > 0 && (_jsxs("div", { className: "flex items-center gap-2 px-3 py-2 text-sm border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [_jsxs("span", { children: [selection.selected.size, " ausgew\u00E4hlt"] }), _jsx("button", { type: "button", className: "text-xs underline text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]", onClick: () => selection.onChange(new Set()), children: "Auswahl aufheben" }), _jsx("div", { className: "flex-1" }), selection.bulkActions] })), isMobile ? (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [selection && (_jsxs("label", { className: "flex items-center gap-2 py-2 pr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" }), _jsx("span", { className: "text-[var(--color-text-muted)]", children: "Alle" })] })), _jsx("div", { className: "flex-1" }), gearButton] })) : (_jsxs("div", { className: "grid items-center min-w-min px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { gridTemplateColumns: 'var(--dt-cols)', background: 'var(--color-surface-raised)' }, children: [selection && (_jsx("input", { type: "checkbox", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" })), visibleColumns.map((col) => {
|
|
437
437
|
const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
|
|
438
438
|
const filterValue = prefs.filters[col.id];
|
|
439
439
|
const isFiltered = filterValue != null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@efa-one/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "Offizielles SDK für efa-one-Apps: Auth/Session, Service-Discovery, Gateway-Client, Audit/Reporting/Mail/Notifications, Permission- und Capability-Registrierung sowie das Frontend-IPC-/i18n-Toolkit.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"@radix-ui/react-tooltip": ">=1",
|
|
59
59
|
"express": ">=4",
|
|
60
60
|
"i18next": ">=23",
|
|
61
|
-
"i18next-http-backend": ">=2",
|
|
61
|
+
"i18next-http-backend": ">=4.0.2",
|
|
62
62
|
"jsonwebtoken": ">=9",
|
|
63
63
|
"lucide-react": ">=0.400.0",
|
|
64
64
|
"pg": ">=8",
|
|
@@ -111,12 +111,12 @@
|
|
|
111
111
|
"@radix-ui/react-tooltip": "^1.1.6",
|
|
112
112
|
"@types/express": "^4.17.21",
|
|
113
113
|
"@types/jsonwebtoken": "^9.0.6",
|
|
114
|
-
"@types/node": "^20.
|
|
114
|
+
"@types/node": "^22.20.5",
|
|
115
115
|
"@types/pg": "^8.11.0",
|
|
116
116
|
"@types/react": "^18.2.46",
|
|
117
117
|
"@types/react-dom": "^18.2.0",
|
|
118
118
|
"@types/supertest": "^7.2.0",
|
|
119
|
-
"@vitest/coverage-v8": "^
|
|
119
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
120
120
|
"cookie-parser": "^1.4.6",
|
|
121
121
|
"eslint": "^10.9.1",
|
|
122
122
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"supertest": "^7.2.2",
|
|
133
133
|
"typescript": "6.0.3",
|
|
134
134
|
"typescript-eslint": "^8.67.0",
|
|
135
|
-
"vitest": "^
|
|
135
|
+
"vitest": "^5.0.5"
|
|
136
136
|
},
|
|
137
137
|
"engines": {
|
|
138
138
|
"node": ">=20"
|