@ondewo/sip-client-nodejs 5.2.0 → 5.4.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/LICENSE +0 -0
- package/README.md +3 -0
- package/api/ondewo/sip/sip_grpc_pb.js +15 -16
- package/auth/offlineTokenProvider.d.ts +158 -0
- package/auth/offlineTokenProvider.js +320 -0
- package/auth/offlineTokenProvider.spec.ts +595 -0
- package/auth/offlineTokenProvider.ts +433 -0
- package/package.json +7 -4
- package/public-api.js +0 -0
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -43,7 +43,9 @@ git clone https://github.com/ondewo/ondewo-sip-client-nodejs.git ## Clone reposi
|
|
|
43
43
|
cd ondewo-sip-client-nodejs ## Change into repo-directoy
|
|
44
44
|
make setup_developer_environment_locally ## Install dependencies
|
|
45
45
|
```
|
|
46
|
+
|
|
46
47
|
## Package structure
|
|
48
|
+
|
|
47
49
|
```
|
|
48
50
|
npm
|
|
49
51
|
├── api
|
|
@@ -67,3 +69,4 @@ npm
|
|
|
67
69
|
├── public-api.js
|
|
68
70
|
└── README.md
|
|
69
71
|
```
|
|
72
|
+
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED CODE -- DO NOT EDIT!
|
|
2
2
|
|
|
3
3
|
// Original file comments:
|
|
4
|
-
// Copyright 2021 ONDEWO GmbH
|
|
4
|
+
// Copyright 2021 - 2026 ONDEWO GmbH
|
|
5
5
|
//
|
|
6
6
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
7
|
// you may not use this file except in compliance with the License.
|
|
@@ -121,9 +121,9 @@ function deserialize_ondewo_sip_SipTransferCallRequest(buffer_arg) {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
|
|
124
|
-
// ONDEWO-SIP API available at <a href="https://github.com/ondewo/ondewo-sip-api
|
|
124
|
+
// <p>ONDEWO-SIP API available at <a href="https://github.com/ondewo/ondewo-sip-api">GitHub</a></p>
|
|
125
125
|
var SipService = exports.SipService = {
|
|
126
|
-
// Starts a new SIP session for an account registered at a SIP server. <code>RegisterAccount</code> need to be called before
|
|
126
|
+
// <p>Starts a new SIP session for an account registered at a SIP server. <code>RegisterAccount</code> need to be called before.</p>
|
|
127
127
|
sipStartSession: {
|
|
128
128
|
path: '/ondewo.sip.Sip/SipStartSession',
|
|
129
129
|
requestStream: false,
|
|
@@ -135,7 +135,7 @@ sipStartSession: {
|
|
|
135
135
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
136
136
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
137
137
|
},
|
|
138
|
-
// Ends a SIP session for an account registered at a SIP server
|
|
138
|
+
// <p>Ends a SIP session for an account registered at a SIP server</p>
|
|
139
139
|
sipEndSession: {
|
|
140
140
|
path: '/ondewo.sip.Sip/SipEndSession',
|
|
141
141
|
requestStream: false,
|
|
@@ -147,7 +147,7 @@ sipEndSession: {
|
|
|
147
147
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
148
148
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
149
149
|
},
|
|
150
|
-
// Starts a call in an active SIP session for an account registered at a SIP server
|
|
150
|
+
// <p>Starts a call in an active SIP session for an account registered at a SIP server</p>
|
|
151
151
|
sipStartCall: {
|
|
152
152
|
path: '/ondewo.sip.Sip/SipStartCall',
|
|
153
153
|
requestStream: false,
|
|
@@ -159,7 +159,7 @@ sipStartCall: {
|
|
|
159
159
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
160
160
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
161
161
|
},
|
|
162
|
-
// Ends a call in an active SIP session for an account registered at a SIP server
|
|
162
|
+
// <p>Ends a call in an active SIP session for an account registered at a SIP server</p>
|
|
163
163
|
sipEndCall: {
|
|
164
164
|
path: '/ondewo.sip.Sip/SipEndCall',
|
|
165
165
|
requestStream: false,
|
|
@@ -171,8 +171,7 @@ sipEndCall: {
|
|
|
171
171
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
172
172
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
173
173
|
},
|
|
174
|
-
// Transfers a call in an active SIP session for an account registered at a SIP server to
|
|
175
|
-
// another SIP account or phone number specified by <code>transfer_id</code>
|
|
174
|
+
// <p>Transfers a call in an active SIP session for an account registered at a SIP server to another SIP account or phone number specified by <code>transfer_id</code></p>
|
|
176
175
|
sipTransferCall: {
|
|
177
176
|
path: '/ondewo.sip.Sip/SipTransferCall',
|
|
178
177
|
requestStream: false,
|
|
@@ -184,7 +183,7 @@ sipTransferCall: {
|
|
|
184
183
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
185
184
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
186
185
|
},
|
|
187
|
-
// Registers s SIP account at a SIP server
|
|
186
|
+
// <p>Registers s SIP account at a SIP server</p>
|
|
188
187
|
sipRegisterAccount: {
|
|
189
188
|
path: '/ondewo.sip.Sip/SipRegisterAccount',
|
|
190
189
|
requestStream: false,
|
|
@@ -196,7 +195,7 @@ sipRegisterAccount: {
|
|
|
196
195
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
197
196
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
198
197
|
},
|
|
199
|
-
// Gets the current SIP status
|
|
198
|
+
// <p>Gets the current SIP status</p>
|
|
200
199
|
sipGetSipStatus: {
|
|
201
200
|
path: '/ondewo.sip.Sip/SipGetSipStatus',
|
|
202
201
|
requestStream: false,
|
|
@@ -208,7 +207,7 @@ sipGetSipStatus: {
|
|
|
208
207
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
209
208
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
210
209
|
},
|
|
211
|
-
// Gets the history of SIP status
|
|
210
|
+
// <p>Gets the history of SIP status</p>
|
|
212
211
|
sipGetSipStatusHistory: {
|
|
213
212
|
path: '/ondewo.sip.Sip/SipGetSipStatusHistory',
|
|
214
213
|
requestStream: false,
|
|
@@ -220,7 +219,7 @@ sipGetSipStatusHistory: {
|
|
|
220
219
|
responseSerialize: serialize_ondewo_sip_SipStatusHistoryResponse,
|
|
221
220
|
responseDeserialize: deserialize_ondewo_sip_SipStatusHistoryResponse,
|
|
222
221
|
},
|
|
223
|
-
// Plays wav files during an ongoing call of an active SIP session
|
|
222
|
+
// <p>Plays wav files during an ongoing call of an active SIP session</p>
|
|
224
223
|
sipPlayWavFiles: {
|
|
225
224
|
path: '/ondewo.sip.Sip/SipPlayWavFiles',
|
|
226
225
|
requestStream: false,
|
|
@@ -232,7 +231,7 @@ sipPlayWavFiles: {
|
|
|
232
231
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
233
232
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
234
233
|
},
|
|
235
|
-
// Mutes the microphone in an ongoing call of an active SIP session
|
|
234
|
+
// <p>Mutes the microphone in an ongoing call of an active SIP session</p>
|
|
236
235
|
sipMute: {
|
|
237
236
|
path: '/ondewo.sip.Sip/SipMute',
|
|
238
237
|
requestStream: false,
|
|
@@ -244,7 +243,7 @@ sipMute: {
|
|
|
244
243
|
responseSerialize: serialize_ondewo_sip_SipStatus,
|
|
245
244
|
responseDeserialize: deserialize_ondewo_sip_SipStatus,
|
|
246
245
|
},
|
|
247
|
-
// Un-mutes the microphone in an ongoing call of an active SIP session
|
|
246
|
+
// <p>Un-mutes the microphone in an ongoing call of an active SIP session</p>
|
|
248
247
|
sipUnMute: {
|
|
249
248
|
path: '/ondewo.sip.Sip/SipUnMute',
|
|
250
249
|
requestStream: false,
|
|
@@ -258,5 +257,5 @@ sipUnMute: {
|
|
|
258
257
|
},
|
|
259
258
|
};
|
|
260
259
|
|
|
261
|
-
exports.SipClient = grpc.makeGenericClientConstructor(SipService);
|
|
262
|
-
// SIP LifeCycle is explained at <a href="https://thanhloi2603.wordpress.com/2017/06/10/sip-lifecycle-overview/">here</a>
|
|
260
|
+
exports.SipClient = grpc.makeGenericClientConstructor(SipService, 'Sip');
|
|
261
|
+
// <p>SIP LifeCycle is explained at <a href="https://thanhloi2603.wordpress.com/2017/06/10/sip-lifecycle-overview/">here</a></p>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structural type of the fetch Response fields this helper reads. Keeps the module
|
|
3
|
+
* self-contained (no DOM lib dependency) while still typing the injectable `fetchImpl`.
|
|
4
|
+
*/
|
|
5
|
+
export interface TokenFetchResponse {
|
|
6
|
+
/** Whether the HTTP status is in the 2xx success range. */
|
|
7
|
+
ok: boolean;
|
|
8
|
+
/** The numeric HTTP status code of the response. */
|
|
9
|
+
status: number;
|
|
10
|
+
/**
|
|
11
|
+
* Read the full response body as text.
|
|
12
|
+
*
|
|
13
|
+
* @returns A promise resolving to the raw response body string.
|
|
14
|
+
*/
|
|
15
|
+
text(): Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
/** Init object passed to the injectable fetch. */
|
|
18
|
+
export interface TokenFetchInit {
|
|
19
|
+
/** The HTTP method, always `"POST"` for the token endpoint. */
|
|
20
|
+
method: string;
|
|
21
|
+
/** The request headers (content type + accept) sent to the token endpoint. */
|
|
22
|
+
headers: Record<string, string>;
|
|
23
|
+
/** The form-encoded request body. */
|
|
24
|
+
body: string;
|
|
25
|
+
/**
|
|
26
|
+
* Optional undici dispatcher (Node's non-standard `fetch` extension); set only on the default
|
|
27
|
+
* transport when `keycloakVerifySsl` is `false` to skip TLS verification.
|
|
28
|
+
*/
|
|
29
|
+
dispatcher?: unknown;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Injectable fetch signature (a subset of the global `fetch`) used by the token endpoint call.
|
|
33
|
+
*
|
|
34
|
+
* @param url - The absolute token-endpoint URL to POST to.
|
|
35
|
+
* @param init - The request method, headers, and form-encoded body.
|
|
36
|
+
* @returns A promise resolving to the minimal {@link TokenFetchResponse} the helper reads.
|
|
37
|
+
*/
|
|
38
|
+
export type TokenFetch = (url: string, init: TokenFetchInit) => Promise<TokenFetchResponse>;
|
|
39
|
+
/** Options for the D18 headless-SDK offline-token login. */
|
|
40
|
+
export interface OfflineTokenLoginOptions {
|
|
41
|
+
/** Base Keycloak URL, e.g. "https://auth.example.com/auth" (trailing slash tolerated). */
|
|
42
|
+
keycloakUrl: string;
|
|
43
|
+
/** Realm name, e.g. "ondewo-ccai-platform". */
|
|
44
|
+
realm: string;
|
|
45
|
+
/** Public SDK client id, e.g. "ondewo-sip-cai-sdk-public". NO client_secret (Q1). */
|
|
46
|
+
clientId: string;
|
|
47
|
+
/** 2FA-exempt technical-user email. */
|
|
48
|
+
username: string;
|
|
49
|
+
/** Technical-user password. */
|
|
50
|
+
password: string;
|
|
51
|
+
/** Optional cap (seconds) on how long the auto-refresh loop runs after login. */
|
|
52
|
+
tokenExpirationInS?: number;
|
|
53
|
+
/** Optional fetch override (tests inject a mock); defaults to the global fetch. */
|
|
54
|
+
fetchImpl?: TokenFetch;
|
|
55
|
+
/**
|
|
56
|
+
* When `false`, disable TLS certificate verification on the Keycloak token request (opt-in
|
|
57
|
+
* insecure, for a self-signed local Envoy). Defaults to `true` (secure). Ignored when a custom
|
|
58
|
+
* `fetchImpl` is injected. Node-only (undici dispatcher).
|
|
59
|
+
*/
|
|
60
|
+
keycloakVerifySsl?: boolean;
|
|
61
|
+
/** Optional clock override returning epoch ms (tests); defaults to Date.now. */
|
|
62
|
+
nowInMs?: () => number;
|
|
63
|
+
}
|
|
64
|
+
/** Error raised on any token-endpoint or token-shape failure. */
|
|
65
|
+
export declare class TokenError extends Error {
|
|
66
|
+
/**
|
|
67
|
+
* Construct a new {@link TokenError}.
|
|
68
|
+
*
|
|
69
|
+
* @param message - A human-readable description of the token failure.
|
|
70
|
+
*/
|
|
71
|
+
constructor(message: string);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A live access-token holder backed by a bounded auto-refresh loop. Obtain one from {@link login};
|
|
75
|
+
* read {@link getAuthorizationHeader} for the gRPC `Authorization` metadata and call {@link stop} when done.
|
|
76
|
+
*/
|
|
77
|
+
export declare class OfflineTokenProvider {
|
|
78
|
+
private readonly tokenEndpoint;
|
|
79
|
+
private readonly clientId;
|
|
80
|
+
private readonly tokenExpirationInS;
|
|
81
|
+
private readonly fetchImpl;
|
|
82
|
+
private readonly nowInMs;
|
|
83
|
+
private accessToken;
|
|
84
|
+
private refreshToken;
|
|
85
|
+
private timer;
|
|
86
|
+
private stopped;
|
|
87
|
+
private deadlineInMs;
|
|
88
|
+
private onRefreshErrorHandler;
|
|
89
|
+
/**
|
|
90
|
+
* Construct a provider from the login options. Does not perform any network I/O; call
|
|
91
|
+
* {@link bootstrap} (or the module-level {@link login}) to actually authenticate.
|
|
92
|
+
*
|
|
93
|
+
* @param options - The D18 offline-token login options (URL, realm, client id, credentials, overrides).
|
|
94
|
+
*/
|
|
95
|
+
constructor(options: OfflineTokenLoginOptions);
|
|
96
|
+
/**
|
|
97
|
+
* Perform the one-time ROPC login and arm the first refresh. Awaited by {@link login}.
|
|
98
|
+
*
|
|
99
|
+
* @param username - The 2FA-exempt technical-user email.
|
|
100
|
+
* @param password - The technical-user password.
|
|
101
|
+
* @returns A promise that resolves once the access token is stored and the first refresh is armed.
|
|
102
|
+
* @throws {TokenError} If the token endpoint fails or the response carries no `refresh_token`
|
|
103
|
+
* (the SDK client lacks `directAccessGrants` + the `offline_access` scope).
|
|
104
|
+
*/
|
|
105
|
+
bootstrap(username: string, password: string): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Exchange the offline refresh token for a fresh access token and re-arm the next refresh.
|
|
108
|
+
* No-ops once {@link stop} has been called or the bounded deadline has elapsed.
|
|
109
|
+
*
|
|
110
|
+
* @returns A promise that resolves once the token is refreshed (or the loop has lapsed/stopped).
|
|
111
|
+
* @throws {TokenError} If the refresh token-endpoint call fails or returns an invalid body.
|
|
112
|
+
*/
|
|
113
|
+
private refresh;
|
|
114
|
+
/**
|
|
115
|
+
* Arm a single timer for the next refresh, clamped to the bounded deadline. Stops silently once
|
|
116
|
+
* `tokenExpirationInS` has elapsed (no further renewal -> access lapses -> re-login required).
|
|
117
|
+
*
|
|
118
|
+
* @param expiresInRaw - The access token lifetime in seconds from the token response, or
|
|
119
|
+
* `undefined`/non-positive to fall back to {@link MIN_REFRESH_DELAY_IN_S}.
|
|
120
|
+
*/
|
|
121
|
+
private scheduleRefresh;
|
|
122
|
+
/**
|
|
123
|
+
* Register a callback invoked with the error of a failed background refresh (optional diagnostics).
|
|
124
|
+
*
|
|
125
|
+
* @param handler - The callback receiving the error thrown by a failed background refresh.
|
|
126
|
+
* @returns Nothing.
|
|
127
|
+
*/
|
|
128
|
+
onRefreshError(handler: (error: unknown) => void): void;
|
|
129
|
+
/**
|
|
130
|
+
* Read the current access token.
|
|
131
|
+
*
|
|
132
|
+
* @returns The current access token, or `null` before bootstrap / after the bounded loop has lapsed.
|
|
133
|
+
*/
|
|
134
|
+
getAccessToken(): string | null;
|
|
135
|
+
/**
|
|
136
|
+
* Build the value for an `Authorization` gRPC metadata header.
|
|
137
|
+
*
|
|
138
|
+
* @returns The header value `Bearer <access_token>`.
|
|
139
|
+
* @throws {TokenError} If no access token is available (login has not completed or has lapsed).
|
|
140
|
+
*/
|
|
141
|
+
getAuthorizationHeader(): string;
|
|
142
|
+
/**
|
|
143
|
+
* Stop the auto-refresh loop. Idempotent; safe to call from any state.
|
|
144
|
+
*
|
|
145
|
+
* @returns Nothing.
|
|
146
|
+
*/
|
|
147
|
+
stop(): void;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* One-time ROPC + offline_access login against the PUBLIC SDK client, returning a live token provider
|
|
151
|
+
* whose access token is auto-refreshed in the background until `tokenExpirationInS` elapses.
|
|
152
|
+
*
|
|
153
|
+
* @param options - The D18 offline-token login options (URL, realm, client id, credentials, overrides).
|
|
154
|
+
* @returns A promise resolving to a bootstrapped {@link OfflineTokenProvider} with a live access token.
|
|
155
|
+
* @throws {TokenError} If `options` is missing, a required string option is empty, or the token
|
|
156
|
+
* endpoint / response is invalid.
|
|
157
|
+
*/
|
|
158
|
+
export declare function login(options: OfflineTokenLoginOptions): Promise<OfflineTokenProvider>;
|
|
@@ -0,0 +1,320 @@
|
|
|
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 __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
17
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
18
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
19
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
20
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
21
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
22
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.OfflineTokenProvider = exports.TokenError = void 0;
|
|
27
|
+
exports.login = login;
|
|
28
|
+
// D18 headless-SDK auth helper (keycloak-migration-plan §7.8 + D18).
|
|
29
|
+
//
|
|
30
|
+
// One-time ROPC login (grant_type=password, scope=offline_access) against the PUBLIC SDK client
|
|
31
|
+
// `ondewo-sip-cai-sdk-public` (no client_secret -- Q1), then a bounded background loop that refreshes
|
|
32
|
+
// the short-lived access token from the offline refresh token before it expires. The current access
|
|
33
|
+
// token is exposed for an `Authorization: Bearer <token>` gRPC metadata header. The refresh loop stops
|
|
34
|
+
// after `tokenExpirationInS` (if given) has elapsed since login.
|
|
35
|
+
/**
|
|
36
|
+
* Seconds of head-room subtracted from a token's `expires_in` so the refresh fires before the access
|
|
37
|
+
* token actually lapses (covers clock skew + the round-trip to Keycloak).
|
|
38
|
+
*/
|
|
39
|
+
const REFRESH_SKEW_IN_S = 30;
|
|
40
|
+
/**
|
|
41
|
+
* Lower bound (in seconds) for the scheduled refresh delay so a tiny/zero `expires_in` cannot spin a
|
|
42
|
+
* hot loop.
|
|
43
|
+
*/
|
|
44
|
+
const MIN_REFRESH_DELAY_IN_S = 1;
|
|
45
|
+
/** Error raised on any token-endpoint or token-shape failure. */
|
|
46
|
+
class TokenError extends Error {
|
|
47
|
+
/**
|
|
48
|
+
* Construct a new {@link TokenError}.
|
|
49
|
+
*
|
|
50
|
+
* @param message - A human-readable description of the token failure.
|
|
51
|
+
*/
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'TokenError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.TokenError = TokenError;
|
|
58
|
+
/**
|
|
59
|
+
* Build the OIDC token endpoint URL for a realm, tolerating a trailing slash on `keycloakUrl` and an
|
|
60
|
+
* optional `/auth` relative path already baked into it.
|
|
61
|
+
*
|
|
62
|
+
* @param keycloakUrl - The base Keycloak URL (a trailing slash is tolerated and stripped).
|
|
63
|
+
* @param realm - The realm name, which is URL-encoded into the path.
|
|
64
|
+
* @returns The fully-qualified OIDC `openid-connect/token` endpoint URL for the realm.
|
|
65
|
+
*/
|
|
66
|
+
function buildTokenEndpoint(keycloakUrl, realm) {
|
|
67
|
+
const base = keycloakUrl.replace(/\/+$/, '');
|
|
68
|
+
return `${base}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* POST an `application/x-www-form-urlencoded` body to the token endpoint and return the parsed JSON.
|
|
72
|
+
*
|
|
73
|
+
* @param tokenEndpoint - The absolute token-endpoint URL to POST to.
|
|
74
|
+
* @param params - The form parameters (grant type, client id, credentials, scope, etc.).
|
|
75
|
+
* @param fetchImpl - The fetch implementation used to perform the request.
|
|
76
|
+
* @returns A promise resolving to the parsed {@link KeycloakTokenResponse}.
|
|
77
|
+
* @throws {TokenError} On a non-2xx response, an unparseable body, or a missing `access_token`.
|
|
78
|
+
*/
|
|
79
|
+
function postTokenRequest(tokenEndpoint, params, fetchImpl) {
|
|
80
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
81
|
+
const body = new URLSearchParams(params).toString();
|
|
82
|
+
const response = yield fetchImpl(tokenEndpoint, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
86
|
+
Accept: 'application/json'
|
|
87
|
+
},
|
|
88
|
+
body
|
|
89
|
+
});
|
|
90
|
+
const text = yield response.text();
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new TokenError(`Keycloak token endpoint returned HTTP ${response.status}: ${text}`);
|
|
93
|
+
}
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(text);
|
|
97
|
+
}
|
|
98
|
+
catch (_a) {
|
|
99
|
+
throw new TokenError(`Keycloak token endpoint returned a non-JSON body: ${text}`);
|
|
100
|
+
}
|
|
101
|
+
if (typeof parsed.access_token !== 'string' || parsed.access_token.length === 0) {
|
|
102
|
+
throw new TokenError('Keycloak token response did not contain an access_token');
|
|
103
|
+
}
|
|
104
|
+
return parsed;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build the default fetch layer: delegate to the global `fetch` (Node >= 18).
|
|
109
|
+
*
|
|
110
|
+
* When `verifySsl` is `false`, a cached undici `Agent` with `rejectUnauthorized: false` is attached
|
|
111
|
+
* to every request as its `dispatcher`, so the Keycloak token call skips TLS certificate verification
|
|
112
|
+
* (opt-in insecure; Node-only). The dispatcher is built once here and reused for all requests this
|
|
113
|
+
* transport makes (login + refreshes); the secure default never loads undici.
|
|
114
|
+
*
|
|
115
|
+
* @param {boolean} verifySsl - Whether to verify the Keycloak server's TLS certificate.
|
|
116
|
+
* @returns {Function} A fetch layer bound to the chosen TLS-verification behaviour.
|
|
117
|
+
*/
|
|
118
|
+
function createDefaultFetch(verifySsl) {
|
|
119
|
+
let dispatcher;
|
|
120
|
+
if (!verifySsl) {
|
|
121
|
+
const { Agent } = require('undici');
|
|
122
|
+
dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
123
|
+
}
|
|
124
|
+
return (url, init) => {
|
|
125
|
+
const globalFetch = globalThis.fetch;
|
|
126
|
+
return globalFetch(url, dispatcher === undefined ? init : Object.assign({}, init, { dispatcher }));
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A live access-token holder backed by a bounded auto-refresh loop. Obtain one from {@link login};
|
|
131
|
+
* read {@link getAuthorizationHeader} for the gRPC `Authorization` metadata and call {@link stop} when done.
|
|
132
|
+
*/
|
|
133
|
+
class OfflineTokenProvider {
|
|
134
|
+
/**
|
|
135
|
+
* Construct a provider from the login options. Does not perform any network I/O; call
|
|
136
|
+
* {@link bootstrap} (or the module-level {@link login}) to actually authenticate.
|
|
137
|
+
*
|
|
138
|
+
* @param options - The D18 offline-token login options (URL, realm, client id, credentials, overrides).
|
|
139
|
+
*/
|
|
140
|
+
constructor(options) {
|
|
141
|
+
this.tokenEndpoint = buildTokenEndpoint(options.keycloakUrl, options.realm);
|
|
142
|
+
this.clientId = options.clientId;
|
|
143
|
+
this.tokenExpirationInS = options.tokenExpirationInS;
|
|
144
|
+
this.fetchImpl = options.fetchImpl !== undefined ? options.fetchImpl : createDefaultFetch(options.keycloakVerifySsl !== false);
|
|
145
|
+
this.nowInMs = options.nowInMs !== undefined ? options.nowInMs : Date.now;
|
|
146
|
+
this.accessToken = null;
|
|
147
|
+
this.refreshToken = null;
|
|
148
|
+
this.timer = null;
|
|
149
|
+
this.stopped = false;
|
|
150
|
+
this.deadlineInMs = null;
|
|
151
|
+
this.onRefreshErrorHandler = null;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Perform the one-time ROPC login and arm the first refresh. Awaited by {@link login}.
|
|
155
|
+
*
|
|
156
|
+
* @param username - The 2FA-exempt technical-user email.
|
|
157
|
+
* @param password - The technical-user password.
|
|
158
|
+
* @returns A promise that resolves once the access token is stored and the first refresh is armed.
|
|
159
|
+
* @throws {TokenError} If the token endpoint fails or the response carries no `refresh_token`
|
|
160
|
+
* (the SDK client lacks `directAccessGrants` + the `offline_access` scope).
|
|
161
|
+
*/
|
|
162
|
+
bootstrap(username, password) {
|
|
163
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
164
|
+
const tokenResponse = yield postTokenRequest(this.tokenEndpoint, {
|
|
165
|
+
grant_type: 'password',
|
|
166
|
+
client_id: this.clientId,
|
|
167
|
+
username,
|
|
168
|
+
password,
|
|
169
|
+
scope: 'offline_access'
|
|
170
|
+
}, this.fetchImpl);
|
|
171
|
+
this.accessToken = tokenResponse.access_token;
|
|
172
|
+
this.refreshToken = typeof tokenResponse.refresh_token === 'string' ? tokenResponse.refresh_token : null;
|
|
173
|
+
if (this.refreshToken === null) {
|
|
174
|
+
throw new TokenError('Keycloak token response did not contain a refresh_token; the SDK client must have ' +
|
|
175
|
+
'directAccessGrants + the offline_access scope (ondewo-sip-cai-sdk-public)');
|
|
176
|
+
}
|
|
177
|
+
if (this.tokenExpirationInS !== undefined) {
|
|
178
|
+
const expirationInMs = this.tokenExpirationInS * 1000;
|
|
179
|
+
this.deadlineInMs = this.nowInMs() + expirationInMs;
|
|
180
|
+
}
|
|
181
|
+
this.scheduleRefresh(tokenResponse.expires_in);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Exchange the offline refresh token for a fresh access token and re-arm the next refresh.
|
|
186
|
+
* No-ops once {@link stop} has been called or the bounded deadline has elapsed.
|
|
187
|
+
*
|
|
188
|
+
* @returns A promise that resolves once the token is refreshed (or the loop has lapsed/stopped).
|
|
189
|
+
* @throws {TokenError} If the refresh token-endpoint call fails or returns an invalid body.
|
|
190
|
+
*/
|
|
191
|
+
refresh() {
|
|
192
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
193
|
+
/* c8 ignore next 3 -- unreachable: stop() always clears the only timer that calls refresh() */
|
|
194
|
+
if (this.stopped) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Re-check the bounded deadline at fire time (not just at schedule time): once it has elapsed the
|
|
198
|
+
// loop stops with no further renewal -> the access token lapses -> re-login is required.
|
|
199
|
+
if (this.deadlineInMs !== null && this.nowInMs() >= this.deadlineInMs) {
|
|
200
|
+
this.stop();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const tokenResponse = yield postTokenRequest(this.tokenEndpoint, {
|
|
204
|
+
grant_type: 'refresh_token',
|
|
205
|
+
client_id: this.clientId,
|
|
206
|
+
refresh_token: this.refreshToken
|
|
207
|
+
}, this.fetchImpl);
|
|
208
|
+
this.accessToken = tokenResponse.access_token;
|
|
209
|
+
// Keycloak may rotate the offline refresh token; keep the newest one when present.
|
|
210
|
+
if (typeof tokenResponse.refresh_token === 'string' && tokenResponse.refresh_token.length > 0) {
|
|
211
|
+
this.refreshToken = tokenResponse.refresh_token;
|
|
212
|
+
}
|
|
213
|
+
this.scheduleRefresh(tokenResponse.expires_in);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Arm a single timer for the next refresh, clamped to the bounded deadline. Stops silently once
|
|
218
|
+
* `tokenExpirationInS` has elapsed (no further renewal -> access lapses -> re-login required).
|
|
219
|
+
*
|
|
220
|
+
* @param expiresInRaw - The access token lifetime in seconds from the token response, or
|
|
221
|
+
* `undefined`/non-positive to fall back to {@link MIN_REFRESH_DELAY_IN_S}.
|
|
222
|
+
*/
|
|
223
|
+
scheduleRefresh(expiresInRaw) {
|
|
224
|
+
if (this.stopped) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const expiresInS = typeof expiresInRaw === 'number' && expiresInRaw > 0 ? expiresInRaw : MIN_REFRESH_DELAY_IN_S;
|
|
228
|
+
let delayInS = Math.max(expiresInS - REFRESH_SKEW_IN_S, MIN_REFRESH_DELAY_IN_S);
|
|
229
|
+
if (this.deadlineInMs !== null) {
|
|
230
|
+
const remainingInMs = this.deadlineInMs - this.nowInMs();
|
|
231
|
+
if (remainingInMs <= 0) {
|
|
232
|
+
this.stop();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
delayInS = Math.min(delayInS, remainingInMs / 1000);
|
|
236
|
+
}
|
|
237
|
+
this.timer = setTimeout(() => {
|
|
238
|
+
this.refresh().catch((refreshError) => {
|
|
239
|
+
// Swallow a transient refresh failure but surface it so the caller can react; the next
|
|
240
|
+
// gRPC call gets the stale (possibly expired) token and re-logs in on UNAUTHENTICATED.
|
|
241
|
+
if (this.onRefreshErrorHandler !== null) {
|
|
242
|
+
this.onRefreshErrorHandler(refreshError);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}, delayInS * 1000);
|
|
246
|
+
// Do not keep the event loop alive solely for the refresh timer.
|
|
247
|
+
/* c8 ignore next 3 -- the else branch is unreachable: Node's Timeout always exposes unref() */
|
|
248
|
+
if (typeof this.timer.unref === 'function') {
|
|
249
|
+
this.timer.unref();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Register a callback invoked with the error of a failed background refresh (optional diagnostics).
|
|
254
|
+
*
|
|
255
|
+
* @param handler - The callback receiving the error thrown by a failed background refresh.
|
|
256
|
+
* @returns Nothing.
|
|
257
|
+
*/
|
|
258
|
+
onRefreshError(handler) {
|
|
259
|
+
this.onRefreshErrorHandler = handler;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Read the current access token.
|
|
263
|
+
*
|
|
264
|
+
* @returns The current access token, or `null` before bootstrap / after the bounded loop has lapsed.
|
|
265
|
+
*/
|
|
266
|
+
getAccessToken() {
|
|
267
|
+
return this.accessToken;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Build the value for an `Authorization` gRPC metadata header.
|
|
271
|
+
*
|
|
272
|
+
* @returns The header value `Bearer <access_token>`.
|
|
273
|
+
* @throws {TokenError} If no access token is available (login has not completed or has lapsed).
|
|
274
|
+
*/
|
|
275
|
+
getAuthorizationHeader() {
|
|
276
|
+
if (this.accessToken === null) {
|
|
277
|
+
throw new TokenError('No access token available; login() has not completed or has lapsed');
|
|
278
|
+
}
|
|
279
|
+
return `Bearer ${this.accessToken}`;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Stop the auto-refresh loop. Idempotent; safe to call from any state.
|
|
283
|
+
*
|
|
284
|
+
* @returns Nothing.
|
|
285
|
+
*/
|
|
286
|
+
stop() {
|
|
287
|
+
this.stopped = true;
|
|
288
|
+
if (this.timer !== null) {
|
|
289
|
+
clearTimeout(this.timer);
|
|
290
|
+
this.timer = null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
exports.OfflineTokenProvider = OfflineTokenProvider;
|
|
295
|
+
/**
|
|
296
|
+
* One-time ROPC + offline_access login against the PUBLIC SDK client, returning a live token provider
|
|
297
|
+
* whose access token is auto-refreshed in the background until `tokenExpirationInS` elapses.
|
|
298
|
+
*
|
|
299
|
+
* @param options - The D18 offline-token login options (URL, realm, client id, credentials, overrides).
|
|
300
|
+
* @returns A promise resolving to a bootstrapped {@link OfflineTokenProvider} with a live access token.
|
|
301
|
+
* @throws {TokenError} If `options` is missing, a required string option is empty, or the token
|
|
302
|
+
* endpoint / response is invalid.
|
|
303
|
+
*/
|
|
304
|
+
function login(options) {
|
|
305
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
306
|
+
if (options === undefined || options === null) {
|
|
307
|
+
throw new TokenError('login() requires an options object');
|
|
308
|
+
}
|
|
309
|
+
const requiredKeys = ['keycloakUrl', 'realm', 'clientId', 'username', 'password'];
|
|
310
|
+
for (const key of requiredKeys) {
|
|
311
|
+
const value = options[key];
|
|
312
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
313
|
+
throw new TokenError(`login() option "${key}" is required and must be a non-empty string`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const provider = new OfflineTokenProvider(options);
|
|
317
|
+
yield provider.bootstrap(options.username, options.password);
|
|
318
|
+
return provider;
|
|
319
|
+
});
|
|
320
|
+
}
|