@ondewo/s2t-client-typescript 7.4.1 → 7.5.1

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.
@@ -0,0 +1,422 @@
1
+ "use strict";
2
+ // Copyright 2021-2026 ONDEWO GmbH
3
+ //
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // you may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ //
8
+ // http://www.apache.org/licenses/LICENSE-2.0
9
+ //
10
+ // Unless required by applicable law or agreed to in writing, software
11
+ // distributed under the License is distributed on an "AS IS" BASIS,
12
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ // See the License for the specific language governing permissions and
14
+ // limitations under the License.
15
+ //
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
50
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
51
+ };
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.OfflineTokenProvider = exports.REDACTED = exports.INSECURE_AGENT_OPTIONS = exports.TokenError = void 0;
54
+ exports.createDefaultTokenFetch = createDefaultTokenFetch;
55
+ exports.login = login;
56
+ /**
57
+ * D18 headless-SDK auth helper (keycloak-migration-plan §7.8 + D18).
58
+ *
59
+ * One-time ROPC login (`grant_type=password`, `scope=offline_access`) against the PUBLIC SDK client
60
+ * `ondewo-nlu-cai-sdk-public` (no client_secret -- Q1), then a bounded background loop that refreshes
61
+ * the short-lived access token from the offline refresh token before it expires. The current access
62
+ * token is exposed for an `Authorization: Bearer <token>` gRPC metadata header. The refresh loop stops
63
+ * after `tokenExpirationInS` (if given) has elapsed since login.
64
+ *
65
+ * @packageDocumentation
66
+ */
67
+ /**
68
+ * Seconds of head-room subtracted from a token's `expires_in` so the refresh fires before the access
69
+ * token actually lapses (covers clock skew + the round-trip to Keycloak).
70
+ */
71
+ const REFRESH_SKEW_IN_S = 30;
72
+ /** Lower bound (seconds) for the scheduled refresh delay so a tiny/zero `expires_in` cannot spin a hot loop. */
73
+ const MIN_REFRESH_DELAY_IN_S = 1;
74
+ /** Error raised on any token-endpoint or token-shape failure. */
75
+ class TokenError extends Error {
76
+ /**
77
+ * Construct a `TokenError` with a fixed `name` of `"TokenError"`.
78
+ *
79
+ * @param message - Human-readable description of the token failure.
80
+ */
81
+ constructor(message) {
82
+ super(message);
83
+ this.name = 'TokenError';
84
+ }
85
+ }
86
+ exports.TokenError = TokenError;
87
+ /**
88
+ * Build the OIDC token endpoint URL for a realm, tolerating a trailing slash on `keycloakUrl` and an
89
+ * optional `/auth` relative path already baked into it.
90
+ *
91
+ * @param keycloakUrl - Base Keycloak URL (trailing slash tolerated).
92
+ * @param realm - Realm name; URL-encoded into the path.
93
+ * @returns The fully-qualified `.../realms/<realm>/protocol/openid-connect/token` endpoint URL.
94
+ */
95
+ function buildTokenEndpoint(keycloakUrl, realm) {
96
+ const base = keycloakUrl.replace(/\/+$/, '');
97
+ return `${base}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`;
98
+ }
99
+ /**
100
+ * POST an `application/x-www-form-urlencoded` body to the token endpoint and return the parsed JSON.
101
+ *
102
+ * @param tokenEndpoint - The OIDC token endpoint URL (see {@link buildTokenEndpoint}).
103
+ * @param params - Form fields to URL-encode into the request body (grant type, client id, credentials).
104
+ * @param fetchImpl - The injectable fetch used to perform the request.
105
+ * @returns A promise resolving to the parsed {@link KeycloakTokenResponse}.
106
+ * @throws {@link TokenError} On a non-2xx response, an unparseable body, or a missing `access_token`.
107
+ */
108
+ async function postTokenRequest(tokenEndpoint, params, fetchImpl) {
109
+ const body = new URLSearchParams(params).toString();
110
+ const response = await fetchImpl(tokenEndpoint, {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/x-www-form-urlencoded',
114
+ Accept: 'application/json'
115
+ },
116
+ body
117
+ });
118
+ const text = await response.text();
119
+ if (!response.ok) {
120
+ throw new TokenError(`Keycloak token endpoint returned HTTP ${response.status}: ${text}`);
121
+ }
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(text);
125
+ }
126
+ catch {
127
+ throw new TokenError(`Keycloak token endpoint returned a non-JSON body: ${text}`);
128
+ }
129
+ if (typeof parsed.access_token !== 'string' || parsed.access_token.length === 0) {
130
+ throw new TokenError('Keycloak token response did not contain an access_token');
131
+ }
132
+ return parsed;
133
+ }
134
+ /**
135
+ * The undici `Agent` options that switch TLS certificate verification OFF for the token request.
136
+ * Exported so the security-relevant `rejectUnauthorized: false` literal is pinned by a test rather than
137
+ * living as a bare literal that could be flipped without any test noticing.
138
+ */
139
+ exports.INSECURE_AGENT_OPTIONS = {
140
+ connect: { rejectUnauthorized: false }
141
+ };
142
+ /** Cached insecure undici dispatcher (built once, reused) so `verifySsl:false` costs one Agent. */
143
+ let insecureNodeDispatcher;
144
+ /**
145
+ * Lazily build a cached undici `Agent` that skips TLS certificate verification -- the Node analog of
146
+ * Python's `requests.post(..., verify=False)`. Node-guarded and loaded via a dynamic `import("undici")`
147
+ * so a browser bundle never pulls in `undici` and the flag stays a hard no-op outside Node.
148
+ *
149
+ * @returns The insecure dispatcher under Node, or `undefined` in a browser (TLS is owned by the browser).
150
+ */
151
+ async function getInsecureNodeDispatcher() {
152
+ const nodeProcess = globalThis.process;
153
+ /* c8 ignore next 3 -- browser guard: not exercised under the Node test runner */
154
+ if (nodeProcess === undefined || nodeProcess.versions === undefined || nodeProcess.versions.node === undefined) {
155
+ return undefined;
156
+ }
157
+ if (insecureNodeDispatcher === undefined) {
158
+ const undiciModuleName = 'undici';
159
+ const undici = (await Promise.resolve(`${undiciModuleName}`).then(s => __importStar(require(s))));
160
+ insecureNodeDispatcher = new undici.Agent(exports.INSECURE_AGENT_OPTIONS);
161
+ }
162
+ return insecureNodeDispatcher;
163
+ }
164
+ /**
165
+ * Build the DEFAULT token transport (used only when no `fetchImpl` is injected). It delegates to the
166
+ * global WHATWG fetch, and -- when `verifySsl` is `false` under Node -- attaches an insecure undici
167
+ * dispatcher to the request init so the token POST skips certificate verification. With `verifySsl`
168
+ * `true` (the default) it is a plain pass-through to `globalThis.fetch`, i.e. unchanged behavior.
169
+ *
170
+ * @param verifySsl - Whether to verify the Keycloak TLS certificate; `false` opts into the insecure path.
171
+ * @returns A {@link TokenFetch} that resolves the global fetch at call time (honoring test overrides).
172
+ */
173
+ function createDefaultTokenFetch(verifySsl) {
174
+ return async (url, init) => {
175
+ const dispatcher = verifySsl ? undefined : await getInsecureNodeDispatcher();
176
+ const effectiveInit = dispatcher !== undefined ? { ...init, dispatcher } : init;
177
+ const globalFetch = globalThis.fetch;
178
+ return globalFetch(url, effectiveInit);
179
+ };
180
+ }
181
+ /** What {@link OfflineTokenProvider.toJSON} renders in place of a non-empty token. */
182
+ exports.REDACTED = '***REDACTED***';
183
+ /**
184
+ * Redact a token for logging: `null` and `''` render as is, anything else as {@link REDACTED}.
185
+ *
186
+ * @param token - The token to render.
187
+ * @returns The logging-safe rendering.
188
+ */
189
+ function redactToken(token) {
190
+ if (token === null || token === '') {
191
+ return token;
192
+ }
193
+ return exports.REDACTED;
194
+ }
195
+ /**
196
+ * A live access-token holder backed by a bounded auto-refresh loop. Obtain one from {@link login};
197
+ * read {@link getAuthorizationHeader} for the gRPC `Authorization` metadata and call {@link stop} when done.
198
+ */
199
+ class OfflineTokenProvider {
200
+ /**
201
+ * Initialize the provider from login options without performing any network call. Use {@link login}
202
+ * (which constructs and then {@link bootstrap}s) for the normal flow.
203
+ *
204
+ * @param options - The D18 headless-SDK offline-token login options.
205
+ */
206
+ constructor(options) {
207
+ this.tokenEndpoint = buildTokenEndpoint(options.keycloakUrl, options.realm);
208
+ this.clientId = options.clientId;
209
+ this.tokenExpirationInS = options.tokenExpirationInS;
210
+ // A custom fetchImpl always wins (the verifySsl flag is ignored for injected transports); only the
211
+ // DEFAULT transport honors verifySsl, and only under Node. Absent/undefined verifySsl => secure (true).
212
+ const verifySsl = options.keycloakVerifySsl !== false;
213
+ this.fetchImpl = options.fetchImpl !== undefined ? options.fetchImpl : createDefaultTokenFetch(verifySsl);
214
+ this.nowInMs = options.nowInMs !== undefined ? options.nowInMs : Date.now;
215
+ this.accessToken = null;
216
+ this.refreshToken = null;
217
+ this.timer = null;
218
+ this.stopped = false;
219
+ this.deadlineInMs = null;
220
+ this.onRefreshErrorHandler = null;
221
+ }
222
+ /**
223
+ * Perform the one-time ROPC login and arm the first refresh. Awaited by {@link login}.
224
+ *
225
+ * @param username - The 2FA-exempt technical-user email.
226
+ * @param password - The technical-user password.
227
+ * @returns A promise that resolves once the access token is stored and the first refresh is scheduled.
228
+ * @throws {@link TokenError} If the token endpoint fails, or when the response carries no usable
229
+ * `refresh_token` -- absent, not a string, or empty (i.e. the SDK client lacks directAccessGrants
230
+ * + the `offline_access` scope).
231
+ */
232
+ async bootstrap(username, password) {
233
+ const tokenResponse = await postTokenRequest(this.tokenEndpoint, {
234
+ grant_type: 'password',
235
+ client_id: this.clientId,
236
+ username,
237
+ password,
238
+ scope: 'offline_access'
239
+ }, this.fetchImpl);
240
+ this.accessToken = tokenResponse.access_token;
241
+ // An empty string is rejected exactly like a missing token, mirroring the rotation guard in
242
+ // refresh(): a blank offline token would otherwise bootstrap a provider whose every renewal POSTs
243
+ // `refresh_token=` and fails, turning a clear login error into a silent expiry ~5 minutes later.
244
+ this.refreshToken =
245
+ typeof tokenResponse.refresh_token === 'string' && tokenResponse.refresh_token.length > 0
246
+ ? tokenResponse.refresh_token
247
+ : null;
248
+ if (this.refreshToken === null) {
249
+ throw new TokenError('Keycloak token response did not contain a refresh_token; the SDK client must have ' +
250
+ 'directAccessGrants + the offline_access scope (ondewo-nlu-cai-sdk-public)');
251
+ }
252
+ if (this.tokenExpirationInS !== undefined) {
253
+ const expirationInMs = this.tokenExpirationInS * 1000;
254
+ this.deadlineInMs = this.nowInMs() + expirationInMs;
255
+ }
256
+ this.scheduleRefresh(tokenResponse.expires_in);
257
+ }
258
+ /**
259
+ * Exchange the offline refresh token for a fresh access token and re-arm the next refresh. No-ops
260
+ * once {@link stop} has been called or the bounded deadline has elapsed.
261
+ *
262
+ * @returns A promise that resolves once the refreshed token is stored and the next refresh is armed.
263
+ * @throws {@link TokenError} If the refresh token request fails or returns an invalid body; the
264
+ * rejection is routed to the {@link onRefreshError} handler by the firing timer.
265
+ */
266
+ async refresh() {
267
+ /* c8 ignore next 3 -- unreachable: stop() always clears the only timer that calls refresh() */
268
+ if (this.stopped) {
269
+ return;
270
+ }
271
+ // Re-check the bounded deadline at fire time (not just at schedule time): once it has elapsed the
272
+ // loop stops with no further renewal -> the access token lapses -> re-login is required.
273
+ if (this.deadlineInMs !== null && this.nowInMs() >= this.deadlineInMs) {
274
+ this.stop();
275
+ return;
276
+ }
277
+ const tokenResponse = await postTokenRequest(this.tokenEndpoint, {
278
+ grant_type: 'refresh_token',
279
+ client_id: this.clientId,
280
+ refresh_token: this.refreshToken
281
+ }, this.fetchImpl);
282
+ this.accessToken = tokenResponse.access_token;
283
+ // Keycloak may rotate the offline refresh token; keep the newest one when present.
284
+ if (typeof tokenResponse.refresh_token === 'string' && tokenResponse.refresh_token.length > 0) {
285
+ this.refreshToken = tokenResponse.refresh_token;
286
+ }
287
+ this.scheduleRefresh(tokenResponse.expires_in);
288
+ }
289
+ /**
290
+ * Arm a single timer for the next refresh, clamped to the bounded deadline. Stops silently once
291
+ * `tokenExpirationInS` has elapsed (no further renewal -> access lapses -> re-login required).
292
+ *
293
+ * @param expiresInRaw - The `expires_in` (seconds) from the latest token response, or `undefined`;
294
+ * non-positive / missing values fall back to {@link MIN_REFRESH_DELAY_IN_S}.
295
+ */
296
+ scheduleRefresh(expiresInRaw) {
297
+ if (this.stopped) {
298
+ return;
299
+ }
300
+ const expiresInS = typeof expiresInRaw === 'number' && expiresInRaw > 0 ? expiresInRaw : MIN_REFRESH_DELAY_IN_S;
301
+ let delayInS = Math.max(expiresInS - REFRESH_SKEW_IN_S, MIN_REFRESH_DELAY_IN_S);
302
+ if (this.deadlineInMs !== null) {
303
+ const remainingInMs = this.deadlineInMs - this.nowInMs();
304
+ if (remainingInMs <= 0) {
305
+ this.stop();
306
+ return;
307
+ }
308
+ delayInS = Math.min(delayInS, remainingInMs / 1000);
309
+ }
310
+ this.timer = setTimeout(() => {
311
+ this.refresh().catch((refreshError) => {
312
+ // Swallow a transient refresh failure but surface it so the caller can react; the next
313
+ // gRPC call gets the stale (possibly expired) token and re-logs in on UNAUTHENTICATED.
314
+ if (this.onRefreshErrorHandler !== null) {
315
+ this.onRefreshErrorHandler(refreshError);
316
+ }
317
+ });
318
+ }, delayInS * 1000);
319
+ // Do not keep the event loop alive solely for the refresh timer.
320
+ /* c8 ignore next 3 -- the else branch is unreachable: Node's Timeout always exposes unref() */
321
+ if (typeof this.timer.unref === 'function') {
322
+ this.timer.unref();
323
+ }
324
+ }
325
+ /**
326
+ * Register a callback invoked with the error of a failed background refresh (optional diagnostics).
327
+ * A later call replaces any previously registered handler.
328
+ *
329
+ * @param handler - Receives the rejection value of a failed background refresh.
330
+ */
331
+ onRefreshError(handler) {
332
+ this.onRefreshErrorHandler = handler;
333
+ }
334
+ /**
335
+ * Read the current access token.
336
+ *
337
+ * @returns The current access token, or `null` before bootstrap / after the bounded loop has lapsed.
338
+ */
339
+ getAccessToken() {
340
+ return this.accessToken;
341
+ }
342
+ /**
343
+ * Build the value for an `Authorization` gRPC metadata header.
344
+ *
345
+ * @returns The header value `Bearer <access_token>`.
346
+ * @throws {@link TokenError} If no access token is available (login not completed or already lapsed).
347
+ */
348
+ getAuthorizationHeader() {
349
+ if (this.accessToken === null) {
350
+ throw new TokenError('No access token available; login() has not completed or has lapsed');
351
+ }
352
+ return `Bearer ${this.accessToken}`;
353
+ }
354
+ /**
355
+ * A logging-safe view of this provider: the access and refresh tokens render as `***REDACTED***`
356
+ * (`null` before login and an empty token stay as they are). `JSON.stringify(provider)` uses it, and so
357
+ * do Node's `console.log(provider)` / `util.inspect(provider)` through the hook below, so none of them
358
+ * prints a token. {@link OfflineTokenProvider.getAuthorizationHeader} still returns the real one.
359
+ *
360
+ * @returns The endpoint, client id and stop flag, with both tokens redacted.
361
+ */
362
+ toJSON() {
363
+ return {
364
+ tokenEndpoint: this.tokenEndpoint,
365
+ clientId: this.clientId,
366
+ accessToken: redactToken(this.accessToken),
367
+ refreshToken: redactToken(this.refreshToken),
368
+ stopped: this.stopped
369
+ };
370
+ }
371
+ /**
372
+ * Node's `util.inspect` hook (used by `console.log`): renders {@link OfflineTokenProvider.toJSON}, so
373
+ * logging the provider never prints a token. Looked up via `Symbol.for`, so no `util` import is needed
374
+ * and the module stays usable in a browser bundle.
375
+ *
376
+ * @returns The redacted view.
377
+ */
378
+ [Symbol.for('nodejs.util.inspect.custom')]() {
379
+ return this.toJSON();
380
+ }
381
+ /**
382
+ * Stop the auto-refresh loop and clear any pending timer. Idempotent; safe to call from any state.
383
+ * After this the access token is no longer renewed and will eventually lapse.
384
+ */
385
+ stop() {
386
+ this.stopped = true;
387
+ if (this.timer !== null) {
388
+ clearTimeout(this.timer);
389
+ this.timer = null;
390
+ }
391
+ }
392
+ }
393
+ exports.OfflineTokenProvider = OfflineTokenProvider;
394
+ /**
395
+ * One-time ROPC + offline_access login against the PUBLIC SDK client, returning a live token provider
396
+ * whose access token is auto-refreshed in the background until `tokenExpirationInS` elapses.
397
+ *
398
+ * @param options - The D18 headless-SDK offline-token login options; the five string fields
399
+ * (`keycloakUrl`, `realm`, `clientId`, `username`, `password`) are required and must be non-empty.
400
+ * @returns A promise resolving to a bootstrapped {@link OfflineTokenProvider} with its first refresh armed.
401
+ * @throws {@link TokenError} If `options` is missing, a required field is absent/empty, or the
402
+ * bootstrap login fails (see {@link OfflineTokenProvider.bootstrap}).
403
+ */
404
+ async function login(options) {
405
+ if (options === undefined || options === null) {
406
+ throw new TokenError('login() requires an options object');
407
+ }
408
+ const requiredKeys = ['keycloakUrl', 'realm', 'clientId', 'username', 'password'];
409
+ for (const key of requiredKeys) {
410
+ const value = options[key];
411
+ if (typeof value !== 'string' || value.length === 0) {
412
+ throw new TokenError(`login() option "${key}" is required and must be a non-empty string`);
413
+ }
414
+ }
415
+ const provider = new OfflineTokenProvider(options);
416
+ await provider.bootstrap(options.username, options.password);
417
+ return provider;
418
+ }
419
+ // The gRPC-web endpoint / TLS helper ships through this module: `make create_npm_package` compiles this
420
+ // file (tsc follows the import), so `@ondewo/s2t-client-typescript/auth/offlineTokenProvider` exports both
421
+ // hand-written helpers and a proto-compiler regeneration cannot drop it.
422
+ __exportStar(require("./grpcWebEndpoint"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ondewo/s2t-client-typescript",
3
- "version": "7.4.1",
3
+ "version": "7.5.1",
4
4
  "description": "ONDEWO Speech to Text (S2T) Client library for Typescript",
5
5
  "author": "ONDEWO GmbH <office@ondewo.com>",
6
6
  "homepage": "https://ondewo.com",
@@ -20,7 +20,7 @@
20
20
  "directory": "https://github.com/ondewo/ondewo-s2t-client-typescript"
21
21
  },
22
22
  "dependencies": {
23
- "google-protobuf": "3.21.4",
23
+ "google-protobuf": "4.0.2",
24
24
  "grpc-web": "^1.5.0",
25
25
  "tslib": "^2.8.1",
26
26
  "undici": "^6.27.0"
package/public-api.d.ts CHANGED
@@ -1,4 +1,6 @@
1
+ export * from './api/google/protobuf/struct_pb.d';
2
+ export * from './api/google/protobuf/empty_pb.d';
1
3
  export * from './api/ondewo/s2t/speech-to-text_pb.d';
2
4
  export * from './api/ondewo/s2t/speech-to-text_grpc_web_pb.d';
3
- export * from './api/google/protobuf/empty_pb.d';
4
- export * from './api/google/protobuf/struct_pb.d';
5
+ export * from './auth/grpcWebEndpoint';
6
+ export * from './auth/offlineTokenProvider';
package/public-api.js CHANGED
@@ -1,4 +1,6 @@
1
- export * from './api/ondewo/s2t/speech-to-text_pb';
2
- export * from './api/ondewo/s2t/speech-to-text_grpc_web_pb';
3
- export * from './api/google/protobuf/empty_pb';
4
1
  export * from './api/google/protobuf/struct_pb';
2
+ export * from './api/google/protobuf/empty_pb';
3
+ export * from './api/ondewo/s2t/speech-to-text_grpc_web_pb';
4
+ export * from './api/ondewo/s2t/speech-to-text_pb';
5
+ export * from './auth/grpcWebEndpoint';
6
+ export * from './auth/offlineTokenProvider';