@mcp-abap-adt/auth-broker 2.2.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,6 +61,7 @@ const distPath = path.resolve(__dirname, '..', 'index.js');
61
61
  const { AuthBroker } = require(distPath);
62
62
  const auth_providers_1 = require("@mcp-abap-adt/auth-providers");
63
63
  const auth_stores_1 = require("@mcp-abap-adt/auth-stores");
64
+ const workDir_1 = require("./workDir");
64
65
  /**
65
66
  * A person completes this login at a browser; the provider's own default
66
67
  * (30s) is sized for an unattended caller instead.
@@ -116,6 +117,20 @@ function showHelp() {
116
117
  console.log(' --redirect-port <port> Port for OAuth redirect URI (default: from auth-providers, currently 61001).');
117
118
  console.log(' Must match XSUAA redirect-uris config.');
118
119
  console.log('');
120
+ console.log('SAML (saml2-pure, saml2-bearer; passed to mcp-sso, see mcp-sso --help for all):');
121
+ console.log(' --idp-metadata <url|path> IdP SAML metadata (https or file): certificate, entityID, SSO URL');
122
+ console.log(' e.g. https://<ias-tenant>.accounts.ondemand.com/saml2/metadata');
123
+ console.log(' --idp-cert <path> IdP signing certificate (PEM or DER); repeat for key rotation');
124
+ console.log(' --idp-entity-id <id> IdP entityID; the Issuer the assertion must name');
125
+ console.log(' --idp-sso-url <url> IdP SSO endpoint (read from --idp-metadata when not given)');
126
+ console.log(' --idp-initiated The IdP starts the login; required for saml2-bearer against XSUAA');
127
+ console.log(' --authn-request-id <id> AuthnRequest ID an --assertion answers, when sent elsewhere');
128
+ console.log(' --sp-entity-id <id> SP entityID, the Audience (saml2-bearer: read from XSUAA metadata)');
129
+ console.log(' --acs-url <url> The Recipient (saml2-bearer: read from XSUAA metadata)');
130
+ console.log(' --saml-metadata <path> XSUAA SP metadata file; with --service-key <uaa.url>/saml/metadata is read');
131
+ console.log(' --assertion <base64> A SAMLResponse obtained elsewhere');
132
+ console.log(' --assertion-flow <flow> browser|manual|assertion');
133
+ console.log('');
119
134
  console.log(' --version, -v Show version number');
120
135
  console.log(' --help, -h Show this help message');
121
136
  console.log('');
@@ -126,11 +141,11 @@ function showHelp() {
126
141
  console.log(' # OIDC SSO (device/password/browser/token-exchange)');
127
142
  console.log(' mcp-auth oidc --flow device --issuer https://issuer --client-id my-client --output ./sso.env --type xsuaa');
128
143
  console.log('');
129
- console.log(' # SAML2 pure (cookies)');
130
- console.log(' mcp-auth saml2-pure --idp-sso-url https://idp/sso --sp-entity-id my-sp --output ./saml.env --type abap');
144
+ console.log(' # SAML2 pure (cookies); every assertion is validated: see mcp-sso --help');
145
+ console.log(' mcp-auth saml2-pure --idp-sso-url https://idp/sso --sp-entity-id my-sp --idp-cert ./idp.pem --idp-entity-id https://idp/metadata --output ./saml.env --type abap');
131
146
  console.log('');
132
147
  console.log(' # SAML2 bearer (in progress, requires --dev)');
133
- console.log(' mcp-auth saml2-bearer --dev --service-key ./service-key.json --assertion <base64> --output ./sso.env --type xsuaa');
148
+ console.log(' mcp-auth saml2-bearer --dev --service-key ./service-key.json --idp-metadata https://<ias-tenant>.accounts.ondemand.com/saml2/metadata --idp-initiated --output ./sso.env --type xsuaa');
134
149
  console.log('');
135
150
  console.log(' # XSUAA with authorization_code (default, opens browser)');
136
151
  console.log(' mcp-auth --service-key ./service-key.json --output ./mcp.env --type xsuaa');
@@ -378,6 +393,12 @@ function runMcpSso(args) {
378
393
  }
379
394
  process.exit(result.status ?? 1);
380
395
  }
396
+ /** The run's private working directory, created on first use and removed on any exit. */
397
+ let runWorkDir;
398
+ function workDir() {
399
+ runWorkDir ??= (0, workDir_1.createWorkDir)('mcp-auth');
400
+ return runWorkDir;
401
+ }
381
402
  async function main() {
382
403
  const rawArgs = process.argv.slice(2);
383
404
  const subcommand = rawArgs[0];
@@ -481,9 +502,7 @@ async function main() {
481
502
  console.log('🔍 Detected "credentials" wrapper -> unwrapping to temp file');
482
503
  effectiveJson = json.credentials;
483
504
  // Create temp file with unwrapped content to make it compatible with standard stores
484
- const tempDir = path.join(path.dirname(resolvedOutputPath), '.tmp');
485
- if (!fs.existsSync(tempDir))
486
- fs.mkdirSync(tempDir, { recursive: true });
505
+ const tempDir = workDir();
487
506
  const tempKeyPath = path.join(tempDir, `${destination}.json`);
488
507
  fs.writeFileSync(tempKeyPath, JSON.stringify(effectiveJson, null, 2));
489
508
  // Point store to temp directory so it reads the unwrapped file
@@ -547,10 +566,7 @@ async function main() {
547
566
  throw new Error('Env file not found and no service key provided.');
548
567
  }
549
568
  // Create temporary session store (work off a temp copy of env file)
550
- const tempSessionDir = path.join(path.dirname(resolvedOutputPath), '.tmp');
551
- if (!fs.existsSync(tempSessionDir)) {
552
- fs.mkdirSync(tempSessionDir, { recursive: true });
553
- }
569
+ const tempSessionDir = workDir();
554
570
  if (envExists && resolvedEnvPath) {
555
571
  const tempEnvPath = path.join(tempSessionDir, `${destination}.env`);
556
572
  fs.copyFileSync(resolvedEnvPath, tempEnvPath);
@@ -571,7 +587,7 @@ async function main() {
571
587
  const brokerServiceUrl = actualServiceUrl || '<SERVICE_URL>';
572
588
  const sessionStore = options.authType === 'xsuaa'
573
589
  ? new auth_stores_1.XsuaaSessionStore(tempSessionDir, brokerServiceUrl)
574
- : new auth_stores_1.AbapSessionStore(tempSessionDir);
590
+ : new auth_stores_1.AbapSessionStore(tempSessionDir, undefined, actualServiceUrl);
575
591
  const sessionAuthConfig = await sessionStore.getAuthorizationConfig(destination);
576
592
  let serviceKeyAuthConfig = null;
577
593
  if (serviceKeyStore?.getAuthorizationConfig) {
@@ -638,15 +654,29 @@ async function main() {
638
654
  timeoutMs: INTERACTIVE_LOGIN_TIMEOUT_MS,
639
655
  }),
640
656
  });
657
+ // The output file is a self-contained session: whoever reads it refreshes
658
+ // the token with the UAA credentials it carries, and has no service key.
659
+ // So this CLI writes the credentials into its temporary session itself —
660
+ // its own decision about its own output. The broker no longer copies the
661
+ // client secret into a session store, and without these the refresh token
662
+ // the login obtains would not be readable back below.
663
+ if (!sessionAuthConfig) {
664
+ await sessionStore.setAuthorizationConfig(destination, authConfig);
665
+ }
641
666
  const broker = new AuthBroker({
642
667
  sessionStore,
643
668
  serviceKeyStore: serviceKeyStore || undefined,
644
- tokenProvider,
645
- }, options.browser);
669
+ provider: tokenProvider,
670
+ });
646
671
  console.log(`🔐 Getting token for destination "${destination}"...`);
647
672
  const token = await broker.getToken(destination);
648
673
  console.log(`✅ Token obtained successfully`);
649
674
  const connConfig = await sessionStore.getConnectionConfig(destination);
675
+ // What the login left in the session, not what was known before it:
676
+ // `authConfig` came from the service key, which holds no refresh token,
677
+ // and the refresh token the login obtained went into the session store.
678
+ // Writing `authConfig` dropped it with the temporary session directory.
679
+ const savedAuthConfig = (await sessionStore.getAuthorizationConfig(destination)) ?? authConfig;
650
680
  if (!token) {
651
681
  throw new Error(`Token provider did not return authorization token for destination "${destination}"`);
652
682
  }
@@ -655,7 +685,7 @@ async function main() {
655
685
  const finalServiceUrl = outputServiceUrl === '<SERVICE_URL>' ? undefined : outputServiceUrl;
656
686
  // Write output file based on format
657
687
  if (options.format === 'env') {
658
- writeEnvFile(resolvedOutputPath, options.authType, token, authConfig?.refreshToken, finalServiceUrl, authConfig?.uaaUrl, authConfig?.uaaClientId, authConfig?.uaaClientSecret);
688
+ writeEnvFile(resolvedOutputPath, options.authType, token, savedAuthConfig?.refreshToken, finalServiceUrl, savedAuthConfig?.uaaUrl, savedAuthConfig?.uaaClientId, savedAuthConfig?.uaaClientSecret);
659
689
  console.log(`✅ .env file created: ${resolvedOutputPath}`);
660
690
  // Show what was written
661
691
  console.log(`📋 .env file contains:`);
@@ -663,40 +693,33 @@ async function main() {
663
693
  if (finalServiceUrl) {
664
694
  console.log(` - ${auth_stores_1.ABAP_CONNECTION_VARS.SERVICE_URL}=${finalServiceUrl}`);
665
695
  }
666
- console.log(` - ${auth_stores_1.ABAP_CONNECTION_VARS.AUTHORIZATION_TOKEN}=${token.substring(0, 50)}...`);
667
- if (authConfig?.refreshToken) {
668
- console.log(` - ${auth_stores_1.ABAP_AUTHORIZATION_VARS.REFRESH_TOKEN}=${authConfig.refreshToken.substring(0, 50)}...`);
696
+ console.log(` - ${auth_stores_1.ABAP_CONNECTION_VARS.AUTHORIZATION_TOKEN}=<redacted, ${token.length} chars>`);
697
+ if (savedAuthConfig?.refreshToken) {
698
+ console.log(` - ${auth_stores_1.ABAP_AUTHORIZATION_VARS.REFRESH_TOKEN}=<redacted, ${savedAuthConfig.refreshToken.length} chars>`);
669
699
  }
670
700
  }
671
701
  else {
672
702
  if (finalServiceUrl) {
673
703
  console.log(` - XSUAA_MCP_URL=${finalServiceUrl}`);
674
704
  }
675
- console.log(` - ${auth_stores_1.XSUAA_CONNECTION_VARS.AUTHORIZATION_TOKEN}=${token.substring(0, 50)}...`);
676
- if (authConfig?.refreshToken) {
677
- console.log(` - ${auth_stores_1.XSUAA_AUTHORIZATION_VARS.REFRESH_TOKEN}=${authConfig.refreshToken.substring(0, 50)}...`);
705
+ console.log(` - ${auth_stores_1.XSUAA_CONNECTION_VARS.AUTHORIZATION_TOKEN}=<redacted, ${token.length} chars>`);
706
+ if (savedAuthConfig?.refreshToken) {
707
+ console.log(` - ${auth_stores_1.XSUAA_AUTHORIZATION_VARS.REFRESH_TOKEN}=<redacted, ${savedAuthConfig.refreshToken.length} chars>`);
678
708
  }
679
709
  }
680
710
  }
681
711
  else {
682
- writeJsonFile(resolvedOutputPath, token, authConfig?.refreshToken, finalServiceUrl, authConfig?.uaaUrl, authConfig?.uaaClientId, authConfig?.uaaClientSecret);
712
+ writeJsonFile(resolvedOutputPath, token, savedAuthConfig?.refreshToken, finalServiceUrl, savedAuthConfig?.uaaUrl, savedAuthConfig?.uaaClientId, savedAuthConfig?.uaaClientSecret);
683
713
  console.log(`✅ JSON file created: ${resolvedOutputPath}`);
684
714
  console.log(`📋 Output contains:`);
685
- console.log(` - accessToken: ${token.substring(0, 50)}...`);
686
- if (authConfig?.refreshToken) {
687
- console.log(` - refreshToken: ${authConfig.refreshToken.substring(0, 50)}...`);
715
+ console.log(` - accessToken: <redacted, ${token.length} chars>`);
716
+ if (savedAuthConfig?.refreshToken) {
717
+ console.log(` - refreshToken: <redacted, ${savedAuthConfig.refreshToken.length} chars>`);
688
718
  }
689
719
  if (finalServiceUrl) {
690
720
  console.log(` - serviceUrl: ${finalServiceUrl}`);
691
721
  }
692
722
  }
693
- // Cleanup temp directory
694
- try {
695
- fs.rmSync(tempSessionDir, { recursive: true, force: true });
696
- }
697
- catch {
698
- // Ignore cleanup errors
699
- }
700
723
  // Exit explicitly to close any open handles (e.g., OAuth callback server)
701
724
  process.exit(0);
702
725
  }
@@ -20,11 +20,15 @@
20
20
  * # OIDC token exchange
21
21
  * mcp-sso oidc --flow token_exchange --issuer https://issuer --client-id my-client --subject-token <token> --output ./sso.env --type xsuaa
22
22
  *
23
- * # SAML bearer flow
24
- * mcp-sso bearer --idp-sso-url https://idp/sso --sp-entity-id my-sp --token-endpoint https://uaa.example/oauth/token --assertion <base64> --output ./sso.env --type xsuaa
23
+ * # SAML bearer flow with a service key: the Audience, Recipient and token alias come from
24
+ * # <uaa.url>/saml/metadata, the IdP's trust from its metadata
25
+ * mcp-sso bearer --service-key ./service-key.json --idp-metadata https://<ias-tenant>.accounts.ondemand.com/saml2/metadata --idp-initiated --output ./sso.env --type xsuaa
26
+ *
27
+ * # SAML bearer flow, everything stated (IdP-initiated assertion, as UAA/XSUAA require)
28
+ * mcp-sso bearer --idp-sso-url https://idp/sso --sp-entity-id <uaa-entity-id> --acs-url <uaa-bearer-acs> --idp-cert ./idp-signing.pem --idp-entity-id https://idp/metadata --idp-initiated --token-endpoint https://uaa.example/oauth/token --assertion <base64> --output ./sso.env --type xsuaa
25
29
  *
26
30
  * # SAML pure flow (cookie)
27
- * mcp-sso saml2 --flow pure --idp-sso-url https://idp/sso --sp-entity-id my-sp --assertion <base64> --cookie "SAP_SESSION=..." --output ./sso.env --type abap
31
+ * mcp-sso saml2 --flow pure --idp-sso-url https://idp/sso --sp-entity-id my-sp --idp-cert ./idp-signing.pem --idp-entity-id https://idp/metadata --cookie "SAP_SESSION=..." --output ./sso.env --type abap
28
32
  */
29
33
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
30
34
  if (k2 === undefined) k2 = k;
@@ -69,6 +73,8 @@ const auth_providers_1 = require("@mcp-abap-adt/auth-providers");
69
73
  const auth_stores_1 = require("@mcp-abap-adt/auth-stores");
70
74
  const logger_1 = require("@mcp-abap-adt/logger");
71
75
  const mcpSsoConfig_1 = require("./mcpSsoConfig");
76
+ const samlMetadata_1 = require("./samlMetadata");
77
+ const workDir_1 = require("./workDir");
72
78
  function getVersion() {
73
79
  try {
74
80
  const candidates = [
@@ -137,15 +143,28 @@ function showHelp() {
137
143
  console.log('');
138
144
  console.log('SAML Options:');
139
145
  console.log(' --idp-sso-url <url> IdP SSO URL');
140
- console.log(' --sp-entity-id <id> SP Entity ID');
141
- console.log(' --acs-url <url> ACS URL (default: http://localhost:<port>/callback)');
142
- console.log(' --saml-metadata <path> SAML metadata XML (to resolve token alias)');
146
+ console.log(' --sp-entity-id <id> SP Entity ID; also the Audience the assertion must name');
147
+ console.log(' --acs-url <url> ACS URL; the Recipient the assertion must name (default: http://localhost:<port>/callback)');
148
+ console.log(' --idp-cert <path> IdP signing certificate file (PEM or DER); repeat for key rotation');
149
+ console.log(' --idp-entity-id <id> IdP entityID; the Issuer the assertion must name');
150
+ console.log(' --idp-metadata <url|path> IdP SAML metadata (https or file): fills --idp-cert, --idp-entity-id');
151
+ console.log(' and --idp-sso-url where not given, e.g. https://<ias>/saml2/metadata');
152
+ console.log(' --idp-initiated The IdP starts the login; no AuthnRequest is sent (required for bearer');
153
+ console.log(' against UAA/XSUAA). Use with --assertion or --assertion-flow manual');
154
+ console.log(' --authn-request-id <id> AuthnRequest ID an --assertion answers (SP-initiated, request sent elsewhere)');
155
+ console.log(' --saml-metadata <path> XSUAA SP metadata; bearer reads it for the token alias, --acs-url and');
156
+ console.log(' --sp-entity-id. With --service-key, <uaa.url>/saml/metadata is read instead');
143
157
  console.log(' --relay-state <value> RelayState (optional)');
144
- console.log(' --assertion-flow <flow> browser|manual|assertion (default: browser)');
158
+ console.log(' --assertion-flow <flow> browser|manual|assertion (default: browser; manual with --idp-initiated)');
145
159
  console.log(' --assertion <base64> SAMLResponse (base64)');
146
160
  console.log(' --cookie <value> Session cookies (for pure SAML)');
147
161
  console.log(' --token-endpoint <url> Token endpoint for SAML bearer exchange');
148
162
  console.log('');
163
+ console.log(' Every SAML assertion is validated before use. Both SAML flows require --idp-cert');
164
+ console.log(' and --idp-entity-id, or --idp-metadata (or idpCertificates/idpEntityId in --config). An --assertion');
165
+ console.log(' also needs --idp-initiated or --authn-request-id; the browser and manual flows');
166
+ console.log(' send their own request unless --idp-initiated is given.');
167
+ console.log('');
149
168
  console.log(' --version, -v Show version number');
150
169
  console.log(' --help, -h Show this help message');
151
170
  }
@@ -246,6 +265,7 @@ function parseArgs() {
246
265
  let cookie;
247
266
  let uaaUrl;
248
267
  let samlMetadataPath;
268
+ const samlTrust = {};
249
269
  const firstArg = args[0];
250
270
  if (firstArg && !firstArg.startsWith('-')) {
251
271
  if (firstArg === 'oidc') {
@@ -458,6 +478,7 @@ function parseArgs() {
458
478
  i++;
459
479
  break;
460
480
  default:
481
+ i += (0, mcpSsoConfig_1.parseSamlTrustArg)(samlTrust, arg, next);
461
482
  break;
462
483
  }
463
484
  }
@@ -501,13 +522,9 @@ function parseArgs() {
501
522
  cookie,
502
523
  uaaUrl,
503
524
  samlMetadataPath,
525
+ ...samlTrust,
504
526
  };
505
527
  }
506
- function resolveSamlTokenAlias(metadataXml) {
507
- const regex = /<md:AssertionConsumerService[^>]*Location="([^"]*\/oauth\/token\/alias\/[^"]+)"/i;
508
- const match = metadataXml.match(regex);
509
- return match?.[1];
510
- }
511
528
  async function main() {
512
529
  const options = parseArgs();
513
530
  if (!options) {
@@ -587,6 +604,9 @@ async function main() {
587
604
  // are left alone; the file only fills what they didn't set. A no-op when
588
605
  // --config wasn't given.
589
606
  (0, mcpSsoConfig_1.applyFileConfig)(options, providerConfigFromFile);
607
+ // The user's own --token-endpoint, before a service key sets the plain
608
+ // /oauth/token, which a saml2-bearer grant must not use.
609
+ const explicitTokenEndpoint = options.tokenEndpoint;
590
610
  if (options.serviceKeyPath) {
591
611
  const resolvedServiceKeyPath = path.resolve(options.serviceKeyPath);
592
612
  const serviceKeyDir = path.dirname(resolvedServiceKeyPath);
@@ -614,19 +634,16 @@ async function main() {
614
634
  options.authorizationEndpoint = `${uaaUrl.replace(/\/+$/, '')}/oauth/authorize`;
615
635
  }
616
636
  }
617
- if (options.samlMetadataPath) {
618
- const resolvedMetadataPath = path.resolve(options.samlMetadataPath);
619
- if (!fs.existsSync(resolvedMetadataPath)) {
620
- console.error(`❌ SAML metadata file not found: ${resolvedMetadataPath}`);
621
- process.exit(1);
622
- }
623
- const metadataXml = fs.readFileSync(resolvedMetadataPath, 'utf8');
624
- const aliasUrl = resolveSamlTokenAlias(metadataXml);
625
- if (!aliasUrl) {
626
- console.error('❌ SAML metadata does not contain token alias endpoint.');
627
- process.exit(1);
628
- }
629
- options.tokenEndpoint = aliasUrl;
637
+ // What the SAML metadata states and the caller did not: the identity
638
+ // provider's trust from --idp-metadata, and for saml2-bearer the Audience,
639
+ // Recipient and token endpoint from XSUAA's own metadata (--saml-metadata,
640
+ // else <uaa.url>/saml/metadata from the service key).
641
+ try {
642
+ await (0, samlMetadata_1.applySamlMetadata)(options, explicitTokenEndpoint);
643
+ }
644
+ catch (error) {
645
+ console.error(`❌ SAML metadata: ${error instanceof Error ? error.message : String(error)}`);
646
+ process.exit(1);
630
647
  }
631
648
  if (options.protocol === 'oidc' && options.flow === 'password') {
632
649
  if (options.uaaUrl && !options.tokenEndpoint) {
@@ -652,10 +669,8 @@ async function main() {
652
669
  options.passcode = await (0, mcpSsoConfig_1.readManualInput)('Paste passcode: ');
653
670
  }
654
671
  }
655
- const tempSessionDir = path.join(path.dirname(resolvedOutputPath), '.tmp');
656
- if (!fs.existsSync(tempSessionDir)) {
657
- fs.mkdirSync(tempSessionDir, { recursive: true });
658
- }
672
+ // Removed on any exit, error and signal included: it holds the secret.
673
+ const tempSessionDir = (0, workDir_1.createWorkDir)('mcp-sso');
659
674
  if (resolvedEnvPath && fs.existsSync(resolvedEnvPath)) {
660
675
  const tempEnvPath = path.join(tempSessionDir, `${destination}.env`);
661
676
  fs.copyFileSync(resolvedEnvPath, tempEnvPath);
@@ -706,21 +721,20 @@ async function main() {
706
721
  refreshToken: existingAuth?.refreshToken,
707
722
  });
708
723
  }
709
- const providerConfig = (0, mcpSsoConfig_1.buildProviderConfig)(options, existingAuth, existingConn);
710
- const providerConfigWithLogger = providerConfig.config
724
+ const withLogger = (config) => config.config
711
725
  ? {
712
- ...providerConfig,
726
+ ...config,
713
727
  config: {
714
- ...providerConfig.config,
715
- logger: providerConfig.config?.logger ?? logger,
728
+ ...config.config,
729
+ logger: config.config?.logger ?? logger,
716
730
  },
717
731
  }
718
- : providerConfig;
719
- const tokenProvider = auth_providers_1.SsoProviderFactory.create(providerConfigWithLogger);
732
+ : config;
733
+ const tokenProvider = auth_providers_1.SsoProviderFactory.create(withLogger((0, mcpSsoConfig_1.buildProviderConfig)(options, existingAuth, existingConn)));
720
734
  const broker = new AuthBroker({
721
735
  sessionStore,
722
- tokenProvider,
723
- }, options.browser, logger);
736
+ provider: tokenProvider,
737
+ }, logger);
724
738
  console.log(`🔐 Getting token for destination "${destination}"...`);
725
739
  await broker.getToken(destination);
726
740
  console.log(`✅ Token obtained successfully`);
@@ -796,12 +810,6 @@ async function main() {
796
810
  fs.writeFileSync(resolvedOutputPath, JSON.stringify(outputData, null, 2), 'utf8');
797
811
  console.log(`✅ JSON file created: ${resolvedOutputPath}`);
798
812
  }
799
- try {
800
- fs.rmSync(tempSessionDir, { recursive: true, force: true });
801
- }
802
- catch {
803
- // ignore cleanup errors
804
- }
805
813
  }
806
814
  main().catch((error) => {
807
815
  console.error(`❌ Error: ${error.message}`);
@@ -15,6 +15,9 @@ exports.INTERACTIVE_LOGIN_TIMEOUT_MS = void 0;
15
15
  exports.readManualInput = readManualInput;
16
16
  exports.normalizeProviderConfig = normalizeProviderConfig;
17
17
  exports.applyFileConfig = applyFileConfig;
18
+ exports.readIdpCertificateFile = readIdpCertificateFile;
19
+ exports.resolveIdpCertificates = resolveIdpCertificates;
20
+ exports.parseSamlTrustArg = parseSamlTrustArg;
18
21
  exports.buildOidcBrowserConfig = buildOidcBrowserConfig;
19
22
  exports.buildOidcDeviceConfig = buildOidcDeviceConfig;
20
23
  exports.buildOidcPasswordConfig = buildOidcPasswordConfig;
@@ -22,6 +25,8 @@ exports.buildOidcTokenExchangeConfig = buildOidcTokenExchangeConfig;
22
25
  exports.buildSamlBearerConfig = buildSamlBearerConfig;
23
26
  exports.buildSamlPureConfig = buildSamlPureConfig;
24
27
  exports.buildProviderConfig = buildProviderConfig;
28
+ const node_fs_1 = require("node:fs");
29
+ const node_path_1 = require("node:path");
25
30
  const node_readline_1 = require("node:readline");
26
31
  const auth_providers_1 = require("@mcp-abap-adt/auth-providers");
27
32
  /**
@@ -34,8 +39,17 @@ function readManualInput(prompt) {
34
39
  input: process.stdin,
35
40
  output: process.stdout,
36
41
  });
37
- return new Promise((resolve) => {
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
+ });
38
51
  rl.question(prompt, (answer) => {
52
+ answered = true;
39
53
  rl.close();
40
54
  resolve(answer.trim());
41
55
  });
@@ -103,6 +117,9 @@ const CONFIG_BACKFILL_FIELDS = [
103
117
  'assertion',
104
118
  'cookie',
105
119
  'uaaUrl',
120
+ 'idpEntityId',
121
+ 'idpMetadata',
122
+ 'authnRequestId',
106
123
  ];
107
124
  /**
108
125
  * Backfills `options` in place from a `--config` file's raw `config` object.
@@ -153,6 +170,112 @@ function applyFileConfig(options, fileConfig) {
153
170
  options[field] = fields[field];
154
171
  }
155
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
+ }
156
279
  }
157
280
  function requireOption(value, flagName) {
158
281
  if (!value) {
@@ -257,6 +380,9 @@ function buildSamlAuthorization(options) {
257
380
  payload: options.assertion,
258
381
  });
259
382
  }
383
+ if (options.idpInitiated) {
384
+ return buildIdpInitiatedAuthorization(options);
385
+ }
260
386
  const assertionFlow = options.assertionFlow || 'browser';
261
387
  if (assertionFlow !== 'browser') {
262
388
  // 'manual', and an 'assertion' flow given no value, both need a human to
@@ -274,6 +400,34 @@ function buildSamlAuthorization(options) {
274
400
  timeoutMs: exports.INTERACTIVE_LOGIN_TIMEOUT_MS,
275
401
  });
276
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
+ }
277
431
  function buildSamlCookieProvider(options) {
278
432
  const assertionFlow = options.assertionFlow || (options.assertion ? 'assertion' : 'browser');
279
433
  return async (samlResponse) => {
@@ -286,12 +440,25 @@ function buildSamlCookieProvider(options) {
286
440
  return readManualInput('Paste session cookies: ');
287
441
  };
288
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
+ }
289
455
  function buildSamlBearerConfig(options) {
290
456
  return {
291
457
  idpSsoUrl: requireOption(options.idpSsoUrl, '--idp-sso-url'),
292
458
  spEntityId: requireOption(options.spEntityId, '--sp-entity-id'),
293
459
  acsUrl: options.acsUrl,
294
460
  relayState: options.relayState,
461
+ ...buildSamlTrust(options),
295
462
  tokenUrl: options.tokenEndpoint,
296
463
  uaaUrl: options.uaaUrl,
297
464
  clientId: options.clientId,
@@ -305,6 +472,7 @@ function buildSamlPureConfig(options) {
305
472
  spEntityId: requireOption(options.spEntityId, '--sp-entity-id'),
306
473
  acsUrl: options.acsUrl,
307
474
  relayState: options.relayState,
475
+ ...buildSamlTrust(options),
308
476
  authorization: buildSamlAuthorization(options),
309
477
  cookieProvider: buildSamlCookieProvider(options),
310
478
  };