@mcp-abap-adt/auth-broker 3.0.3 → 3.1.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.
@@ -1,566 +0,0 @@
1
- "use strict";
2
- /**
3
- * Pure config-building logic for `mcp-sso`, split out from bin/mcp-sso.ts so
4
- * it can be imported by tests directly.
5
- *
6
- * bin/mcp-sso.ts itself cannot be `import`ed safely: it `require()`s the
7
- * compiled `dist/index.js` at module load time (assuming it is always run
8
- * from `dist/bin/`) and calls `main()` at the bottom of the file. Neither is
9
- * true when a test loads the TypeScript source directly, so anything that
10
- * needs unit coverage — building an `SsoProviderConfig` from CLI options and
11
- * an optional `--config` file, and merging the two — lives here instead.
12
- */
13
- Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.INTERACTIVE_LOGIN_TIMEOUT_MS = void 0;
15
- exports.readManualInput = readManualInput;
16
- exports.normalizeProviderConfig = normalizeProviderConfig;
17
- exports.applyFileConfig = applyFileConfig;
18
- exports.readIdpCertificateFile = readIdpCertificateFile;
19
- exports.resolveIdpCertificates = resolveIdpCertificates;
20
- exports.parseSamlTrustArg = parseSamlTrustArg;
21
- exports.buildOidcBrowserConfig = buildOidcBrowserConfig;
22
- exports.buildOidcDeviceConfig = buildOidcDeviceConfig;
23
- exports.buildOidcPasswordConfig = buildOidcPasswordConfig;
24
- exports.buildOidcTokenExchangeConfig = buildOidcTokenExchangeConfig;
25
- exports.buildSamlBearerConfig = buildSamlBearerConfig;
26
- exports.buildSamlPureConfig = buildSamlPureConfig;
27
- exports.buildProviderConfig = buildProviderConfig;
28
- const node_fs_1 = require("node:fs");
29
- const node_path_1 = require("node:path");
30
- const node_readline_1 = require("node:readline");
31
- const auth_providers_1 = require("@mcp-abap-adt/auth-providers");
32
- /**
33
- * A person completes these logins at a browser; the library's own default
34
- * (30s) is sized for an unattended caller instead.
35
- */
36
- exports.INTERACTIVE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
37
- function readManualInput(prompt) {
38
- const rl = (0, node_readline_1.createInterface)({
39
- input: process.stdin,
40
- output: process.stdout,
41
- });
42
- // A closed stdin never answers the question, and a promise that never
43
- // settles lets the event loop drain: the process exited 0 with nothing
44
- // written, which a script reads as success. End of input is a refusal.
45
- return new Promise((resolve, reject) => {
46
- let answered = false;
47
- rl.on('close', () => {
48
- if (!answered)
49
- reject(new Error(`no input: stdin closed at "${prompt.trim()}"`));
50
- });
51
- rl.question(prompt, (answer) => {
52
- answered = true;
53
- rl.close();
54
- resolve(answer.trim());
55
- });
56
- });
57
- }
58
- function normalizeProviderConfig(raw) {
59
- if (!raw || typeof raw !== 'object') {
60
- return null;
61
- }
62
- if (raw.provider) {
63
- return raw.provider;
64
- }
65
- if (raw.protocol && raw.flow) {
66
- const { protocol, flow, config, ...rest } = raw;
67
- return {
68
- protocol,
69
- flow,
70
- config: config ?? rest,
71
- };
72
- }
73
- return null;
74
- }
75
- /**
76
- * Pre-2.0.0 provider config fields that named a JavaScript function
77
- * (`() => Promise<string>` or similar) rather than a value. JSON cannot
78
- * carry a function, so a `--config` file can never legitimately supply one —
79
- * if a key like this shows up, the file is stale in a way no conversion can
80
- * fix. Refuse rather than silently drop it, and say what to write instead.
81
- */
82
- const UNSUPPORTED_LEGACY_CONFIG_FIELDS = {
83
- authorizationCodeProvider: "'authorizationCodeProvider' (a function) cannot be expressed in a --config file. Use --code for a value you already hold, or drive the OIDC browser flow interactively without it.",
84
- assertionProvider: "'assertionProvider' (a function) cannot be expressed in a --config file. Use --assertion for a value you already hold, or --assertion-flow to choose how it is obtained.",
85
- manualInput: "'manualInput' (a function) cannot be expressed in a --config file. Use --assertion-flow manual to prompt on this CLI's own stdin instead.",
86
- };
87
- /**
88
- * `--config` file fields that map 1:1 onto a `McpSsoOptions` field of the
89
- * same name — every pre-2.0.0 config field the strategies still need, other
90
- * than `authorizationCode` (see `applyFileConfig`), lands here.
91
- */
92
- const CONFIG_BACKFILL_FIELDS = [
93
- 'browser',
94
- 'redirectPort',
95
- 'redirectUri',
96
- 'issuerUrl',
97
- 'authorizationEndpoint',
98
- 'tokenEndpoint',
99
- 'deviceAuthorizationEndpoint',
100
- 'clientId',
101
- 'clientSecret',
102
- 'scopes',
103
- 'scope',
104
- 'username',
105
- 'password',
106
- 'passcode',
107
- 'subjectToken',
108
- 'subjectTokenType',
109
- 'audience',
110
- 'actorToken',
111
- 'actorTokenType',
112
- 'idpSsoUrl',
113
- 'spEntityId',
114
- 'acsUrl',
115
- 'relayState',
116
- 'assertionFlow',
117
- 'assertion',
118
- 'cookie',
119
- 'uaaUrl',
120
- 'idpEntityId',
121
- 'idpMetadata',
122
- 'authnRequestId',
123
- ];
124
- /**
125
- * Backfills `options` in place from a `--config` file's raw `config` object.
126
- * CLI flags always win — a field already set on `options` is left alone; the
127
- * file only fills gaps.
128
- *
129
- * This is what makes a config-file-only run (no --protocol/--flow on the
130
- * CLI) reach the same strategy-building code as a CLI-flag run: without it,
131
- * the file's fields — including a `browser`/`redirectPort` a 2.0.0 provider
132
- * no longer accepts directly — would reach `SsoProviderFactory.create()`
133
- * untouched and be silently ignored.
134
- *
135
- * Every field a pre-2.0.0 config could carry either lands on its
136
- * `McpSsoOptions` equivalent here (which the strategy built from `options`
137
- * then consumes) or the run is refused via `process.exit(1)` naming what to
138
- * write instead. Nothing may reach the provider without one of those two
139
- * happening.
140
- *
141
- * Also backfills `options.protocol`/`options.flow` from the file when the
142
- * CLI didn't set them — a `--config`-only run (no `--protocol`/`--flow`
143
- * flags at all) must still resolve to a real flow, or nothing downstream
144
- * ever calls a builder at all.
145
- *
146
- * A no-op when `fileConfig` is `null` (no `--config` was given), so callers
147
- * can invoke this unconditionally.
148
- */
149
- function applyFileConfig(options, fileConfig) {
150
- if (!fileConfig) {
151
- return;
152
- }
153
- options.protocol = options.protocol ?? fileConfig.protocol;
154
- options.flow = options.flow ?? fileConfig.flow;
155
- const fields = (fileConfig.config ?? {});
156
- for (const [field, guidance] of Object.entries(UNSUPPORTED_LEGACY_CONFIG_FIELDS)) {
157
- if (fields[field] !== undefined) {
158
- console.error(`❌ --config: ${guidance}`);
159
- process.exit(1);
160
- }
161
- }
162
- // Pre-2.0.0 field name for the OIDC manual/OOB code paste path; maps onto
163
- // the same slot --code fills.
164
- if (options.code === undefined &&
165
- typeof fields.authorizationCode === 'string') {
166
- options.code = fields.authorizationCode;
167
- }
168
- for (const field of CONFIG_BACKFILL_FIELDS) {
169
- if (options[field] === undefined && fields[field] !== undefined) {
170
- options[field] = fields[field];
171
- }
172
- }
173
- if (fields.idpInitiated !== undefined) {
174
- // A string "false" would be truthy and silently declare the opposite.
175
- if (typeof fields.idpInitiated !== 'boolean') {
176
- console.error("❌ --config: 'idpInitiated' must be true or false.");
177
- process.exit(1);
178
- }
179
- options.idpInitiated = options.idpInitiated ?? fields.idpInitiated;
180
- }
181
- if (fields.idpCertificates !== undefined) {
182
- const raw = fields.idpCertificates;
183
- const list = typeof raw === 'string' ? [raw] : raw;
184
- if (!Array.isArray(list) ||
185
- !list.every((entry) => typeof entry === 'string')) {
186
- console.error("❌ --config: 'idpCertificates' must be a string or a list of strings (PEM or base64 DER).");
187
- process.exit(1);
188
- }
189
- // Trust is replaced, never widened: a certificate named on the CLI means
190
- // the file's list is not trusted alongside it. A rotated-out certificate
191
- // left in the file must not keep verifying assertions.
192
- if (options.idpCertificates === undefined &&
193
- options.idpCertificateFiles === undefined) {
194
- options.idpCertificates = list;
195
- }
196
- }
197
- }
198
- const PEM_CERTIFICATE_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
199
- const BASE64_TEXT = /^[A-Za-z0-9+/]+={0,2}$/;
200
- /**
201
- * Reads one `--idp-cert` file into the entries `idpCertificates` takes. A
202
- * PEM file may hold several certificates (a rotation bundle); each becomes
203
- * its own entry, since a single string is read as one certificate and the
204
- * rest would be ignored. A binary DER file (`.cer`, `.der`) is base64-encoded.
205
- * Whether the result is a certificate at all is the provider's check.
206
- */
207
- function readIdpCertificateFile(filePath) {
208
- const resolved = (0, node_path_1.resolve)(filePath);
209
- let content;
210
- try {
211
- content = (0, node_fs_1.readFileSync)(resolved);
212
- }
213
- catch {
214
- console.error(`❌ IdP certificate file not found: ${resolved}`);
215
- process.exit(1);
216
- }
217
- const text = content.toString('utf8');
218
- const blocks = text.match(PEM_CERTIFICATE_BLOCK);
219
- if (blocks) {
220
- return blocks;
221
- }
222
- if (text.includes('-----BEGIN') ||
223
- BASE64_TEXT.test(text.replace(/\s+/g, ''))) {
224
- return [text.trim()];
225
- }
226
- return [content.toString('base64')];
227
- }
228
- /**
229
- * The certificates the SAML providers are given: inline ones (a `--config`
230
- * file) and those read from `--idp-cert` files. `undefined` when there are
231
- * none, so the provider's own `ValidationError` names what is missing.
232
- */
233
- function resolveIdpCertificates(options) {
234
- const certificates = [
235
- ...(options.idpCertificates ?? []),
236
- ...(options.idpCertificateFiles ?? []).flatMap(readIdpCertificateFile),
237
- ];
238
- return certificates.length > 0 ? certificates : undefined;
239
- }
240
- /**
241
- * Parses the SAML trust flags into `target`, returning how many values after
242
- * `arg` were consumed (0 for a flag with no value, or an argument that is not
243
- * one of these). Kept here rather than in bin/mcp-sso.ts so it can be tested.
244
- */
245
- function parseSamlTrustArg(target, arg, next) {
246
- switch (arg) {
247
- case '--idp-cert':
248
- if (!next || next.startsWith('--')) {
249
- console.error('❌ --idp-cert needs a certificate file path.');
250
- process.exit(1);
251
- }
252
- // Repeatable: every occurrence adds a certificate, for key rotation.
253
- target.idpCertificateFiles = [
254
- ...(target.idpCertificateFiles ?? []),
255
- next,
256
- ];
257
- return 1;
258
- case '--idp-entity-id':
259
- target.idpEntityId = next;
260
- return 1;
261
- case '--idp-metadata':
262
- if (!next || next.startsWith('--')) {
263
- console.error('❌ --idp-metadata needs an https URL or a file path.');
264
- process.exit(1);
265
- }
266
- target.idpMetadata = next;
267
- return 1;
268
- case '--idp-initiated':
269
- // A flag with no value. Left undefined when absent, so a --config
270
- // file's idpInitiated can still fill it.
271
- target.idpInitiated = true;
272
- return 0;
273
- case '--authn-request-id':
274
- target.authnRequestId = next;
275
- return 1;
276
- default:
277
- return 0;
278
- }
279
- }
280
- function requireOption(value, flagName) {
281
- if (!value) {
282
- console.error(`❌ ${flagName} is required for this flow.`);
283
- process.exit(1);
284
- }
285
- return value;
286
- }
287
- function resolveOidcTokenEndpoint(options) {
288
- return (options.tokenEndpoint ||
289
- (options.uaaUrl
290
- ? `${options.uaaUrl.replace(/\/+$/, '')}/oauth/token`
291
- : undefined));
292
- }
293
- /**
294
- * Only the OIDC 'browser' flow opens a browser; routes `--browser`,
295
- * `--redirect-port` and manual/OOB code paste into the strategy that
296
- * replaces them.
297
- */
298
- function buildOidcBrowserAuthorization(options) {
299
- if (options.code) {
300
- // The consumer already holds the code (manual paste / OOB redirect
301
- // URI); no callback server is opened at all.
302
- return (0, auth_providers_1.asOidcResult)((0, auth_providers_1.staticCodeStrategy)({
303
- redirectUri: options.redirectUri,
304
- payload: options.code,
305
- }));
306
- }
307
- // No fallback: an omitted --redirect-port lets the strategy bind its own
308
- // default port rather than this CLI pinning a number it doesn't own.
309
- return (0, auth_providers_1.oidcCallbackStrategy)({
310
- port: options.redirectPort,
311
- browser: options.browser,
312
- timeoutMs: exports.INTERACTIVE_LOGIN_TIMEOUT_MS,
313
- });
314
- }
315
- function buildOidcCommon(options) {
316
- return {
317
- issuerUrl: options.issuerUrl,
318
- clientId: requireOption(options.clientId, '--client-id'),
319
- clientSecret: options.clientSecret,
320
- };
321
- }
322
- function buildOidcBrowserConfig(options) {
323
- return {
324
- ...buildOidcCommon(options),
325
- authorizationEndpoint: options.authorizationEndpoint,
326
- tokenEndpoint: resolveOidcTokenEndpoint(options),
327
- scopes: options.scopes,
328
- authorization: buildOidcBrowserAuthorization(options),
329
- };
330
- }
331
- function buildOidcDeviceConfig(options) {
332
- // Device flow never opens a browser from this process; --browser and
333
- // --redirect-port have nothing to attach to here.
334
- return {
335
- ...buildOidcCommon(options),
336
- deviceAuthorizationEndpoint: options.deviceAuthorizationEndpoint,
337
- tokenEndpoint: resolveOidcTokenEndpoint(options),
338
- scopes: options.scopes,
339
- };
340
- }
341
- function buildOidcPasswordConfig(options) {
342
- // Password flow never opens a browser; --browser and --redirect-port are
343
- // not meaningful here either.
344
- const passcode = options.passcode;
345
- const username = options.username || (passcode ? 'passcode' : undefined);
346
- const password = options.password || passcode;
347
- return {
348
- ...buildOidcCommon(options),
349
- username: requireOption(username, '--username (or --passcode)'),
350
- password: requireOption(password, '--password (or --passcode)'),
351
- tokenEndpoint: resolveOidcTokenEndpoint(options),
352
- scopes: options.scopes,
353
- };
354
- }
355
- function buildOidcTokenExchangeConfig(options) {
356
- // Token exchange never opens a browser; --browser and --redirect-port are
357
- // not meaningful here either.
358
- return {
359
- ...buildOidcCommon(options),
360
- subjectToken: requireOption(options.subjectToken, '--subject-token'),
361
- subjectTokenType: options.subjectTokenType ||
362
- 'urn:ietf:params:oauth:token-type:access_token',
363
- scope: options.scope,
364
- audience: options.audience,
365
- actorToken: options.actorToken,
366
- actorTokenType: options.actorTokenType,
367
- tokenEndpoint: resolveOidcTokenEndpoint(options),
368
- };
369
- }
370
- /**
371
- * Both SAML flows (bearer, pure) can open a browser; routes `--browser`,
372
- * `--redirect-port` and the manual/static assertion options into the
373
- * strategy that replaces them.
374
- */
375
- function buildSamlAuthorization(options) {
376
- if (options.assertion) {
377
- // The consumer already holds the assertion; nothing is opened or asked.
378
- return (0, auth_providers_1.staticCodeStrategy)({
379
- redirectUri: options.acsUrl,
380
- payload: options.assertion,
381
- });
382
- }
383
- if (options.idpInitiated) {
384
- return buildIdpInitiatedAuthorization(options);
385
- }
386
- const assertionFlow = options.assertionFlow || 'browser';
387
- if (assertionFlow !== 'browser') {
388
- // 'manual', and an 'assertion' flow given no value, both need a human to
389
- // lift the SAMLResponse out of the POST body by hand.
390
- return (0, auth_providers_1.manualSamlResponseStrategy)({
391
- redirectUri: options.acsUrl,
392
- read: readManualInput,
393
- });
394
- }
395
- // No fallback: an omitted --redirect-port lets the strategy bind its own
396
- // default port rather than this CLI pinning a number it doesn't own.
397
- return (0, auth_providers_1.samlCallbackStrategy)({
398
- port: options.redirectPort,
399
- browser: options.browser,
400
- timeoutMs: exports.INTERACTIVE_LOGIN_TIMEOUT_MS,
401
- });
402
- }
403
- /**
404
- * An IdP-initiated login has no URL for this CLI to open: the only one
405
- * auth-providers can build carries an AuthnRequest, and with `idpInitiated`
406
- * it refuses to build one. So the strategy never calls
407
- * `buildAuthorizationUrl` — the user starts the login at the identity
408
- * provider and pastes the SAMLResponse it posts. The browser flow, which
409
- * exists to open that URL, is refused rather than left to fail after the
410
- * provider is built.
411
- */
412
- function buildIdpInitiatedAuthorization(options) {
413
- if (options.assertionFlow === 'browser') {
414
- console.error('❌ --idp-initiated cannot use --assertion-flow browser: there is no request URL to open. ' +
415
- 'Start the login at the identity provider and use --assertion-flow manual, or pass --assertion.');
416
- process.exit(1);
417
- }
418
- // The ACS the assertion names as its Recipient; the same fallback the
419
- // auth-providers strategies use when none is declared.
420
- const redirectUri = options.acsUrl ?? `http://localhost:${auth_providers_1.DEFAULT_CALLBACK_PORT}/callback`;
421
- return {
422
- async authorize() {
423
- const payload = await readManualInput('Start the login at your identity provider, then paste the SAMLResponse (from the POST body): ');
424
- if (!payload) {
425
- throw new Error('No SAMLResponse was provided');
426
- }
427
- return { payload, redirectUri };
428
- },
429
- };
430
- }
431
- function buildSamlCookieProvider(options) {
432
- const assertionFlow = options.assertionFlow || (options.assertion ? 'assertion' : 'browser');
433
- return async (samlResponse) => {
434
- if (options.cookie) {
435
- return options.cookie;
436
- }
437
- if (assertionFlow === 'assertion') {
438
- return `SAMLResponse=${samlResponse}`;
439
- }
440
- return readManualInput('Paste session cookies: ');
441
- };
442
- }
443
- /**
444
- * What auth-providers 4 validates an assertion against. Absent trust material
445
- * is passed as absent: the provider's constructor names what is missing.
446
- */
447
- function buildSamlTrust(options) {
448
- return {
449
- idpCertificates: resolveIdpCertificates(options),
450
- idpEntityId: options.idpEntityId,
451
- idpInitiated: options.idpInitiated,
452
- authnRequestId: options.authnRequestId,
453
- };
454
- }
455
- function buildSamlBearerConfig(options) {
456
- return {
457
- idpSsoUrl: requireOption(options.idpSsoUrl, '--idp-sso-url'),
458
- spEntityId: requireOption(options.spEntityId, '--sp-entity-id'),
459
- acsUrl: options.acsUrl,
460
- relayState: options.relayState,
461
- ...buildSamlTrust(options),
462
- tokenUrl: options.tokenEndpoint,
463
- uaaUrl: options.uaaUrl,
464
- clientId: options.clientId,
465
- clientSecret: options.clientSecret,
466
- authorization: buildSamlAuthorization(options),
467
- };
468
- }
469
- function buildSamlPureConfig(options) {
470
- return {
471
- idpSsoUrl: requireOption(options.idpSsoUrl, '--idp-sso-url'),
472
- spEntityId: requireOption(options.spEntityId, '--sp-entity-id'),
473
- acsUrl: options.acsUrl,
474
- relayState: options.relayState,
475
- ...buildSamlTrust(options),
476
- authorization: buildSamlAuthorization(options),
477
- cookieProvider: buildSamlCookieProvider(options),
478
- };
479
- }
480
- /**
481
- * Builds the effective `SsoProviderConfig` from `options` alone.
482
- *
483
- * `options` must already be the *merged* view — `--config` file fields
484
- * backfilled beneath whatever the CLI flags set, via `applyFileConfig` — so
485
- * this never needs a separate file/CLI merge step, and required-field
486
- * validation (`requireOption`, inside each builder above) sees the same
487
- * merged data regardless of whether a value came from the file or a flag.
488
- */
489
- function buildProviderConfig(options, existingAuth, existingConn) {
490
- if (!options.protocol || !options.flow) {
491
- throw new Error('Provider config is missing. Use --config or --protocol/--flow options.');
492
- }
493
- let result;
494
- if (options.protocol === 'oidc') {
495
- switch (options.flow) {
496
- case 'browser':
497
- result = {
498
- protocol: 'oidc',
499
- flow: 'browser',
500
- config: buildOidcBrowserConfig(options),
501
- };
502
- break;
503
- case 'device':
504
- result = {
505
- protocol: 'oidc',
506
- flow: 'device',
507
- config: buildOidcDeviceConfig(options),
508
- };
509
- break;
510
- case 'password':
511
- result = {
512
- protocol: 'oidc',
513
- flow: 'password',
514
- config: buildOidcPasswordConfig(options),
515
- };
516
- break;
517
- case 'token_exchange':
518
- result = {
519
- protocol: 'oidc',
520
- flow: 'token_exchange',
521
- config: buildOidcTokenExchangeConfig(options),
522
- };
523
- break;
524
- default:
525
- throw new Error(`Unsupported OIDC flow: ${options.flow}`);
526
- }
527
- }
528
- else if (options.protocol === 'saml2') {
529
- switch (options.flow) {
530
- case 'bearer':
531
- result = {
532
- protocol: 'saml2',
533
- flow: 'bearer',
534
- config: buildSamlBearerConfig(options),
535
- };
536
- break;
537
- case 'pure':
538
- result = {
539
- protocol: 'saml2',
540
- flow: 'pure',
541
- config: buildSamlPureConfig(options),
542
- };
543
- break;
544
- default:
545
- throw new Error(`Unsupported SAML flow: ${options.flow}`);
546
- }
547
- }
548
- else {
549
- throw new Error(`Unsupported protocol: ${options.protocol}`);
550
- }
551
- const accessToken = existingConn?.authorizationToken;
552
- const refreshToken = existingAuth?.refreshToken;
553
- if ((result.protocol === 'oidc' ||
554
- (result.protocol === 'saml2' && result.flow === 'bearer')) &&
555
- (accessToken || refreshToken)) {
556
- result = {
557
- ...result,
558
- config: {
559
- ...result.config,
560
- accessToken,
561
- refreshToken,
562
- },
563
- };
564
- }
565
- return result;
566
- }