@mcp-abap-adt/auth-broker 3.0.4 → 4.0.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/CHANGELOG.md +448 -1
- package/README.md +723 -533
- package/dist/AuthBroker.d.ts +178 -29
- package/dist/AuthBroker.d.ts.map +1 -1
- package/dist/AuthBroker.js +494 -101
- package/dist/DestinationConfigError.d.ts +27 -0
- package/dist/DestinationConfigError.d.ts.map +1 -0
- package/dist/DestinationConfigError.js +36 -0
- package/dist/SessionWriter.d.ts +54 -0
- package/dist/SessionWriter.d.ts.map +1 -0
- package/dist/SessionWriter.js +135 -0
- package/dist/binding.d.ts +95 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +207 -0
- package/dist/bindingOf.d.ts +36 -0
- package/dist/bindingOf.d.ts.map +1 -0
- package/dist/bindingOf.js +53 -0
- package/dist/destinations.d.ts +139 -0
- package/dist/destinations.d.ts.map +1 -0
- package/dist/destinations.js +429 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/providers/ITokenProvider.d.ts +2 -1
- package/dist/providers/ITokenProvider.d.ts.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.js +2 -2
- package/dist/stores/index.d.ts +2 -2
- package/dist/stores/index.js +2 -2
- package/dist/stores/interfaces.d.ts +4 -3
- package/dist/stores/interfaces.d.ts.map +1 -1
- package/dist/stores/interfaces.js +2 -2
- package/dist/types.d.ts +5 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -1
- package/package.json +25 -45
- package/CONTRIBUTORS.md +0 -33
- package/dist/__tests__/helpers/configHelpers.d.ts +0 -59
- package/dist/__tests__/helpers/configHelpers.d.ts.map +0 -1
- package/dist/__tests__/helpers/configHelpers.js +0 -206
- package/dist/__tests__/helpers/netHelpers.d.ts +0 -3
- package/dist/__tests__/helpers/netHelpers.d.ts.map +0 -1
- package/dist/__tests__/helpers/netHelpers.js +0 -63
- package/dist/__tests__/helpers/testLogger.d.ts +0 -7
- package/dist/__tests__/helpers/testLogger.d.ts.map +0 -1
- package/dist/__tests__/helpers/testLogger.js +0 -54
- package/dist/bin/mcp-auth.js +0 -752
- package/dist/bin/mcp-sso.js +0 -825
- package/dist/bin/mcpSsoConfig.js +0 -566
- package/dist/bin/samlMetadata.js +0 -175
- package/dist/bin/workDir.js +0 -79
package/dist/AuthBroker.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* AuthBroker:
|
|
3
|
+
* AuthBroker: the credential for a destination, and tokens for one.
|
|
4
|
+
*
|
|
5
|
+
* `getProvider` builds the `IAuthProvider` a destination states, from the
|
|
6
|
+
* means its service key store holds and the secret its session store holds.
|
|
7
|
+
* Every token provider it builds writes what it obtains back to the session
|
|
8
|
+
* store through `onTokens` — the secret alone, retried by the broker when the
|
|
9
|
+
* store fails (`SessionWriter`); `flush()` reports what is still pending.
|
|
10
|
+
* The token API (`getToken`, `refreshToken`, `createTokenRefresher`) asks that
|
|
11
|
+
* same provider — one per destination, shared — or, when the consumer gives
|
|
12
|
+
* one, the consumer's provider, whose every answer it writes through the same
|
|
13
|
+
* path.
|
|
4
14
|
*
|
|
5
15
|
* The broker orchestrates and nothing more. It resolves what the stores know
|
|
6
16
|
* about a destination, hands it to the provider, asks the provider for a token
|
|
@@ -12,12 +22,46 @@
|
|
|
12
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
23
|
exports.AuthBroker = void 0;
|
|
14
24
|
const interfaces_auth_1 = require("@mcp-abap-adt/interfaces-auth");
|
|
25
|
+
const binding_1 = require("./binding");
|
|
26
|
+
const bindingOf_1 = require("./bindingOf");
|
|
27
|
+
const DestinationConfigError_1 = require("./DestinationConfigError");
|
|
28
|
+
const destinations_1 = require("./destinations");
|
|
29
|
+
const SessionWriter_1 = require("./SessionWriter");
|
|
15
30
|
const noOpLogger = {
|
|
16
31
|
info: () => { },
|
|
17
32
|
error: () => { },
|
|
18
33
|
warn: () => { },
|
|
19
34
|
debug: () => { },
|
|
20
35
|
};
|
|
36
|
+
/** The session secret's fields on a connection config — never means. */
|
|
37
|
+
/** The session's side: the secret and what it is bound to. */
|
|
38
|
+
const SECRET_FIELDS = [
|
|
39
|
+
'authorizationToken',
|
|
40
|
+
'sessionCookies',
|
|
41
|
+
'expiresAt',
|
|
42
|
+
'issuedFor',
|
|
43
|
+
'issuedBy',
|
|
44
|
+
];
|
|
45
|
+
const isSecretField = (field) => SECRET_FIELDS.includes(field);
|
|
46
|
+
/** When the result expires, in epoch ms: its own `expiresAt`, else `expiresIn` from now. */
|
|
47
|
+
function expiryOf(result) {
|
|
48
|
+
if (typeof result.expiresAt === 'number')
|
|
49
|
+
return result.expiresAt;
|
|
50
|
+
if (typeof result.expiresIn === 'number') {
|
|
51
|
+
return Date.now() + Math.round(result.expiresIn * 1000);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
/** A provider `getProvider` built that obtains tokens: a token provider. */
|
|
56
|
+
function obtainsTokens(provider) {
|
|
57
|
+
const candidate = provider;
|
|
58
|
+
return (typeof candidate.getTokens === 'function' &&
|
|
59
|
+
typeof candidate.refreshTokens === 'function');
|
|
60
|
+
}
|
|
61
|
+
/** A stored string that counts as present: `''` is none. */
|
|
62
|
+
function present(value) {
|
|
63
|
+
return typeof value === 'string' && value !== '';
|
|
64
|
+
}
|
|
21
65
|
function errorCode(error) {
|
|
22
66
|
if (error !== null && typeof error === 'object' && 'code' in error) {
|
|
23
67
|
const code = error.code;
|
|
@@ -25,6 +69,13 @@ function errorCode(error) {
|
|
|
25
69
|
}
|
|
26
70
|
return undefined;
|
|
27
71
|
}
|
|
72
|
+
/** A result carrying a token, or the 3.x error for one that carries none. */
|
|
73
|
+
function checked(destination, result) {
|
|
74
|
+
if (!result?.authorizationToken) {
|
|
75
|
+
throw new Error(`Token provider did not return authorization token for destination "${destination}"`);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
28
79
|
/**
|
|
29
80
|
* AuthBroker manages authentication tokens for destinations
|
|
30
81
|
*/
|
|
@@ -33,7 +84,29 @@ class AuthBroker {
|
|
|
33
84
|
serviceKeyStore;
|
|
34
85
|
sessionStore;
|
|
35
86
|
provider;
|
|
36
|
-
providers
|
|
87
|
+
/** A consumer factory's providers: the promise of the build, as `built`. */
|
|
88
|
+
consumerBuilt = new Map();
|
|
89
|
+
/**
|
|
90
|
+
* One cache per destination, shared by `getProvider` and — when the
|
|
91
|
+
* consumer gives no provider — the token API: the promise of the
|
|
92
|
+
* build, set before its first read.
|
|
93
|
+
*/
|
|
94
|
+
built = new Map();
|
|
95
|
+
/**
|
|
96
|
+
* The write that failed for a result, keyed on the result the provider
|
|
97
|
+
* handed over and returns, then on the destination it was written
|
|
98
|
+
* for: the token API throws it to its caller, as 3.x did, while the broker
|
|
99
|
+
* keeps retrying. Dropped once a write of that result for that destination
|
|
100
|
+
* lands.
|
|
101
|
+
*/
|
|
102
|
+
failedWrites = new WeakMap();
|
|
103
|
+
authorization;
|
|
104
|
+
oidcAuthorization;
|
|
105
|
+
deviceCodePresenter;
|
|
106
|
+
samlCookies;
|
|
107
|
+
assertionReplayStore;
|
|
108
|
+
/** getProvider's writes of the session secret, retried on their own. */
|
|
109
|
+
writer;
|
|
37
110
|
/**
|
|
38
111
|
* @param config Stores and the provider (instance or factory)
|
|
39
112
|
* @param logger Optional logger. Nothing the broker logs contains a token.
|
|
@@ -46,9 +119,6 @@ class AuthBroker {
|
|
|
46
119
|
if (!sessionStore) {
|
|
47
120
|
throw new Error('AuthBroker: sessionStore is required');
|
|
48
121
|
}
|
|
49
|
-
if (!provider) {
|
|
50
|
-
throw new Error('AuthBroker: provider is required');
|
|
51
|
-
}
|
|
52
122
|
for (const method of [
|
|
53
123
|
'getAuthorizationConfig',
|
|
54
124
|
'getConnectionConfig',
|
|
@@ -61,7 +131,7 @@ class AuthBroker {
|
|
|
61
131
|
throw new Error(`AuthBroker: sessionStore.${method} must be a function`);
|
|
62
132
|
}
|
|
63
133
|
}
|
|
64
|
-
if (typeof provider !== 'function') {
|
|
134
|
+
if (provider && typeof provider !== 'function') {
|
|
65
135
|
if (typeof provider.getTokens !== 'function') {
|
|
66
136
|
throw new Error('AuthBroker: provider.getTokens must be a function');
|
|
67
137
|
}
|
|
@@ -83,69 +153,192 @@ class AuthBroker {
|
|
|
83
153
|
this.sessionStore = sessionStore;
|
|
84
154
|
this.serviceKeyStore = serviceKeyStore;
|
|
85
155
|
this.provider = provider;
|
|
156
|
+
this.authorization = config.authorization;
|
|
157
|
+
this.oidcAuthorization = config.oidcAuthorization;
|
|
158
|
+
this.deviceCodePresenter = config.deviceCodePresenter;
|
|
159
|
+
this.samlCookies = config.samlCookies;
|
|
160
|
+
this.assertionReplayStore = config.assertionReplayStore;
|
|
86
161
|
this.logger = logger ?? noOpLogger;
|
|
162
|
+
this.writer = new SessionWriter_1.SessionWriter(async (destination, bound) => {
|
|
163
|
+
try {
|
|
164
|
+
await this.writeSecret(destination, bound);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
// Per destination: one result object may be answered for several
|
|
168
|
+
// (an instance serves every destination), and a write that lands for
|
|
169
|
+
// one says nothing about another's.
|
|
170
|
+
let failures = this.failedWrites.get(bound.original);
|
|
171
|
+
if (!failures) {
|
|
172
|
+
failures = new Map();
|
|
173
|
+
this.failedWrites.set(bound.original, failures);
|
|
174
|
+
}
|
|
175
|
+
failures.set(destination, { error });
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
this.failedWrites.get(bound.original)?.delete(destination);
|
|
179
|
+
}, this.logger);
|
|
87
180
|
this.logger.debug('[AuthBroker] Broker initialized', {
|
|
88
181
|
hasServiceKeyStore: !!serviceKeyStore,
|
|
89
|
-
providerForm:
|
|
182
|
+
providerForm: provider === undefined
|
|
183
|
+
? 'none'
|
|
184
|
+
: typeof provider === 'function'
|
|
185
|
+
? 'factory'
|
|
186
|
+
: 'instance',
|
|
90
187
|
});
|
|
91
188
|
}
|
|
92
189
|
/**
|
|
93
190
|
* A token for the destination: the provider's current one, which it refreshes
|
|
94
191
|
* or obtains by login when it judges the cached one unusable.
|
|
95
192
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
193
|
+
* Without a consumer `provider`, the provider is the one `getProvider` hands
|
|
194
|
+
* out for the destination — the same instance, so one token and one renewal
|
|
195
|
+
* serve both — and what it obtains is written by its `onTokens`. With one,
|
|
196
|
+
* the consumer's provider is asked and its answer written, as in 3.x — the
|
|
197
|
+
* secret alone.
|
|
198
|
+
*
|
|
199
|
+
* Errors from the provider (its typed errors included) propagate unchanged;
|
|
200
|
+
* so does a store failure, a failed write of the token included — the token
|
|
201
|
+
* stands and the broker keeps retrying the write.
|
|
202
|
+
*
|
|
203
|
+
* @throws DestinationConfigError for a destination the key store states as
|
|
204
|
+
* `basic` or `snc`, before any provider is asked; without a consumer
|
|
205
|
+
* `provider`, also for one whose provider obtains no token.
|
|
98
206
|
*/
|
|
99
207
|
async getToken(destination) {
|
|
100
208
|
return this.obtain(destination, 'getTokens');
|
|
101
209
|
}
|
|
102
210
|
/**
|
|
103
211
|
* A new token for the destination, never the cached one — for a caller whose
|
|
104
|
-
* token the server has just refused. Calls the provider's `refreshTokens()
|
|
105
|
-
*
|
|
212
|
+
* token the server has just refused. Calls the provider's `refreshTokens()`;
|
|
213
|
+
* a renewal already in flight for the destination is joined, not repeated.
|
|
106
214
|
*/
|
|
107
215
|
async refreshToken(destination) {
|
|
108
216
|
return this.obtain(destination, 'refreshTokens');
|
|
109
217
|
}
|
|
110
218
|
async obtain(destination, method) {
|
|
219
|
+
const means = await this.statedForTokens(destination);
|
|
220
|
+
const result = this.provider
|
|
221
|
+
? await this.obtainFromConsumer(destination, method, means)
|
|
222
|
+
: await this.obtainShared(destination, method, means);
|
|
223
|
+
return result.authorizationToken;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The destination's means, read first: a destination the key store states
|
|
227
|
+
* as `basic` or `snc` holds no token, so the token API refuses it before any
|
|
228
|
+
* provider is asked — it would write a token over a credential that is not
|
|
229
|
+
* one. A key store that states no `authType`, or no key store, is
|
|
230
|
+
* a 3.x setup, served as before.
|
|
231
|
+
*/
|
|
232
|
+
async statedForTokens(destination) {
|
|
233
|
+
const serviceKeyStore = this.serviceKeyStore;
|
|
234
|
+
const means = serviceKeyStore
|
|
235
|
+
? await this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination))
|
|
236
|
+
: null;
|
|
237
|
+
const authType = means?.authType;
|
|
238
|
+
if (authType === 'basic' || authType === 'snc') {
|
|
239
|
+
throw new DestinationConfigError_1.DestinationConfigError(destination, ['authType'], `the token API serves no ${authType} destination: it holds no token`);
|
|
240
|
+
}
|
|
241
|
+
return means;
|
|
242
|
+
}
|
|
243
|
+
/** The token API on `getProvider`'s provider. */
|
|
244
|
+
async obtainShared(destination, method, means) {
|
|
245
|
+
if (!this.serviceKeyStore) {
|
|
246
|
+
throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider', 'serviceKeyStore'], 'the token API needs the provider option, or a service key store stating a token grant');
|
|
247
|
+
}
|
|
248
|
+
if ((means?.authType === 'jwt' || means?.authType === 'saml') &&
|
|
249
|
+
means.grantType === 'none') {
|
|
250
|
+
throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider'], 'a none destination presents a handed-over credential and obtains no token: use getProvider, or give the token API the provider option');
|
|
251
|
+
}
|
|
252
|
+
const provider = await this.getProvider(destination);
|
|
253
|
+
if (!obtainsTokens(provider)) {
|
|
254
|
+
throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider'], 'the destination states a credential that obtains no token: use getProvider, or give the token API the provider option');
|
|
255
|
+
}
|
|
256
|
+
this.logger.debug(`[AuthBroker] ${method} for ${destination}`);
|
|
257
|
+
const result = checked(destination, await provider[method]());
|
|
258
|
+
// onTokens wrote it, or failed to: the failure is this caller's too.
|
|
259
|
+
this.throwFailedWrite(destination, result);
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The token API on the consumer's provider, as 3.x: the session read first,
|
|
264
|
+
* then the key store, for `serviceUrl` and the client; every answer — a
|
|
265
|
+
* cache hit included — written, the secret alone, through the broker's one
|
|
266
|
+
* write path.
|
|
267
|
+
*/
|
|
268
|
+
async obtainFromConsumer(destination, method, means) {
|
|
111
269
|
const connConfig = await this.read(destination, 'session connection config', () => this.sessionStore.getConnectionConfig(destination));
|
|
112
270
|
const serviceUrl = await this.resolveServiceUrl(destination, connConfig);
|
|
113
|
-
const provider = await this.
|
|
271
|
+
const { provider, binding } = await this.consumerProviderFor(destination, serviceUrl, connConfig, means);
|
|
114
272
|
this.logger.debug(`[AuthBroker] ${method} for ${destination}`);
|
|
115
|
-
const result = await provider[method]();
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
273
|
+
const result = checked(destination, await provider[method]());
|
|
274
|
+
await this.writer.submit(destination, {
|
|
275
|
+
result: { ...result, expiresAt: expiryOf(result) },
|
|
276
|
+
original: result,
|
|
277
|
+
binding,
|
|
278
|
+
carry: 'any',
|
|
279
|
+
});
|
|
280
|
+
this.throwFailedWrite(destination, result);
|
|
281
|
+
return result;
|
|
121
282
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
283
|
+
/** The failure recorded for this result's write here, thrown as the store raised it. */
|
|
284
|
+
throwFailedWrite(destination, result) {
|
|
285
|
+
const failed = this.failedWrites.get(result)?.get(destination);
|
|
286
|
+
if (failed) {
|
|
287
|
+
throw failed.error;
|
|
125
288
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The consumer's provider for the destination, cached per destination as a
|
|
292
|
+
* promise set before its first read and dropped when it throws, so
|
|
293
|
+
* the next call builds again: a factory's build — handed the client resolved
|
|
294
|
+
* then — or an instance as given, for every destination, handed no client.
|
|
295
|
+
* Either way the binding is fixed here, from the `serviceUrl`, SAP client and
|
|
296
|
+
* client of this first call, and kept with the provider.
|
|
297
|
+
*/
|
|
298
|
+
consumerProviderFor(destination, serviceUrl, connConfig, means) {
|
|
299
|
+
const cached = this.consumerBuilt.get(destination);
|
|
300
|
+
if (cached) {
|
|
301
|
+
return cached;
|
|
129
302
|
}
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
303
|
+
const provider = this.provider;
|
|
304
|
+
const sapClient = present(connConfig?.sapClient)
|
|
305
|
+
? connConfig.sapClient
|
|
306
|
+
: means?.sapClient;
|
|
307
|
+
const build = Promise.resolve().then(async () => {
|
|
308
|
+
if (typeof provider !== 'function') {
|
|
309
|
+
return {
|
|
310
|
+
provider,
|
|
311
|
+
binding: (0, binding_1.consumerBinding)(serviceUrl, sapClient, null),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const client = await this.resolveAuthorizationConfig(destination);
|
|
315
|
+
const built = provider(destination, client, {
|
|
316
|
+
...(connConfig ?? {}),
|
|
317
|
+
serviceUrl,
|
|
318
|
+
});
|
|
319
|
+
this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
|
|
320
|
+
hasCredentials: !!client,
|
|
321
|
+
hasRefreshToken: !!client?.refreshToken,
|
|
322
|
+
hasStoredToken: !!(connConfig?.authorizationToken || connConfig?.sessionCookies),
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
provider: built,
|
|
326
|
+
binding: (0, binding_1.consumerBinding)(serviceUrl, sapClient, client),
|
|
327
|
+
};
|
|
134
328
|
});
|
|
135
|
-
this.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
329
|
+
this.consumerBuilt.set(destination, build);
|
|
330
|
+
build.catch(() => {
|
|
331
|
+
if (this.consumerBuilt.get(destination) === build) {
|
|
332
|
+
this.consumerBuilt.delete(destination);
|
|
333
|
+
}
|
|
140
334
|
});
|
|
141
|
-
return
|
|
335
|
+
return build;
|
|
142
336
|
}
|
|
143
337
|
/**
|
|
144
|
-
* The credentials
|
|
145
|
-
* them, else the service key's, carrying the refresh token the
|
|
146
|
-
* stored
|
|
147
|
-
*
|
|
148
|
-
* client secret into it; `loadSession` is where such a token is read.
|
|
338
|
+
* The credentials a consumer's factory is built with: the session's own when
|
|
339
|
+
* it holds them, else the service key's, carrying the refresh token the
|
|
340
|
+
* session stored — the 3.x order. A session store of auth-stores 3
|
|
341
|
+
* answers no client, so the key store's is what is found.
|
|
149
342
|
*/
|
|
150
343
|
async resolveAuthorizationConfig(destination) {
|
|
151
344
|
const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
|
|
@@ -168,6 +361,10 @@ class AuthBroker {
|
|
|
168
361
|
refreshToken: storedRefreshToken ?? keyAuth.refreshToken,
|
|
169
362
|
};
|
|
170
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* `serviceUrl` for a consumer's provider, in the 3.x order: the session's,
|
|
366
|
+
* else the key store's, else an error before the provider is asked.
|
|
367
|
+
*/
|
|
171
368
|
async resolveServiceUrl(destination, connConfig) {
|
|
172
369
|
let serviceUrl = connConfig?.serviceUrl;
|
|
173
370
|
const serviceKeyStore = this.serviceKeyStore;
|
|
@@ -181,53 +378,6 @@ class AuthBroker {
|
|
|
181
378
|
}
|
|
182
379
|
return serviceUrl;
|
|
183
380
|
}
|
|
184
|
-
/**
|
|
185
|
-
* Writes the result by its type: a SAML result is session cookies, anything
|
|
186
|
-
* else a bearer token. The refresh token is written only when the result has
|
|
187
|
-
* one, so a provider that returns none does not erase the stored one.
|
|
188
|
-
*
|
|
189
|
-
* `ITokenResult.expiresAt` has no field in `IConnectionConfig` to go to; the
|
|
190
|
-
* provider seeded with the stored token reads the expiry from the JWT itself.
|
|
191
|
-
*/
|
|
192
|
-
async persist(destination, serviceUrl, connConfig, result) {
|
|
193
|
-
const isSaml = result.tokenType === 'saml';
|
|
194
|
-
await this.sessionStore.setConnectionConfig(destination, {
|
|
195
|
-
...(connConfig ?? {}),
|
|
196
|
-
serviceUrl,
|
|
197
|
-
authorizationToken: isSaml ? undefined : result.authorizationToken,
|
|
198
|
-
sessionCookies: isSaml ? result.authorizationToken : undefined,
|
|
199
|
-
authType: isSaml ? 'saml' : 'jwt',
|
|
200
|
-
});
|
|
201
|
-
if (result.refreshToken) {
|
|
202
|
-
const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
|
|
203
|
-
if (sessionAuth) {
|
|
204
|
-
// The session holds its own credentials: only the refresh token changes.
|
|
205
|
-
await this.sessionStore.setAuthorizationConfig(destination, {
|
|
206
|
-
...sessionAuth,
|
|
207
|
-
refreshToken: result.refreshToken,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
// The credentials live in the service key and stay there. The session
|
|
212
|
-
// gets the refresh token alone — never the client secret.
|
|
213
|
-
const session = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
|
|
214
|
-
await this.sessionStore.saveSession(destination, {
|
|
215
|
-
...(session ?? {}),
|
|
216
|
-
serviceUrl: session?.serviceUrl ?? serviceUrl,
|
|
217
|
-
refreshToken: result.refreshToken,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
this.logger.info(`[AuthBroker] Token saved for ${destination}`, {
|
|
222
|
-
tokenType: result.tokenType ?? 'jwt',
|
|
223
|
-
authType: result.authType,
|
|
224
|
-
hasRefreshToken: !!result.refreshToken,
|
|
225
|
-
expiresAt: result.expiresAt
|
|
226
|
-
? new Date(result.expiresAt).toISOString()
|
|
227
|
-
: undefined,
|
|
228
|
-
expiresIn: result.expiresIn,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
381
|
/**
|
|
232
382
|
* A store read where absence is an answer and anything else is not.
|
|
233
383
|
*
|
|
@@ -252,34 +402,277 @@ class AuthBroker {
|
|
|
252
402
|
}
|
|
253
403
|
}
|
|
254
404
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
405
|
+
* The destination's client: the service key store's `uaaUrl`, `uaaClientId`
|
|
406
|
+
* and `uaaClientSecret`, with the refresh token the session stores laid over
|
|
407
|
+
* them; `null` when the key store has no client. A client a session store
|
|
408
|
+
* answers is not read, nor a refresh token a key store answers.
|
|
257
409
|
*/
|
|
258
410
|
async getAuthorizationConfig(destination) {
|
|
259
|
-
const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
|
|
260
|
-
if (sessionAuth) {
|
|
261
|
-
return sessionAuth;
|
|
262
|
-
}
|
|
263
411
|
const serviceKeyStore = this.serviceKeyStore;
|
|
264
|
-
|
|
412
|
+
const client = serviceKeyStore
|
|
413
|
+
? await this.read(destination, 'service key authorization config', () => serviceKeyStore.getAuthorizationConfig(destination))
|
|
414
|
+
: null;
|
|
415
|
+
if (!client) {
|
|
265
416
|
return null;
|
|
266
417
|
}
|
|
267
|
-
|
|
418
|
+
const secret = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
|
|
419
|
+
const composed = {
|
|
420
|
+
uaaUrl: client.uaaUrl,
|
|
421
|
+
uaaClientId: client.uaaClientId,
|
|
422
|
+
uaaClientSecret: client.uaaClientSecret,
|
|
423
|
+
};
|
|
424
|
+
if (secret?.refreshToken !== undefined) {
|
|
425
|
+
composed.refreshToken = secret.refreshToken;
|
|
426
|
+
}
|
|
427
|
+
return composed;
|
|
268
428
|
}
|
|
269
429
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
430
|
+
* The destination's connection config: the service key store's means with
|
|
431
|
+
* the session's secret (`authorizationToken`, `sessionCookies`,
|
|
432
|
+
* `expiresAt`) and its binding (`issuedFor`, `issuedBy`) laid over them; `null` when neither store holds anything.
|
|
433
|
+
* Means a session store answers are not read, nor a secret a key store
|
|
434
|
+
* answers.
|
|
272
435
|
*/
|
|
273
436
|
async getConnectionConfig(destination) {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
437
|
+
const serviceKeyStore = this.serviceKeyStore;
|
|
438
|
+
const means = serviceKeyStore
|
|
439
|
+
? await this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination))
|
|
440
|
+
: null;
|
|
441
|
+
const secret = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
|
|
442
|
+
const composed = {};
|
|
443
|
+
for (const [field, value] of Object.entries(means ?? {})) {
|
|
444
|
+
if (!isSecretField(field) && value !== undefined) {
|
|
445
|
+
composed[field] = value;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const field of SECRET_FIELDS) {
|
|
449
|
+
const value = secret?.[field];
|
|
450
|
+
if (value !== undefined) {
|
|
451
|
+
composed[field] = value;
|
|
452
|
+
}
|
|
277
453
|
}
|
|
454
|
+
return Object.keys(composed).length > 0 ? composed : null;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The credential for the destination, ready for a connector: the provider
|
|
458
|
+
* its configuration states (`authType`, and `grantType` for `jwt` / `saml`),
|
|
459
|
+
* built from the means the service key store holds and the secret the
|
|
460
|
+
* session store holds.
|
|
461
|
+
*
|
|
462
|
+
* One provider per destination for the broker's life: concurrent first calls
|
|
463
|
+
* share one build, and a build that threw is tried again on the next call.
|
|
464
|
+
*
|
|
465
|
+
* A token provider (the UAA, OIDC and SAML grants) is seeded from the
|
|
466
|
+
* session when the stored secret is bound to this destination, and writes
|
|
467
|
+
* every token it obtains — at `prepare()`, on expiry, or in `rejected()` after
|
|
468
|
+
* a 401 — back to the session store before it answers (see `flush()`).
|
|
469
|
+
*
|
|
470
|
+
* @throws DestinationConfigError when the destination lacks what its type
|
|
471
|
+
* needs — naming the fields or options, never a value.
|
|
472
|
+
*/
|
|
473
|
+
getProvider(destination) {
|
|
474
|
+
const cached = this.built.get(destination);
|
|
475
|
+
if (cached) {
|
|
476
|
+
return cached;
|
|
477
|
+
}
|
|
478
|
+
// Deferred by one tick, so the promise is in the cache before the build
|
|
479
|
+
// reads anything.
|
|
480
|
+
const build = Promise.resolve().then(() => this.build(destination));
|
|
481
|
+
this.built.set(destination, build);
|
|
482
|
+
build.catch(() => {
|
|
483
|
+
if (this.built.get(destination) === build) {
|
|
484
|
+
this.built.delete(destination);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
return build;
|
|
488
|
+
}
|
|
489
|
+
async build(destination) {
|
|
278
490
|
const serviceKeyStore = this.serviceKeyStore;
|
|
279
491
|
if (!serviceKeyStore) {
|
|
280
|
-
|
|
492
|
+
throw new DestinationConfigError_1.DestinationConfigError(destination, ['serviceKeyStore'], 'getProvider reads the means from a service key store, and none was given');
|
|
493
|
+
}
|
|
494
|
+
const means = await this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination));
|
|
495
|
+
const authType = (0, destinations_1.statedAuthType)(destination, means);
|
|
496
|
+
// statedAuthType refuses a destination without means.
|
|
497
|
+
const stated = means;
|
|
498
|
+
let provider;
|
|
499
|
+
if (authType === 'basic') {
|
|
500
|
+
provider = (0, destinations_1.basicProvider)(destination, stated);
|
|
501
|
+
}
|
|
502
|
+
else if (authType === 'snc') {
|
|
503
|
+
provider = (0, destinations_1.sncProvider)(destination, stated, this.logger);
|
|
281
504
|
}
|
|
282
|
-
|
|
505
|
+
else {
|
|
506
|
+
const grant = (0, destinations_1.statedGrant)(destination, authType, stated);
|
|
507
|
+
if (grant !== 'none') {
|
|
508
|
+
// A grant that obtains a secret: the row's client (none for
|
|
509
|
+
// saml2_pure), what a secret for this destination is bound to
|
|
510
|
+
// — written with every secret, and required of a stored one
|
|
511
|
+
// before it seeds — and the stored secret when it is.
|
|
512
|
+
const client = grant === 'saml2_pure'
|
|
513
|
+
? null
|
|
514
|
+
: await this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination));
|
|
515
|
+
const binding = (0, bindingOf_1.destinationBinding)(authType, grant, stated, client);
|
|
516
|
+
const stored = grant === 'client_credentials'
|
|
517
|
+
? null
|
|
518
|
+
: await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
|
|
519
|
+
const secret = this.boundOrDiscarded(destination, stored, binding);
|
|
520
|
+
// The expiry is fixed when the result arrives, not when a retry
|
|
521
|
+
// finally writes it — on a copy; the outcome of the write is recorded
|
|
522
|
+
// against the result itself, which the provider also returns to the
|
|
523
|
+
// token API.
|
|
524
|
+
const onTokens = (result) => this.writer.submit(destination, {
|
|
525
|
+
result: { ...result, expiresAt: expiryOf(result) },
|
|
526
|
+
original: result,
|
|
527
|
+
binding,
|
|
528
|
+
carry: 'bound',
|
|
529
|
+
});
|
|
530
|
+
const common = {
|
|
531
|
+
destination,
|
|
532
|
+
client,
|
|
533
|
+
secret,
|
|
534
|
+
logger: this.logger,
|
|
535
|
+
onTokens,
|
|
536
|
+
};
|
|
537
|
+
if ((0, destinations_1.isUaaGrant)(grant)) {
|
|
538
|
+
provider = (0, destinations_1.uaaProvider)({
|
|
539
|
+
...common,
|
|
540
|
+
grant,
|
|
541
|
+
authorization: this.authorization,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
else if ((0, destinations_1.isOidcGrant)(grant)) {
|
|
545
|
+
provider = (0, destinations_1.oidcProvider)({
|
|
546
|
+
...common,
|
|
547
|
+
grant,
|
|
548
|
+
means: stated,
|
|
549
|
+
oidcAuthorization: this.oidcAuthorization,
|
|
550
|
+
deviceCodePresenter: this.deviceCodePresenter,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
else if ((0, destinations_1.isSamlGrant)(grant)) {
|
|
554
|
+
provider = (0, destinations_1.samlProvider)({
|
|
555
|
+
...common,
|
|
556
|
+
grant,
|
|
557
|
+
means: stated,
|
|
558
|
+
authorization: this.authorization,
|
|
559
|
+
samlCookies: this.samlCookies,
|
|
560
|
+
assertionReplayStore: this.assertionReplayStore,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
// statedGrant admits only the allowed `authType` / `grantType` pairs.
|
|
565
|
+
throw new Error(`unreachable grant ${grant}`);
|
|
566
|
+
}
|
|
567
|
+
this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
|
|
568
|
+
authType,
|
|
569
|
+
grant,
|
|
570
|
+
seeded: !!secret,
|
|
571
|
+
});
|
|
572
|
+
return provider;
|
|
573
|
+
}
|
|
574
|
+
const secret = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
|
|
575
|
+
const client = authType === 'jwt'
|
|
576
|
+
? await this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination))
|
|
577
|
+
: null;
|
|
578
|
+
provider = (0, destinations_1.handedOverProvider)(destination, authType, secret, (0, bindingOf_1.destinationBinding)(authType, 'none', stated, client));
|
|
579
|
+
}
|
|
580
|
+
this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
|
|
581
|
+
authType,
|
|
582
|
+
});
|
|
583
|
+
return provider;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* The stored secret when it is bound to this destination's resource and
|
|
587
|
+
* issuer, else `null`: the provider is then built as with no session
|
|
588
|
+
* and logs in afresh — the refresh token is not spent either. The log line
|
|
589
|
+
* names the destination only: never a URI, never a token.
|
|
590
|
+
*/
|
|
591
|
+
boundOrDiscarded(destination, stored, binding) {
|
|
592
|
+
if (!stored || (0, binding_1.boundHere)(stored, binding))
|
|
593
|
+
return stored;
|
|
594
|
+
if (present(stored.authorizationToken) ||
|
|
595
|
+
present(stored.sessionCookies) ||
|
|
596
|
+
present(stored.refreshToken)) {
|
|
597
|
+
this.logger.warn(`[AuthBroker] ${destination}: secret bound to another resource, discarded`);
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Waits for the session writes still pending — each gets one more attempt —
|
|
603
|
+
* and rejects naming the destinations whose store still refuses. Call it on
|
|
604
|
+
* shutdown to know whether every token a provider obtained is stored; the
|
|
605
|
+
* broker keeps retrying after a rejection.
|
|
606
|
+
*/
|
|
607
|
+
flush() {
|
|
608
|
+
return this.writer.flush();
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* One write of the destination's session secret — and nothing else:
|
|
612
|
+
* `{ authorizationToken, expiresAt, refreshToken, issuedFor, issuedBy }` in
|
|
613
|
+
* one `saveSession` — or, for a `saml2_pure` result (`tokenType: 'saml'`),
|
|
614
|
+
* `{ sessionCookies, expiresAt, issuedFor, issuedBy }`: cookies are written
|
|
615
|
+
* as cookies, every other result as a token (`saml2_bearer` included). No means is ever written: not `serviceUrl`, not
|
|
616
|
+
* `authType`, not the client — they live in the key store, which the broker
|
|
617
|
+
* never writes.
|
|
618
|
+
*
|
|
619
|
+
* - `expiresAt`: fixed by `onTokens` when the result arrived — the result's
|
|
620
|
+
* own, else the `expiresIn` it reports counted from then (the rule the
|
|
621
|
+
* provider applies to its own cache).
|
|
622
|
+
* - `refreshToken`: the result's, else the one the session holds, read at
|
|
623
|
+
* write time, so a result without one does not erase the stored one — for
|
|
624
|
+
* a provider the broker built, only a stored one bound where this one is:
|
|
625
|
+
* a refresh token obtained for another resource or from another client is
|
|
626
|
+
* not carried into this secret. A consumer's provider was seeded
|
|
627
|
+
* with whatever the session held, so whatever it holds is carried.
|
|
628
|
+
* - `issuedFor` / `issuedBy`: the binding computed when the provider was
|
|
629
|
+
* built, each left out when the means lack its source — so the
|
|
630
|
+
* store clears it.
|
|
631
|
+
* - A destination the key store now states as `basic` or `snc` is not
|
|
632
|
+
* written: those obtain no session secret.
|
|
633
|
+
*/
|
|
634
|
+
async writeSecret(destination, { result, binding, carry }) {
|
|
635
|
+
const serviceKeyStore = this.serviceKeyStore;
|
|
636
|
+
const means = serviceKeyStore
|
|
637
|
+
? await this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination))
|
|
638
|
+
: null;
|
|
639
|
+
if (means?.authType === 'basic' || means?.authType === 'snc') {
|
|
640
|
+
this.logger.warn(`[AuthBroker] Not written: ${destination} is a ${means.authType} destination, which holds no session secret`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
let secret;
|
|
644
|
+
if (result.tokenType === 'saml') {
|
|
645
|
+
// saml2_pure: the cookies are the credential, and SAML has no refresh
|
|
646
|
+
// token to carry.
|
|
647
|
+
secret = {
|
|
648
|
+
sessionCookies: result.authorizationToken,
|
|
649
|
+
expiresAt: result.expiresAt,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
const stored = result.refreshToken
|
|
654
|
+
? null
|
|
655
|
+
: await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
|
|
656
|
+
const storedRefreshToken = present(stored?.refreshToken) &&
|
|
657
|
+
(carry === 'any' || (0, binding_1.boundHere)(stored, binding))
|
|
658
|
+
? stored.refreshToken
|
|
659
|
+
: undefined;
|
|
660
|
+
secret = {
|
|
661
|
+
authorizationToken: result.authorizationToken,
|
|
662
|
+
expiresAt: result.expiresAt,
|
|
663
|
+
refreshToken: result.refreshToken || storedRefreshToken,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (binding.issuedFor !== undefined)
|
|
667
|
+
secret.issuedFor = binding.issuedFor;
|
|
668
|
+
if (binding.issuedBy !== undefined)
|
|
669
|
+
secret.issuedBy = binding.issuedBy;
|
|
670
|
+
await this.sessionStore.saveSession(destination, secret);
|
|
671
|
+
this.logger.info(`[AuthBroker] Session secret saved for ${destination}`, {
|
|
672
|
+
credential: secret.sessionCookies !== undefined ? 'cookies' : 'token',
|
|
673
|
+
hasRefreshToken: !!secret.refreshToken,
|
|
674
|
+
expiresAt: result.expiresAt,
|
|
675
|
+
});
|
|
283
676
|
}
|
|
284
677
|
/**
|
|
285
678
|
* An `ITokenRefresher` for one destination, for injection into a connection:
|