@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.
- package/CHANGELOG.md +235 -0
- package/README.md +324 -284
- package/dist/AuthBroker.d.ts +81 -106
- package/dist/AuthBroker.d.ts.map +1 -1
- package/dist/AuthBroker.js +209 -567
- package/dist/bin/mcp-auth.js +54 -31
- package/dist/bin/mcp-sso.js +52 -44
- package/dist/bin/mcpSsoConfig.js +169 -1
- package/dist/bin/samlMetadata.js +175 -0
- package/dist/bin/workDir.js +79 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/providers/ITokenProvider.d.ts +2 -2
- package/dist/providers/ITokenProvider.d.ts.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/package.json +12 -8
- package/dist/utils/formatting.d.ts +0 -16
- package/dist/utils/formatting.d.ts.map +0 -1
- package/dist/utils/formatting.js +0 -34
package/dist/bin/mcp-auth.js
CHANGED
|
@@ -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 --
|
|
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 =
|
|
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 =
|
|
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
|
-
}
|
|
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,
|
|
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}
|
|
667
|
-
if (
|
|
668
|
-
console.log(` - ${auth_stores_1.ABAP_AUTHORIZATION_VARS.REFRESH_TOKEN}
|
|
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}
|
|
676
|
-
if (
|
|
677
|
-
console.log(` - ${auth_stores_1.XSUAA_AUTHORIZATION_VARS.REFRESH_TOKEN}
|
|
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,
|
|
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.
|
|
686
|
-
if (
|
|
687
|
-
console.log(` - refreshToken: ${
|
|
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
|
}
|
package/dist/bin/mcp-sso.js
CHANGED
|
@@ -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
|
-
*
|
|
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 --
|
|
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(' --
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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
|
|
710
|
-
const providerConfigWithLogger = providerConfig.config
|
|
724
|
+
const withLogger = (config) => config.config
|
|
711
725
|
? {
|
|
712
|
-
...
|
|
726
|
+
...config,
|
|
713
727
|
config: {
|
|
714
|
-
...
|
|
715
|
-
logger:
|
|
728
|
+
...config.config,
|
|
729
|
+
logger: config.config?.logger ?? logger,
|
|
716
730
|
},
|
|
717
731
|
}
|
|
718
|
-
:
|
|
719
|
-
const tokenProvider = auth_providers_1.SsoProviderFactory.create(
|
|
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
|
-
},
|
|
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}`);
|
package/dist/bin/mcpSsoConfig.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|