@uns-kit/api 3.0.40 → 3.0.42
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 +8 -0
- package/dist/app.js +2 -0
- package/dist/operator-api-authority.d.ts +26 -0
- package/dist/operator-api-authority.js +128 -0
- package/dist/operator-jwt.d.ts +12 -0
- package/dist/operator-jwt.js +89 -0
- package/dist/service-api-request-body.d.ts +6 -0
- package/dist/service-api-request-body.js +20 -0
- package/dist/uns-api-proxy.d.ts +4 -0
- package/dist/uns-api-proxy.js +88 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,6 +14,14 @@ For the MQTT topic registry published by the API plugin, see `../../docs/uns-top
|
|
|
14
14
|
| [`@uns-kit/cron`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence. |
|
|
15
15
|
| [`@uns-kit/cli`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cli) | CLI for scaffolding new UNS applications. |
|
|
16
16
|
|
|
17
|
+
## Declared operator operations (local source candidate)
|
|
18
|
+
|
|
19
|
+
See the shared [operator API contract and example](../../docs/operator-api.md).
|
|
20
|
+
An application explicitly declares its read/command operations; the SDK enforces
|
|
21
|
+
current authority and exact launch/body binding. Application business checks and
|
|
22
|
+
durable command receipts remain application-owned. Legacy endpoints gain no
|
|
23
|
+
operator grant automatically.
|
|
24
|
+
|
|
17
25
|
## Installation
|
|
18
26
|
|
|
19
27
|
```bash
|
package/dist/app.js
CHANGED
|
@@ -9,6 +9,7 @@ import { basePath } from "@uns-kit/core/base-path.js";
|
|
|
9
9
|
import logger from "@uns-kit/core/logger.js";
|
|
10
10
|
import os from 'os';
|
|
11
11
|
import { logRequestContext } from "./request-log.js";
|
|
12
|
+
import { OPERATOR_OUTCOME_HEADER } from "@uns-kit/core/runtime/operator-api.js";
|
|
12
13
|
const normalizeBasePrefix = (value) => {
|
|
13
14
|
if (!value)
|
|
14
15
|
return "";
|
|
@@ -93,6 +94,7 @@ export default class App {
|
|
|
93
94
|
return next();
|
|
94
95
|
if (!this.ownershipGate()) {
|
|
95
96
|
res.setHeader("Retry-After", "1");
|
|
97
|
+
res.setHeader(OPERATOR_OUTCOME_HEADER, "not-applied");
|
|
96
98
|
res.status(503).json({ error: "RUNTIME_PASSIVE", message: "Runtime ownership is being transferred. Retry the request." });
|
|
97
99
|
return;
|
|
98
100
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type OperatorDeployment, type OperatorOperation, type OperatorOperationDefinition } from "@uns-kit/core/runtime/operator-api.js";
|
|
2
|
+
/** Use only when the application can prove its command had no external effect.
|
|
3
|
+
* A database/transport failure after a possible commit must stay uncertain. */
|
|
4
|
+
export declare function markOperatorNotApplied(response: {
|
|
5
|
+
setHeader(name: string, value: string): unknown;
|
|
6
|
+
}): void;
|
|
7
|
+
export declare function operatorEndpointDeclarations(definitions: OperatorOperationDefinition[] | undefined, method: string, path: string, authenticationConfigured: boolean): OperatorOperation[];
|
|
8
|
+
/** Route callbacks pass claims only after the existing JWT/JWKS/path checks.
|
|
9
|
+
* Delegated authority is bound again here, inside the application process. */
|
|
10
|
+
export declare class OperatorApiAuthority {
|
|
11
|
+
private readonly options;
|
|
12
|
+
private pending;
|
|
13
|
+
constructor(options: {
|
|
14
|
+
deployment: () => OperatorDeployment;
|
|
15
|
+
statusUrl?: string;
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
});
|
|
18
|
+
authorize(input: {
|
|
19
|
+
declarations: OperatorOperation[];
|
|
20
|
+
claims: Record<string, unknown>;
|
|
21
|
+
authorization: string;
|
|
22
|
+
payload: unknown;
|
|
23
|
+
query?: Record<string, unknown>;
|
|
24
|
+
}): Promise<OperatorOperation | undefined>;
|
|
25
|
+
private currentAuthorization;
|
|
26
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { OPERATOR_DELEGATION_CLAIM, OPERATOR_OUTCOME_HEADER, OperatorApiError, assertOperatorDelegation, assertOperatorPayload, operatorDeploymentSchema, operatorOperation, } from "@uns-kit/core/runtime/operator-api.js";
|
|
2
|
+
/** Use only when the application can prove its command had no external effect.
|
|
3
|
+
* A database/transport failure after a possible commit must stay uncertain. */
|
|
4
|
+
export function markOperatorNotApplied(response) {
|
|
5
|
+
response.setHeader(OPERATOR_OUTCOME_HEADER, "not-applied");
|
|
6
|
+
}
|
|
7
|
+
export function operatorEndpointDeclarations(definitions, method, path, authenticationConfigured) {
|
|
8
|
+
if (!definitions?.length)
|
|
9
|
+
return [];
|
|
10
|
+
if (!authenticationConfigured)
|
|
11
|
+
throw new OperatorApiError("operator_endpoint_requires_authentication", 503);
|
|
12
|
+
if (definitions.length > 16)
|
|
13
|
+
throw new OperatorApiError("operator_endpoint_declaration_limit");
|
|
14
|
+
const parsed = definitions.map((d) => operatorOperation(d, method, path));
|
|
15
|
+
if (new Set(parsed.map((d) => d.id)).size !== parsed.length)
|
|
16
|
+
throw new OperatorApiError("operator_endpoint_duplicate_id");
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
/** Route callbacks pass claims only after the existing JWT/JWKS/path checks.
|
|
20
|
+
* Delegated authority is bound again here, inside the application process. */
|
|
21
|
+
export class OperatorApiAuthority {
|
|
22
|
+
options;
|
|
23
|
+
pending = 0;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
}
|
|
27
|
+
async authorize(input) {
|
|
28
|
+
const delegated = input.claims[OPERATOR_DELEGATION_CLAIM] !== undefined;
|
|
29
|
+
if (!input.declarations.length) {
|
|
30
|
+
if (delegated)
|
|
31
|
+
throw new OperatorApiError("operator_endpoint_not_declared", 403);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const roles = [input.claims["role"], ...(Array.isArray(input.claims["roles"]) ? input.claims["roles"] : [])];
|
|
35
|
+
if (!roles.some((r) => r === "admin" || r === "operator") || typeof input.claims["sub"] !== "string" || !input.claims["sub"])
|
|
36
|
+
throw new OperatorApiError("operator_role_required", 403);
|
|
37
|
+
if (input.query && Object.keys(input.query).length)
|
|
38
|
+
throw new OperatorApiError("operator_query_not_supported");
|
|
39
|
+
const matched = input.declarations.filter((d) => {
|
|
40
|
+
try {
|
|
41
|
+
assertOperatorPayload(d, input.payload);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
if (matched.length !== 1)
|
|
49
|
+
throw new OperatorApiError(matched.length ? "operator_request_ambiguous" : "operator_request_invalid");
|
|
50
|
+
const operation = matched[0];
|
|
51
|
+
if (delegated) {
|
|
52
|
+
let deployment;
|
|
53
|
+
try {
|
|
54
|
+
deployment = operatorDeploymentSchema.parse(this.options.deployment());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new OperatorApiError("operator_deployment_unavailable", 503);
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
assertOperatorDelegation(input.claims, operation, input.payload, deployment);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new OperatorApiError("operator_delegation_rejected", 403);
|
|
64
|
+
}
|
|
65
|
+
if (!this.options.statusUrl)
|
|
66
|
+
throw new OperatorApiError("operator_authorization_status_required", 503);
|
|
67
|
+
}
|
|
68
|
+
// A standalone, locally verified native operator token can remain offline.
|
|
69
|
+
// Delegated machine authority always requires a fresh parent-status check.
|
|
70
|
+
if (this.options.statusUrl)
|
|
71
|
+
await this.currentAuthorization(input.authorization);
|
|
72
|
+
return operation;
|
|
73
|
+
}
|
|
74
|
+
async currentAuthorization(authorization) {
|
|
75
|
+
if (this.pending >= 4)
|
|
76
|
+
throw new OperatorApiError("operator_authorization_capacity", 503);
|
|
77
|
+
this.pending++;
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), 1000);
|
|
80
|
+
try {
|
|
81
|
+
const response = await (this.options.fetch ?? fetch)(this.options.statusUrl, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { authorization },
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
redirect: "error",
|
|
86
|
+
});
|
|
87
|
+
if (response.status === 401 || response.status === 403)
|
|
88
|
+
throw new OperatorApiError("operator_authorization_inactive", 403);
|
|
89
|
+
if (!response.ok || Number(response.headers.get("content-length") ?? 0) > 4096)
|
|
90
|
+
throw new OperatorApiError("operator_authorization_unavailable", 503);
|
|
91
|
+
if (!response.body)
|
|
92
|
+
throw new OperatorApiError("operator_authorization_unavailable", 503);
|
|
93
|
+
const reader = response.body.getReader();
|
|
94
|
+
let length = 0, body = "";
|
|
95
|
+
try {
|
|
96
|
+
const decoder = new TextDecoder();
|
|
97
|
+
while (true) {
|
|
98
|
+
const part = await reader.read();
|
|
99
|
+
if (part.done)
|
|
100
|
+
break;
|
|
101
|
+
length += part.value.byteLength;
|
|
102
|
+
if (length > 4096) {
|
|
103
|
+
await reader.cancel();
|
|
104
|
+
throw new OperatorApiError("operator_authorization_unavailable", 503);
|
|
105
|
+
}
|
|
106
|
+
body += decoder.decode(part.value, { stream: true });
|
|
107
|
+
}
|
|
108
|
+
body += decoder.decode();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
reader.releaseLock();
|
|
112
|
+
}
|
|
113
|
+
const status = JSON.parse(body);
|
|
114
|
+
if (status.active !== true || status.protocolVersion !== 1)
|
|
115
|
+
throw new OperatorApiError("operator_authorization_inactive", 403);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof OperatorApiError)
|
|
119
|
+
throw error;
|
|
120
|
+
throw new OperatorApiError("operator_authorization_unavailable", 503);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
controller.abort();
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
this.pending--;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Purpose-specific JWKS uses existing approved service public keys. PS256/type/
|
|
2
|
+
* audience keep these tokens out of the normal RS256 API/GraphQL credential path. */
|
|
3
|
+
export declare class OperatorJwtVerifier {
|
|
4
|
+
private readonly url?;
|
|
5
|
+
private readonly fetcher;
|
|
6
|
+
private keys;
|
|
7
|
+
private expires;
|
|
8
|
+
private pending;
|
|
9
|
+
constructor(url?: string, fetcher?: typeof fetch);
|
|
10
|
+
verify(token: string): Promise<Record<string, unknown>>;
|
|
11
|
+
private load;
|
|
12
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { OPERATOR_JWT_ALGORITHM, OPERATOR_JWT_AUDIENCE, OPERATOR_JWT_TYPE, OperatorApiError } from "@uns-kit/core/runtime/operator-api.js";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import { createPublicKey } from "node:crypto";
|
|
4
|
+
/** Purpose-specific JWKS uses existing approved service public keys. PS256/type/
|
|
5
|
+
* audience keep these tokens out of the normal RS256 API/GraphQL credential path. */
|
|
6
|
+
export class OperatorJwtVerifier {
|
|
7
|
+
url;
|
|
8
|
+
fetcher;
|
|
9
|
+
keys = [];
|
|
10
|
+
expires = 0;
|
|
11
|
+
pending;
|
|
12
|
+
constructor(url, fetcher = fetch) {
|
|
13
|
+
this.url = url;
|
|
14
|
+
this.fetcher = fetcher;
|
|
15
|
+
}
|
|
16
|
+
async verify(token) {
|
|
17
|
+
const decoded = jwt.decode(token, { complete: true });
|
|
18
|
+
if (!decoded ||
|
|
19
|
+
decoded.header.alg !== OPERATOR_JWT_ALGORITHM ||
|
|
20
|
+
decoded.header.typ !== OPERATOR_JWT_TYPE ||
|
|
21
|
+
typeof decoded.header.kid !== "string" ||
|
|
22
|
+
decoded.header.kid.length > 256 ||
|
|
23
|
+
!this.url)
|
|
24
|
+
throw new OperatorApiError("operator_jwt_invalid", 401);
|
|
25
|
+
if (Date.now() >= this.expires || !this.keys.some((k) => k["kid"] === decoded.header.kid))
|
|
26
|
+
await this.load();
|
|
27
|
+
const keys = this.keys.filter((k) => k["kid"] === decoded.header.kid && k["kty"] === "RSA" && k["alg"] === OPERATOR_JWT_ALGORITHM && !k["d"]);
|
|
28
|
+
if (keys.length !== 1)
|
|
29
|
+
throw new OperatorApiError("operator_jwt_key_unavailable", 401);
|
|
30
|
+
try {
|
|
31
|
+
const key = createPublicKey({ key: keys[0], format: "jwk" });
|
|
32
|
+
const claims = jwt.verify(token, key, { algorithms: [OPERATOR_JWT_ALGORITHM], audience: OPERATOR_JWT_AUDIENCE, maxAge: "30s" });
|
|
33
|
+
if (!claims || typeof claims !== "object")
|
|
34
|
+
throw new Error("invalid");
|
|
35
|
+
return claims;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new OperatorApiError("operator_jwt_invalid", 401);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async load() {
|
|
42
|
+
this.pending ??= (async () => {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timer = setTimeout(() => controller.abort(), 1000);
|
|
45
|
+
try {
|
|
46
|
+
const response = await this.fetcher(this.url, { redirect: "error", signal: controller.signal });
|
|
47
|
+
if (!response.ok || !response.body || Number(response.headers.get("content-length") ?? 0) > 131072)
|
|
48
|
+
throw new Error("unavailable");
|
|
49
|
+
const reader = response.body.getReader();
|
|
50
|
+
const decoder = new TextDecoder();
|
|
51
|
+
let size = 0, body = "";
|
|
52
|
+
try {
|
|
53
|
+
while (true) {
|
|
54
|
+
const part = await reader.read();
|
|
55
|
+
if (part.done)
|
|
56
|
+
break;
|
|
57
|
+
size += part.value.byteLength;
|
|
58
|
+
if (size > 131072) {
|
|
59
|
+
await reader.cancel();
|
|
60
|
+
throw new Error("oversize");
|
|
61
|
+
}
|
|
62
|
+
body += decoder.decode(part.value, { stream: true });
|
|
63
|
+
}
|
|
64
|
+
body += decoder.decode();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
reader.releaseLock();
|
|
68
|
+
}
|
|
69
|
+
const document = JSON.parse(body);
|
|
70
|
+
if (!Array.isArray(document.keys) || document.keys.length > 64)
|
|
71
|
+
throw new Error("invalid");
|
|
72
|
+
this.keys = document.keys;
|
|
73
|
+
this.expires = Date.now() + 15000;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
this.keys = [];
|
|
77
|
+
this.expires = 0;
|
|
78
|
+
throw new OperatorApiError("operator_jwks_unavailable", 503);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
controller.abort();
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
})().finally(() => {
|
|
85
|
+
this.pending = undefined;
|
|
86
|
+
});
|
|
87
|
+
return this.pending;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IPostEndpointOptions } from "@uns-kit/core/uns/uns-interfaces.js";
|
|
2
|
+
/** Service APIs and OpenAPI must agree when a caller overrides requestBody on
|
|
3
|
+
* api.post/put/patch/delete after buildServiceApiInteractions(). A body editor
|
|
4
|
+
* needs the body descriptor even when no flattened catalog schema was supplied.
|
|
5
|
+
* The full JSON Schema stays authoritative in the existing OpenAPI document. */
|
|
6
|
+
export declare function withServiceApiRequestBody(serviceApi: Record<string, unknown> | null | undefined, requestBody: IPostEndpointOptions["requestBody"]): Record<string, unknown> | null;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Service APIs and OpenAPI must agree when a caller overrides requestBody on
|
|
2
|
+
* api.post/put/patch/delete after buildServiceApiInteractions(). A body editor
|
|
3
|
+
* needs the body descriptor even when no flattened catalog schema was supplied.
|
|
4
|
+
* The full JSON Schema stays authoritative in the existing OpenAPI document. */
|
|
5
|
+
export function withServiceApiRequestBody(serviceApi, requestBody) {
|
|
6
|
+
if (!serviceApi || !requestBody)
|
|
7
|
+
return serviceApi ?? null;
|
|
8
|
+
const raw = serviceApi["requestBody"];
|
|
9
|
+
const previous = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
10
|
+
return {
|
|
11
|
+
...serviceApi,
|
|
12
|
+
requestBody: {
|
|
13
|
+
...previous,
|
|
14
|
+
required: requestBody.required ?? true,
|
|
15
|
+
description: requestBody.description ?? previous["description"] ?? null,
|
|
16
|
+
contentType: "application/json",
|
|
17
|
+
schemas: Array.isArray(previous["schemas"]) ? previous["schemas"] : [],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/uns-api-proxy.d.ts
CHANGED
|
@@ -21,12 +21,15 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
21
21
|
private statusInterval;
|
|
22
22
|
private readonly statusIntervalMs;
|
|
23
23
|
private readonly dataCatalogOffers;
|
|
24
|
+
private readonly operatorAuthority;
|
|
25
|
+
private readonly operatorJwt;
|
|
24
26
|
constructor(processName: string, instanceName: string, options: IApiProxyOptions);
|
|
25
27
|
setOwnershipGate(gate: () => boolean): void;
|
|
26
28
|
drainRequests(): Promise<void>;
|
|
27
29
|
/** Track an explicitly read-only handler through completion, including client disconnects. */
|
|
28
30
|
runReadOnlyRequest<T>(res: Response, operation: () => T | Promise<T>): Promise<T>;
|
|
29
31
|
getProcessName(): string;
|
|
32
|
+
private operatorAuthorized;
|
|
30
33
|
private getPublishedApiHost;
|
|
31
34
|
/**
|
|
32
35
|
* Unregister endpoint
|
|
@@ -69,6 +72,7 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
69
72
|
private emitStatusMetrics;
|
|
70
73
|
private registerHealthEndpoint;
|
|
71
74
|
private getApiEventName;
|
|
75
|
+
private verifyRequestToken;
|
|
72
76
|
private extractBearerToken;
|
|
73
77
|
private getPublicKeyFromJwks;
|
|
74
78
|
private fetchJwksKeys;
|
package/dist/uns-api-proxy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withServiceApiRequestBody } from "./service-api-request-body.js";
|
|
1
2
|
import { readFileSync } from "fs";
|
|
2
3
|
import jwt from "jsonwebtoken";
|
|
3
4
|
import * as path from "path";
|
|
@@ -12,6 +13,9 @@ import { UnsTopicMatcher } from "@uns-kit/core/uns/uns-topic-matcher.js";
|
|
|
12
13
|
import { DataSizeMeasurements, PhysicalMeasurements } from "@uns-kit/core/uns/uns-measurements.js";
|
|
13
14
|
import { buildUnsRoutePath } from "@uns-kit/core/uns/uns-path.js";
|
|
14
15
|
import App from "./app.js";
|
|
16
|
+
import { OperatorApiAuthority, operatorEndpointDeclarations, markOperatorNotApplied } from "./operator-api-authority.js";
|
|
17
|
+
import { OPERATOR_API_EXTENSION, OPERATOR_AUTH_SCHEME, OPERATOR_DELEGATION_CLAIM, OperatorApiError } from "@uns-kit/core/runtime/operator-api.js";
|
|
18
|
+
import { OperatorJwtVerifier } from "./operator-jwt.js";
|
|
15
19
|
const packageJsonPath = path.join(basePath, "package.json");
|
|
16
20
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
17
21
|
const normalizeBasePrefix = (value) => {
|
|
@@ -61,9 +65,18 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
61
65
|
statusInterval = null;
|
|
62
66
|
statusIntervalMs = 10_000;
|
|
63
67
|
dataCatalogOffers = new Map();
|
|
68
|
+
operatorAuthority;
|
|
69
|
+
operatorJwt;
|
|
64
70
|
constructor(processName, instanceName, options) {
|
|
65
71
|
super();
|
|
66
72
|
this.options = options;
|
|
73
|
+
const controllerBase = process.env.UNS_CONTROLLER_PUBLIC_BASE;
|
|
74
|
+
const statusUrl = options.operatorAuthorizationStatusUrl ?? (controllerBase ? new URL("/api/auth/token-status", controllerBase).href : undefined);
|
|
75
|
+
this.operatorJwt = new OperatorJwtVerifier(options.operatorJwksUrl ?? (controllerBase ? new URL("/api/auth/operator-jwks.json", controllerBase).href : undefined));
|
|
76
|
+
this.operatorAuthority = new OperatorApiAuthority({ ...(statusUrl ? { statusUrl } : {}), deployment: () => ({
|
|
77
|
+
controllerName: process.env.UNS_ROUTE_CONTROLLER_NAME, rttNode: process.env.RTT_NODE,
|
|
78
|
+
version: process.env.version ?? packageJson.version, instanceId: process.env.RTT_INSTANCE_ID, launchId: process.env.UNS_ROUTE_LAUNCH_ID,
|
|
79
|
+
}) });
|
|
67
80
|
this.apiBasePrefix =
|
|
68
81
|
normalizeBasePrefix(options.apiBasePath ?? process.env.UNS_API_BASE_PATH) || "/api";
|
|
69
82
|
const rawSwaggerBase = normalizeBasePrefix(options.swaggerBasePath ?? process.env.UNS_SWAGGER_BASE_PATH) || this.apiBasePrefix;
|
|
@@ -101,6 +114,25 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
101
114
|
getProcessName() {
|
|
102
115
|
return this.processName;
|
|
103
116
|
}
|
|
117
|
+
async operatorAuthorized(req, res, decoded, declarations) {
|
|
118
|
+
try {
|
|
119
|
+
const delegatedScheme = String(req.headers.authorization ?? "").startsWith(OPERATOR_AUTH_SCHEME + " ");
|
|
120
|
+
if (delegatedScheme !== (decoded[OPERATOR_DELEGATION_CLAIM] !== undefined))
|
|
121
|
+
throw new OperatorApiError("operator_jwt_purpose_mismatch", 403);
|
|
122
|
+
const normalizedAuthorization = delegatedScheme ? "Bearer " + req.headers.authorization.slice(OPERATOR_AUTH_SCHEME.length + 1) : req.headers.authorization;
|
|
123
|
+
await this.operatorAuthority.authorize({ claims: decoded, declarations, payload: req.method === "GET" ? {} : req.body,
|
|
124
|
+
authorization: normalizedAuthorization, query: req.query });
|
|
125
|
+
// Existing application handlers inspect an already-verified bearer.
|
|
126
|
+
req.headers.authorization = normalizedAuthorization;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const status = error instanceof OperatorApiError ? error.status : 503;
|
|
131
|
+
markOperatorNotApplied(res);
|
|
132
|
+
res.status(status).json({ error: error instanceof OperatorApiError ? error.code : "operator_authorization_unavailable" });
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
104
136
|
getPublishedApiHost() {
|
|
105
137
|
const addressInfo = this.app.server.address();
|
|
106
138
|
const port = addressInfo && typeof addressInfo === "object" ? addressInfo.port : undefined;
|
|
@@ -166,6 +198,7 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
166
198
|
const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
|
|
167
199
|
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
168
200
|
const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
|
|
201
|
+
const operatorDeclarations = operatorEndpointDeclarations(options?.operatorOperations, "GET", fullPath, Boolean(this.options.jwks?.wellKnownJwksUrl || this.options.jwtSecret));
|
|
169
202
|
try {
|
|
170
203
|
this.registerApiEndpoint({
|
|
171
204
|
timestamp: time,
|
|
@@ -222,9 +255,7 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
222
255
|
const token = this.extractBearerToken(req, res);
|
|
223
256
|
if (!token)
|
|
224
257
|
return; // response already sent
|
|
225
|
-
const
|
|
226
|
-
const algorithms = this.options.jwks.algorithms || ["RS256"];
|
|
227
|
-
const decoded = jwt.verify(token, publicKey, { algorithms });
|
|
258
|
+
const decoded = await this.verifyRequestToken(req, token);
|
|
228
259
|
const accessRules = Array.isArray(decoded?.accessRules)
|
|
229
260
|
? decoded.accessRules
|
|
230
261
|
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
@@ -236,22 +267,26 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
236
267
|
if (!allowed) {
|
|
237
268
|
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
238
269
|
}
|
|
270
|
+
if (!await this.operatorAuthorized(req, res, decoded, operatorDeclarations))
|
|
271
|
+
return;
|
|
239
272
|
handler(req, res);
|
|
240
273
|
}
|
|
241
274
|
catch (err) {
|
|
275
|
+
if (err instanceof OperatorApiError) {
|
|
276
|
+
markOperatorNotApplied(res);
|
|
277
|
+
return res.status(err.status).json({ error: err.code });
|
|
278
|
+
}
|
|
242
279
|
return res.status(401).json({ error: "Invalid token" });
|
|
243
280
|
}
|
|
244
281
|
});
|
|
245
282
|
}
|
|
246
283
|
else if (this.options?.jwtSecret) {
|
|
247
|
-
this.app.router.get(fullPath, (req, res) => {
|
|
248
|
-
const
|
|
249
|
-
if (!
|
|
250
|
-
return
|
|
251
|
-
}
|
|
252
|
-
const token = authHeader.slice(7);
|
|
284
|
+
this.app.router.get(fullPath, async (req, res) => {
|
|
285
|
+
const token = this.extractBearerToken(req, res);
|
|
286
|
+
if (!token)
|
|
287
|
+
return;
|
|
253
288
|
try {
|
|
254
|
-
const decoded =
|
|
289
|
+
const decoded = await this.verifyRequestToken(req, token);
|
|
255
290
|
const accessRules = Array.isArray(decoded?.accessRules)
|
|
256
291
|
? decoded.accessRules
|
|
257
292
|
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
@@ -263,9 +298,15 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
263
298
|
if (!allowed) {
|
|
264
299
|
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
265
300
|
}
|
|
301
|
+
if (!await this.operatorAuthorized(req, res, decoded, operatorDeclarations))
|
|
302
|
+
return;
|
|
266
303
|
handler(req, res);
|
|
267
304
|
}
|
|
268
305
|
catch (err) {
|
|
306
|
+
if (err instanceof OperatorApiError) {
|
|
307
|
+
markOperatorNotApplied(res);
|
|
308
|
+
return res.status(err.status).json({ error: err.code });
|
|
309
|
+
}
|
|
269
310
|
return res.status(401).json({ error: "Invalid token" });
|
|
270
311
|
}
|
|
271
312
|
});
|
|
@@ -325,6 +366,9 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
325
366
|
},
|
|
326
367
|
};
|
|
327
368
|
}
|
|
369
|
+
if (operatorDeclarations.length && this.app.swaggerSpec?.paths?.[apiPath]?.get) {
|
|
370
|
+
this.app.swaggerSpec.paths[apiPath].get[OPERATOR_API_EXTENSION] = operatorDeclarations;
|
|
371
|
+
}
|
|
328
372
|
}
|
|
329
373
|
catch (error) {
|
|
330
374
|
logger.error(`${this.instanceNameWithSuffix} - Error publishing message to route ${fullPath}: ${error.message}`);
|
|
@@ -430,6 +474,7 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
430
474
|
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
431
475
|
const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
|
|
432
476
|
const methodKey = method.toLowerCase();
|
|
477
|
+
const operatorDeclarations = operatorEndpointDeclarations(options?.operatorOperations, method, fullPath, Boolean(this.options.jwks?.wellKnownJwksUrl || this.options.jwtSecret));
|
|
433
478
|
try {
|
|
434
479
|
this.registerApiEndpoint({
|
|
435
480
|
timestamp: time,
|
|
@@ -443,7 +488,7 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
443
488
|
apiMethod: method,
|
|
444
489
|
apiQueryParams: [],
|
|
445
490
|
apiDescription: options?.apiDescription,
|
|
446
|
-
serviceApi: options?.serviceApi
|
|
491
|
+
serviceApi: withServiceApiRequestBody(options?.serviceApi, options?.requestBody),
|
|
447
492
|
attributeType: UnsAttributeType.Api,
|
|
448
493
|
apiSwaggerEndpoint: swaggerPath,
|
|
449
494
|
asset,
|
|
@@ -460,9 +505,7 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
460
505
|
const token = this.extractBearerToken(req, res);
|
|
461
506
|
if (!token)
|
|
462
507
|
return;
|
|
463
|
-
const
|
|
464
|
-
const algorithms = this.options.jwks.algorithms || ["RS256"];
|
|
465
|
-
const decoded = jwt.verify(token, publicKey, { algorithms });
|
|
508
|
+
const decoded = await this.verifyRequestToken(req, token);
|
|
466
509
|
const accessRules = Array.isArray(decoded?.accessRules)
|
|
467
510
|
? decoded.accessRules
|
|
468
511
|
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
@@ -474,22 +517,26 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
474
517
|
if (!allowed) {
|
|
475
518
|
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
476
519
|
}
|
|
520
|
+
if (!await this.operatorAuthorized(req, res, decoded, operatorDeclarations))
|
|
521
|
+
return;
|
|
477
522
|
handler(req, res);
|
|
478
523
|
}
|
|
479
524
|
catch (_err) {
|
|
525
|
+
if (_err instanceof OperatorApiError) {
|
|
526
|
+
markOperatorNotApplied(res);
|
|
527
|
+
return res.status(_err.status).json({ error: _err.code });
|
|
528
|
+
}
|
|
480
529
|
return res.status(401).json({ error: "Invalid token" });
|
|
481
530
|
}
|
|
482
531
|
});
|
|
483
532
|
}
|
|
484
533
|
else if (this.options?.jwtSecret) {
|
|
485
|
-
routerMethod(fullPath, (req, res) => {
|
|
486
|
-
const
|
|
487
|
-
if (!
|
|
488
|
-
return
|
|
489
|
-
}
|
|
490
|
-
const token = authHeader.slice(7);
|
|
534
|
+
routerMethod(fullPath, async (req, res) => {
|
|
535
|
+
const token = this.extractBearerToken(req, res);
|
|
536
|
+
if (!token)
|
|
537
|
+
return;
|
|
491
538
|
try {
|
|
492
|
-
const decoded =
|
|
539
|
+
const decoded = await this.verifyRequestToken(req, token);
|
|
493
540
|
const accessRules = Array.isArray(decoded?.accessRules)
|
|
494
541
|
? decoded.accessRules
|
|
495
542
|
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
@@ -501,9 +548,15 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
501
548
|
if (!allowed) {
|
|
502
549
|
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
503
550
|
}
|
|
551
|
+
if (!await this.operatorAuthorized(req, res, decoded, operatorDeclarations))
|
|
552
|
+
return;
|
|
504
553
|
handler(req, res);
|
|
505
554
|
}
|
|
506
555
|
catch (_err) {
|
|
556
|
+
if (_err instanceof OperatorApiError) {
|
|
557
|
+
markOperatorNotApplied(res);
|
|
558
|
+
return res.status(_err.status).json({ error: _err.code });
|
|
559
|
+
}
|
|
507
560
|
return res.status(401).json({ error: "Invalid token" });
|
|
508
561
|
}
|
|
509
562
|
});
|
|
@@ -539,6 +592,9 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
539
592
|
},
|
|
540
593
|
};
|
|
541
594
|
}
|
|
595
|
+
if (operatorDeclarations.length && this.app.swaggerSpec?.paths?.[apiPath]?.[methodKey]) {
|
|
596
|
+
this.app.swaggerSpec.paths[apiPath][methodKey][OPERATOR_API_EXTENSION] = operatorDeclarations;
|
|
597
|
+
}
|
|
542
598
|
}
|
|
543
599
|
catch (error) {
|
|
544
600
|
logger.error(`${this.instanceNameWithSuffix} - Error registering ${method} route ${fullPath}: ${error.message}`);
|
|
@@ -664,13 +720,22 @@ export default class UnsApiProxy extends UnsProxy {
|
|
|
664
720
|
return "apiDeleteEvent";
|
|
665
721
|
}
|
|
666
722
|
}
|
|
723
|
+
async verifyRequestToken(req, token) {
|
|
724
|
+
if (String(req.headers.authorization ?? "").startsWith(OPERATOR_AUTH_SCHEME + " "))
|
|
725
|
+
return this.operatorJwt.verify(token);
|
|
726
|
+
if (this.options.jwks?.wellKnownJwksUrl) {
|
|
727
|
+
const key = await this.getPublicKeyFromJwks(token);
|
|
728
|
+
return jwt.verify(token, key, { algorithms: this.options.jwks.algorithms || ["RS256"] });
|
|
729
|
+
}
|
|
730
|
+
return jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
|
|
731
|
+
}
|
|
667
732
|
extractBearerToken(req, res) {
|
|
668
733
|
const authHeader = req.headers["authorization"];
|
|
669
|
-
if (
|
|
734
|
+
if (typeof authHeader !== "string" || !(authHeader.startsWith("Bearer ") || authHeader.startsWith(OPERATOR_AUTH_SCHEME + " "))) {
|
|
670
735
|
res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
671
736
|
return undefined;
|
|
672
737
|
}
|
|
673
|
-
return authHeader.slice(7);
|
|
738
|
+
return authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader.slice(OPERATOR_AUTH_SCHEME.length + 1);
|
|
674
739
|
}
|
|
675
740
|
async getPublicKeyFromJwks(token) {
|
|
676
741
|
// Decode header to get kid
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uns-kit/api",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.42",
|
|
4
4
|
"description": "Express-powered API gateway plugin for UnsProxyProcess with JWT/JWKS support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"multer": "^2.2.0",
|
|
38
38
|
"hyparquet-writer": "^0.16.5",
|
|
39
39
|
"uuid": "^14.0.1",
|
|
40
|
-
"@uns-kit/core": "3.0.
|
|
40
|
+
"@uns-kit/core": "3.0.42"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/cookie-parser": "^1.4.10",
|