@mcp-abap-adt/auth-providers 2.2.2 → 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 +193 -0
- package/README.md +825 -64
- package/dist/__tests__/integration/stand/formLogin.d.ts +68 -0
- package/dist/__tests__/integration/stand/formLogin.d.ts.map +1 -0
- package/dist/__tests__/integration/stand/formLogin.js +194 -0
- package/dist/auth/callbackServer.js +2 -2
- package/dist/auth/passcodeAuth.d.ts +25 -0
- package/dist/auth/passcodeAuth.d.ts.map +1 -0
- package/dist/auth/passcodeAuth.js +62 -0
- package/dist/auth/saml2Auth.d.ts +6 -2
- package/dist/auth/saml2Auth.d.ts.map +1 -1
- package/dist/auth/saml2Auth.js +9 -20
- package/dist/auth/samlBearerAssertion.d.ts +24 -0
- package/dist/auth/samlBearerAssertion.d.ts.map +1 -0
- package/dist/auth/samlBearerAssertion.js +102 -0
- package/dist/auth/strictXml.d.ts +13 -0
- package/dist/auth/strictXml.d.ts.map +1 -0
- package/dist/auth/strictXml.js +21 -0
- package/dist/errors/AssertionValidationError.d.ts +15 -0
- package/dist/errors/AssertionValidationError.d.ts.map +1 -0
- package/dist/errors/AssertionValidationError.js +24 -0
- package/dist/errors/TokenProviderErrors.d.ts +2 -0
- package/dist/errors/TokenProviderErrors.d.ts.map +1 -1
- package/dist/errors/TokenProviderErrors.js +3 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/providers/Saml2BearerProvider.d.ts +1 -0
- package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
- package/dist/providers/Saml2BearerProvider.js +20 -2
- package/dist/providers/Saml2PureProvider.d.ts +1 -0
- package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
- package/dist/providers/Saml2PureProvider.js +19 -5
- package/dist/providers/UaaPasscodeProvider.d.ts +43 -0
- package/dist/providers/UaaPasscodeProvider.d.ts.map +1 -0
- package/dist/providers/UaaPasscodeProvider.js +86 -0
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +3 -3
- package/dist/providers/saml2Utils.d.ts +49 -2
- package/dist/providers/saml2Utils.d.ts.map +1 -1
- package/dist/providers/saml2Utils.js +94 -2
- package/dist/strategies/index.d.ts +1 -1
- package/dist/strategies/index.d.ts.map +1 -1
- package/dist/strategies/index.js +2 -1
- package/dist/strategies/manualStrategies.d.ts +7 -0
- package/dist/strategies/manualStrategies.d.ts.map +1 -1
- package/dist/strategies/manualStrategies.js +21 -0
- package/dist/validation/assertionValidator.d.ts +28 -0
- package/dist/validation/assertionValidator.d.ts.map +1 -0
- package/dist/validation/assertionValidator.js +444 -0
- package/dist/validation/documentIds.d.ts +15 -0
- package/dist/validation/documentIds.d.ts.map +1 -0
- package/dist/validation/documentIds.js +32 -0
- package/dist/validation/inMemoryReplayStore.d.ts +22 -0
- package/dist/validation/inMemoryReplayStore.d.ts.map +1 -0
- package/dist/validation/inMemoryReplayStore.js +49 -0
- package/dist/validation/signedNode.d.ts +54 -0
- package/dist/validation/signedNode.d.ts.map +1 -0
- package/dist/validation/signedNode.js +171 -0
- package/dist/validation/xsdDateTime.d.ts +17 -0
- package/dist/validation/xsdDateTime.d.ts.map +1 -0
- package/dist/validation/xsdDateTime.js +67 -0
- package/package.json +14 -8
- package/bin/auth-device-flow.ts +0 -114
- package/dist/auth/deviceFlowAuth.d.ts +0 -43
- package/dist/auth/deviceFlowAuth.d.ts.map +0 -1
- package/dist/auth/deviceFlowAuth.js +0 -168
- package/dist/providers/DeviceFlowProvider.d.ts +0 -32
- package/dist/providers/DeviceFlowProvider.d.ts.map +0 -1
- package/dist/providers/DeviceFlowProvider.js +0 -86
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Which element the signature covers.
|
|
4
|
+
*
|
|
5
|
+
* Not "is there a valid signature" — that question has a true answer in a
|
|
6
|
+
* document built for a wrapping attack, where a genuinely signed fragment sits
|
|
7
|
+
* beside a forged one. The caller must read the element this returns and no
|
|
8
|
+
* other.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.toPem = toPem;
|
|
12
|
+
exports.quoteUntrusted = quoteUntrusted;
|
|
13
|
+
exports.resolveSignedElements = resolveSignedElements;
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const xml_crypto_1 = require("xml-crypto");
|
|
16
|
+
const DSIG_NS = 'http://www.w3.org/2000/09/xmldsig#';
|
|
17
|
+
/**
|
|
18
|
+
* PEM in, PEM out; bare base64 DER gets its armour — and the result is proved
|
|
19
|
+
* to be a certificate before anything uses it.
|
|
20
|
+
*
|
|
21
|
+
* The spec promises `idpCertificates` accepts either, and a consumer copying
|
|
22
|
+
* `<X509Certificate>` out of identity-provider metadata has bare base64 DER in
|
|
23
|
+
* their hand — the armour is not in the metadata. `xml-crypto` accepts only
|
|
24
|
+
* PEM or a Buffer: measured, a bare base64 certificate makes OpenSSL throw
|
|
25
|
+
* `DECODER routines::unsupported`, while the same bytes re-armoured verify.
|
|
26
|
+
*
|
|
27
|
+
* Left unnormalised that throw would be swallowed by the verification loop and
|
|
28
|
+
* reported as "the signature does not verify against any configured
|
|
29
|
+
* certificate" — blaming the assertion for the consumer's formatting.
|
|
30
|
+
*
|
|
31
|
+
* Base64 syntax alone does not make a string a certificate: `"AAAA"` passes
|
|
32
|
+
* the character class, armours cleanly, and fails only inside OpenSSL, which
|
|
33
|
+
* lands back at the same misleading message. So the armoured result is parsed
|
|
34
|
+
* with `node:crypto`'s `X509Certificate`, which rejects `"AAAA"` with
|
|
35
|
+
* `asn1 encoding routines::wrong tag` — measured, not assumed. Called once per
|
|
36
|
+
* certificate at construction, so the cost never falls on a login.
|
|
37
|
+
*/
|
|
38
|
+
function toPem(certificate) {
|
|
39
|
+
const trimmed = certificate.trim();
|
|
40
|
+
const pem = trimmed.includes('-----BEGIN')
|
|
41
|
+
? trimmed
|
|
42
|
+
: (() => {
|
|
43
|
+
const body = trimmed.replace(/\s+/g, '');
|
|
44
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) {
|
|
45
|
+
throw new Error('a configured certificate is neither PEM nor base64 DER');
|
|
46
|
+
}
|
|
47
|
+
const wrapped = body.replace(/(.{64})/g, '$1\n').trim();
|
|
48
|
+
return `-----BEGIN CERTIFICATE-----\n${wrapped}\n-----END CERTIFICATE-----\n`;
|
|
49
|
+
})();
|
|
50
|
+
try {
|
|
51
|
+
new node_crypto_1.X509Certificate(pem);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`a configured certificate is not a valid X.509 certificate: ${error.message}`);
|
|
55
|
+
}
|
|
56
|
+
return pem;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A value taken from the document before any signature has been verified,
|
|
60
|
+
* made safe to put in a message: JSON-quoted, so a newline smuggled in as
|
|
61
|
+
* ` ` shows as `\n` rather than forging a line in a log, and cut to 64
|
|
62
|
+
* characters, so an attacker cannot fill a log with it.
|
|
63
|
+
*/
|
|
64
|
+
function quoteUntrusted(value) {
|
|
65
|
+
const limit = 64;
|
|
66
|
+
return JSON.stringify(value.length > limit ? `${value.slice(0, limit)}…` : value);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Verifies every signature in the document against the certificates and
|
|
70
|
+
* returns the elements they reference. Throws when there is no signature, or
|
|
71
|
+
* when any one of them fails a rule below.
|
|
72
|
+
*
|
|
73
|
+
* `doc` must be the parse of `xml`, and nothing else: signatures are found and
|
|
74
|
+
* their references resolved in `doc`, while `xml-crypto` verifies the digests
|
|
75
|
+
* over `xml`. Handed a `doc` from different bytes, the element returned would
|
|
76
|
+
* not be the one whose bytes were verified.
|
|
77
|
+
*
|
|
78
|
+
* Several signatures are normal — identity providers often sign the Response
|
|
79
|
+
* and the Assertion both. Each is held to every rule on its own; the caller
|
|
80
|
+
* then takes the element it requires from the returned list.
|
|
81
|
+
*/
|
|
82
|
+
function resolveSignedElements(xml, doc, certificates) {
|
|
83
|
+
const signatures = doc.getElementsByTagNameNS(DSIG_NS, 'Signature');
|
|
84
|
+
if (signatures.length === 0) {
|
|
85
|
+
throw new Error('the document carries no signature');
|
|
86
|
+
}
|
|
87
|
+
// Every signature is held to every rule: an extra signature that fails is
|
|
88
|
+
// something that should not be there, not noise to skip. Two verifying
|
|
89
|
+
// signatures over one element cannot occur — under the enveloped-signature
|
|
90
|
+
// transform the later one changes the bytes the earlier one's digest
|
|
91
|
+
// covered — so no separate rule guards against it.
|
|
92
|
+
const covered = [];
|
|
93
|
+
for (let i = 0; i < signatures.length; i++) {
|
|
94
|
+
covered.push(resolveOne(xml, doc, signatures[i], certificates));
|
|
95
|
+
}
|
|
96
|
+
return covered;
|
|
97
|
+
}
|
|
98
|
+
/** One signature: verified, exactly one reference, enveloped by its target. */
|
|
99
|
+
function resolveOne(xml, doc, signatureNode, certificates) {
|
|
100
|
+
let verified = false;
|
|
101
|
+
for (const certificate of certificates) {
|
|
102
|
+
// Already normalised and proved at construction, so a throw here really
|
|
103
|
+
// is a bad signature rather than a formatting mistake.
|
|
104
|
+
// getCertFromKeyInfo is pinned to "none": a certificate the document
|
|
105
|
+
// carries in its own KeyInfo is the sender's claim about who signed it,
|
|
106
|
+
// and xml-crypto prefers it to publicCert when the hook returns one. It
|
|
107
|
+
// defaults to a no-op in 6.x; stating it keeps a future default from
|
|
108
|
+
// quietly letting an attacker's embedded certificate verify their own
|
|
109
|
+
// signature.
|
|
110
|
+
const verifier = new xml_crypto_1.SignedXml({
|
|
111
|
+
publicCert: certificate,
|
|
112
|
+
getCertFromKeyInfo: () => null,
|
|
113
|
+
});
|
|
114
|
+
verifier.loadSignature(signatureNode);
|
|
115
|
+
try {
|
|
116
|
+
// Returns false for a digest mismatch and throws when the signature
|
|
117
|
+
// value itself fails. Both mean "not this certificate".
|
|
118
|
+
if (verifier.checkSignature(xml)) {
|
|
119
|
+
verified = true;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Try the next certificate in the rotation list.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!verified) {
|
|
128
|
+
throw new Error('the signature does not verify against any configured certificate');
|
|
129
|
+
}
|
|
130
|
+
// The signature is valid; now find what it actually covers. Exactly one
|
|
131
|
+
// reference: two would be two candidate answers to "what is signed", the
|
|
132
|
+
// ambiguity this module exists to remove.
|
|
133
|
+
const references = signatureNode.getElementsByTagNameNS(DSIG_NS, 'Reference');
|
|
134
|
+
if (references.length !== 1) {
|
|
135
|
+
throw new Error(`the signature carries ${references.length} references; exactly one is required`);
|
|
136
|
+
}
|
|
137
|
+
const uri = references[0].getAttribute('URI') ?? '';
|
|
138
|
+
let referenced = null;
|
|
139
|
+
if (uri === '') {
|
|
140
|
+
// An empty URI signs the whole document. It must still satisfy the
|
|
141
|
+
// enveloping rule below — returning early here is exactly how a detached
|
|
142
|
+
// signature with an empty reference walks straight past that rule.
|
|
143
|
+
referenced = doc.documentElement;
|
|
144
|
+
}
|
|
145
|
+
else if (!uri.startsWith('#')) {
|
|
146
|
+
throw new Error(`the signature reference is not a same-document URI: ${quoteUntrusted(uri)}`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const id = uri.slice(1);
|
|
150
|
+
const elements = doc.getElementsByTagName('*');
|
|
151
|
+
for (let i = 0; i < elements.length; i++) {
|
|
152
|
+
if (elements[i].getAttribute('ID') === id) {
|
|
153
|
+
referenced = elements[i];
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!referenced) {
|
|
159
|
+
throw new Error(`the signature references ${quoteUntrusted(uri)}, which is not in the document`);
|
|
160
|
+
}
|
|
161
|
+
// The enveloped signature must sit inside the element it references. A
|
|
162
|
+
// signature moved elsewhere stays cryptographically valid over the bytes it
|
|
163
|
+
// covers, so the maths alone will not catch it — this comparison is what
|
|
164
|
+
// does. It is the check @node-saml/node-saml makes, and the one whose
|
|
165
|
+
// absence made every response @mcp-abap-adt/auth-mocks produced
|
|
166
|
+
// unacceptable to a real library until it was fixed there.
|
|
167
|
+
if (signatureNode.parentNode !== referenced) {
|
|
168
|
+
throw new Error('the signature is not inside the element it references, so it does not envelope it');
|
|
169
|
+
}
|
|
170
|
+
return referenced;
|
|
171
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing an `xsd:dateTime` strictly enough to trust the result.
|
|
3
|
+
*
|
|
4
|
+
* `Date.parse` is not this. It normalises `2026-02-30` into 2 March instead of
|
|
5
|
+
* rejecting it, and it will read a timezone offset no calendar has. Both traps
|
|
6
|
+
* were found in `@mcp-abap-adt/auth-mocks` and fixed there the same way: match
|
|
7
|
+
* the lexical shape, then require every component to survive a round trip. The
|
|
8
|
+
* instant is computed from the components rather than handed back to `Date`,
|
|
9
|
+
* whose handling of invalid strings is implementation-defined.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately not implemented: negative (BCE) years, the `24:00:00`
|
|
12
|
+
* end-of-day form, and leap seconds. No identity provider in this family emits
|
|
13
|
+
* them, and pretending to cover them would be worse than saying so.
|
|
14
|
+
*/
|
|
15
|
+
/** Returns the instant, or null when the value is not a valid xsd:dateTime. */
|
|
16
|
+
export declare function parseXsdDateTime(value: string | null | undefined): Date | null;
|
|
17
|
+
//# sourceMappingURL=xsdDateTime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"xsdDateTime.d.ts","sourceRoot":"","sources":["../../src/validation/xsdDateTime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/B,IAAI,GAAG,IAAI,CAkDb"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Parsing an `xsd:dateTime` strictly enough to trust the result.
|
|
4
|
+
*
|
|
5
|
+
* `Date.parse` is not this. It normalises `2026-02-30` into 2 March instead of
|
|
6
|
+
* rejecting it, and it will read a timezone offset no calendar has. Both traps
|
|
7
|
+
* were found in `@mcp-abap-adt/auth-mocks` and fixed there the same way: match
|
|
8
|
+
* the lexical shape, then require every component to survive a round trip. The
|
|
9
|
+
* instant is computed from the components rather than handed back to `Date`,
|
|
10
|
+
* whose handling of invalid strings is implementation-defined.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately not implemented: negative (BCE) years, the `24:00:00`
|
|
13
|
+
* end-of-day form, and leap seconds. No identity provider in this family emits
|
|
14
|
+
* them, and pretending to cover them would be worse than saying so.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.parseXsdDateTime = parseXsdDateTime;
|
|
18
|
+
const SHAPE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
19
|
+
/** Returns the instant, or null when the value is not a valid xsd:dateTime. */
|
|
20
|
+
function parseXsdDateTime(value) {
|
|
21
|
+
if (!value)
|
|
22
|
+
return null;
|
|
23
|
+
const m = SHAPE.exec(value);
|
|
24
|
+
if (!m)
|
|
25
|
+
return null;
|
|
26
|
+
const [, y, mo, d, h, mi, s, fraction, zone] = m;
|
|
27
|
+
const year = Number(y);
|
|
28
|
+
const month = Number(mo);
|
|
29
|
+
const day = Number(d);
|
|
30
|
+
const hour = Number(h);
|
|
31
|
+
const minute = Number(mi);
|
|
32
|
+
const second = Number(s);
|
|
33
|
+
if (month < 1 || month > 12)
|
|
34
|
+
return null;
|
|
35
|
+
if (hour > 23 || minute > 59 || second > 59)
|
|
36
|
+
return null;
|
|
37
|
+
// The calendar round trip. Date.UTC rolls 2026-02-30 into 2026-03-02, so a
|
|
38
|
+
// component that comes back changed means the date does not exist.
|
|
39
|
+
const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
|
40
|
+
if (utc.getUTCFullYear() !== year ||
|
|
41
|
+
utc.getUTCMonth() !== month - 1 ||
|
|
42
|
+
utc.getUTCDate() !== day) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// Milliseconds from the fraction: pad to 3 digits and truncate extras.
|
|
46
|
+
// Avoid floating-point multiplication: 0.57 * 1000 = 569.99999….
|
|
47
|
+
const ms = fraction
|
|
48
|
+
? Number(fraction.slice(1).padEnd(3, '0').slice(0, 3))
|
|
49
|
+
: 0;
|
|
50
|
+
// Offset in milliseconds. For zone Z it is 0. Otherwise apply the sign.
|
|
51
|
+
let offsetMs = 0;
|
|
52
|
+
if (zone !== 'Z') {
|
|
53
|
+
const offsetHour = Number(zone.slice(1, 3));
|
|
54
|
+
const offsetMinute = Number(zone.slice(4, 6));
|
|
55
|
+
// xsd:dateTime bounds the offset at ±14:00, and exactly 14 allows no
|
|
56
|
+
// minutes. Both halves matter: +15:00 fails the first, +14:01 the second.
|
|
57
|
+
if (offsetHour > 14 || offsetMinute > 59)
|
|
58
|
+
return null;
|
|
59
|
+
if (offsetHour === 14 && offsetMinute !== 0)
|
|
60
|
+
return null;
|
|
61
|
+
const sign = zone[0] === '-' ? -1 : 1;
|
|
62
|
+
offsetMs = sign * (offsetHour * 60 + offsetMinute) * 60_000;
|
|
63
|
+
}
|
|
64
|
+
// The instant is UTC time + milliseconds - offset, because local time = UTC + offset.
|
|
65
|
+
const instant = utc.getTime() + ms - offsetMs;
|
|
66
|
+
return new Date(instant);
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcp-abap-adt/auth-providers",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Token providers for MCP ABAP ADT auth-broker",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -40,8 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"bin": {
|
|
42
42
|
"auth-authorization-code": "./bin/auth-authorization-code.ts",
|
|
43
|
-
"auth-client-credentials": "./bin/auth-client-credentials.ts"
|
|
44
|
-
"auth-device-flow": "./bin/auth-device-flow.ts"
|
|
43
|
+
"auth-client-credentials": "./bin/auth-client-credentials.ts"
|
|
45
44
|
},
|
|
46
45
|
"scripts": {
|
|
47
46
|
"chrono": "./tools/version-stats.sh",
|
|
@@ -53,22 +52,29 @@
|
|
|
53
52
|
"build:fast": "npx tsc -p tsconfig.json",
|
|
54
53
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
55
54
|
"test:check": "npx tsc --noEmit",
|
|
56
|
-
"prepublishOnly": "npm run build"
|
|
55
|
+
"prepublishOnly": "npm run build",
|
|
56
|
+
"stand:up": "tests/stand/up.sh",
|
|
57
|
+
"stand:down": "tests/stand/down.sh",
|
|
58
|
+
"test:stand": "tests/stand/run.sh",
|
|
59
|
+
"test:xsuaa": "tests/xsuaa/run.sh"
|
|
57
60
|
},
|
|
58
61
|
"engines": {
|
|
59
|
-
"node": "
|
|
62
|
+
"node": "^22 || ^24"
|
|
60
63
|
},
|
|
61
64
|
"dependencies": {
|
|
62
|
-
"@mcp-abap-adt/interfaces-auth": "^
|
|
63
|
-
"@mcp-abap-adt/interfaces-auth-sap": "^1.0.
|
|
65
|
+
"@mcp-abap-adt/interfaces-auth": "^2.0.0",
|
|
66
|
+
"@mcp-abap-adt/interfaces-auth-sap": "^1.0.1",
|
|
64
67
|
"@mcp-abap-adt/interfaces-utils": "^1.1.0",
|
|
68
|
+
"@xmldom/xmldom": "^0.9.12",
|
|
65
69
|
"axios": "^1.13.5",
|
|
66
70
|
"express": "^5.1.0",
|
|
67
|
-
"open": "^11.0.0"
|
|
71
|
+
"open": "^11.0.0",
|
|
72
|
+
"xml-crypto": "^6.1.2"
|
|
68
73
|
},
|
|
69
74
|
"devDependencies": {
|
|
70
75
|
"@biomejs/biome": "^2.3.14",
|
|
71
76
|
"@jest/globals": "^30.2.0",
|
|
77
|
+
"@mcp-abap-adt/auth-mocks": "^0.3.0",
|
|
72
78
|
"@mcp-abap-adt/auth-stores": "^1.0.1",
|
|
73
79
|
"@mcp-abap-adt/logger": "^0.4.0",
|
|
74
80
|
"@types/express": "^5.0.5",
|
package/bin/auth-device-flow.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env tsx
|
|
2
|
-
/**
|
|
3
|
-
* Device Flow Provider Test Command
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { DeviceFlowProvider } from '../src/providers/DeviceFlowProvider';
|
|
7
|
-
import {
|
|
8
|
-
getUaaCredentials,
|
|
9
|
-
parseEnvFile,
|
|
10
|
-
parseServiceKey,
|
|
11
|
-
writeEnvFile,
|
|
12
|
-
} from './utils/parseConfig';
|
|
13
|
-
|
|
14
|
-
async function main() {
|
|
15
|
-
const args = process.argv.slice(2);
|
|
16
|
-
let serviceKeyPath: string | undefined;
|
|
17
|
-
let inputEnvPath: string | undefined;
|
|
18
|
-
let outputEnvPath: string | undefined;
|
|
19
|
-
let scope: string | undefined;
|
|
20
|
-
|
|
21
|
-
for (let i = 0; i < args.length; i++) {
|
|
22
|
-
const arg = args[i];
|
|
23
|
-
if (arg === '--service-key' && args[i + 1]) {
|
|
24
|
-
serviceKeyPath = args[++i];
|
|
25
|
-
} else if (arg === '--input-env' && args[i + 1]) {
|
|
26
|
-
inputEnvPath = args[++i];
|
|
27
|
-
} else if (arg === '--output-env' && args[i + 1]) {
|
|
28
|
-
outputEnvPath = args[++i];
|
|
29
|
-
} else if (arg === '--scope' && args[i + 1]) {
|
|
30
|
-
scope = args[++i];
|
|
31
|
-
} else if (arg === '--help' || arg === '-h') {
|
|
32
|
-
console.log(`
|
|
33
|
-
Usage: auth-device-flow [options]
|
|
34
|
-
|
|
35
|
-
Options:
|
|
36
|
-
--service-key <path> Path to service key JSON file
|
|
37
|
-
--input-env <path> Path to .env file for reading existing tokens (for refresh)
|
|
38
|
-
--output-env <path> Path to .env file for saving tokens
|
|
39
|
-
--scope <scope> Optional scope (space-separated)
|
|
40
|
-
--help, -h Show this help message
|
|
41
|
-
`);
|
|
42
|
-
process.exit(0);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (!serviceKeyPath && !inputEnvPath) {
|
|
47
|
-
console.error('❌ Error: Either --service-key or --input-env must be provided');
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (!outputEnvPath) {
|
|
52
|
-
console.error('❌ Error: --output-env is required to save tokens');
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
// Read config from service key or input env
|
|
58
|
-
const serviceKey = serviceKeyPath ? parseServiceKey(serviceKeyPath) : undefined;
|
|
59
|
-
const inputEnv = inputEnvPath ? parseEnvFile(inputEnvPath) : undefined;
|
|
60
|
-
const { uaaUrl, clientId, clientSecret } = getUaaCredentials(serviceKey, inputEnv);
|
|
61
|
-
|
|
62
|
-
// Read existing tokens from input env if provided
|
|
63
|
-
const existingAccessToken = inputEnv?.AUTHORIZATION_TOKEN;
|
|
64
|
-
const existingRefreshToken = inputEnv?.REFRESH_TOKEN;
|
|
65
|
-
|
|
66
|
-
const provider = new DeviceFlowProvider({
|
|
67
|
-
uaaUrl,
|
|
68
|
-
clientId,
|
|
69
|
-
clientSecret,
|
|
70
|
-
scope,
|
|
71
|
-
accessToken: existingAccessToken,
|
|
72
|
-
refreshToken: existingRefreshToken,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
console.log('🔐 Getting tokens using Device Flow...');
|
|
76
|
-
if (serviceKeyPath) {
|
|
77
|
-
console.log(`📁 Service Key: ${serviceKeyPath}`);
|
|
78
|
-
}
|
|
79
|
-
if (inputEnvPath) {
|
|
80
|
-
console.log(`📁 Input Env: ${inputEnvPath}`);
|
|
81
|
-
}
|
|
82
|
-
console.log(`💾 Output Env: ${outputEnvPath}\n`);
|
|
83
|
-
|
|
84
|
-
const result = await provider.getTokens();
|
|
85
|
-
|
|
86
|
-
// Save tokens to output env file
|
|
87
|
-
writeEnvFile(outputEnvPath, {
|
|
88
|
-
authorizationToken: result.authorizationToken,
|
|
89
|
-
refreshToken: result.refreshToken,
|
|
90
|
-
uaaUrl,
|
|
91
|
-
clientId,
|
|
92
|
-
clientSecret,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
console.log('✅ Tokens obtained and saved successfully!');
|
|
96
|
-
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
97
|
-
console.log(`🔑 Authorization Token: ${result.authorizationToken.substring(0, 50)}...`);
|
|
98
|
-
if (result.refreshToken) {
|
|
99
|
-
console.log(`🔄 Refresh Token: ${result.refreshToken.substring(0, 50)}...`);
|
|
100
|
-
}
|
|
101
|
-
console.log(`📋 Auth Type: ${result.authType}`);
|
|
102
|
-
if (result.expiresIn) {
|
|
103
|
-
console.log(`⏰ Expires In: ${result.expiresIn} seconds`);
|
|
104
|
-
}
|
|
105
|
-
console.log(`💾 Saved to: ${outputEnvPath}`);
|
|
106
|
-
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
107
|
-
} catch (error) {
|
|
108
|
-
console.error('❌ Error:', error instanceof Error ? error.message : String(error));
|
|
109
|
-
process.exit(1);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
main();
|
|
114
|
-
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Device Flow authentication
|
|
3
|
-
*
|
|
4
|
-
* OAuth2 Device Flow for devices without browser or input capabilities.
|
|
5
|
-
* User authorizes on another device by entering a code.
|
|
6
|
-
*/
|
|
7
|
-
import type { ILogger } from '@mcp-abap-adt/interfaces-utils';
|
|
8
|
-
export interface DeviceFlowResult {
|
|
9
|
-
deviceCode: string;
|
|
10
|
-
userCode: string;
|
|
11
|
-
verificationUri: string;
|
|
12
|
-
verificationUriComplete?: string;
|
|
13
|
-
expiresIn: number;
|
|
14
|
-
interval?: number;
|
|
15
|
-
}
|
|
16
|
-
export interface DeviceFlowTokens {
|
|
17
|
-
accessToken: string;
|
|
18
|
-
refreshToken?: string;
|
|
19
|
-
expiresIn?: number;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Initiate device flow - get device code and user code
|
|
23
|
-
* @param uaaUrl UAA URL
|
|
24
|
-
* @param clientId Client ID
|
|
25
|
-
* @param scope Optional scope (space-separated)
|
|
26
|
-
* @param logger Optional logger
|
|
27
|
-
* @returns Promise that resolves to device flow result
|
|
28
|
-
* @internal - Internal function, not exported from package
|
|
29
|
-
*/
|
|
30
|
-
export declare function initiateDeviceFlow(uaaUrl: string, clientId: string, scope?: string, logger?: ILogger): Promise<DeviceFlowResult>;
|
|
31
|
-
/**
|
|
32
|
-
* Poll for tokens using device code
|
|
33
|
-
* @param uaaUrl UAA URL
|
|
34
|
-
* @param clientId Client ID
|
|
35
|
-
* @param clientSecret Client secret (optional for public clients)
|
|
36
|
-
* @param deviceCode Device code from initiateDeviceFlow
|
|
37
|
-
* @param interval Polling interval in seconds
|
|
38
|
-
* @param logger Optional logger
|
|
39
|
-
* @returns Promise that resolves to tokens
|
|
40
|
-
* @internal - Internal function, not exported from package
|
|
41
|
-
*/
|
|
42
|
-
export declare function pollForDeviceTokens(uaaUrl: string, clientId: string, clientSecret: string | undefined, deviceCode: string, interval?: number, logger?: ILogger): Promise<DeviceFlowTokens>;
|
|
43
|
-
//# sourceMappingURL=deviceFlowAuth.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"deviceFlowAuth.d.ts","sourceRoot":"","sources":["../../src/auth/deviceFlowAuth.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAG9D,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,OAAO,GACf,OAAO,CAAC,gBAAgB,CAAC,CA4D3B;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,QAAQ,GAAE,MAAU,EACpB,MAAM,CAAC,EAAE,OAAO,GACf,OAAO,CAAC,gBAAgB,CAAC,CAoG3B"}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Device Flow authentication
|
|
4
|
-
*
|
|
5
|
-
* OAuth2 Device Flow for devices without browser or input capabilities.
|
|
6
|
-
* User authorizes on another device by entering a code.
|
|
7
|
-
*/
|
|
8
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
-
};
|
|
11
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.initiateDeviceFlow = initiateDeviceFlow;
|
|
13
|
-
exports.pollForDeviceTokens = pollForDeviceTokens;
|
|
14
|
-
const axios_1 = __importDefault(require("axios"));
|
|
15
|
-
/**
|
|
16
|
-
* Initiate device flow - get device code and user code
|
|
17
|
-
* @param uaaUrl UAA URL
|
|
18
|
-
* @param clientId Client ID
|
|
19
|
-
* @param scope Optional scope (space-separated)
|
|
20
|
-
* @param logger Optional logger
|
|
21
|
-
* @returns Promise that resolves to device flow result
|
|
22
|
-
* @internal - Internal function, not exported from package
|
|
23
|
-
*/
|
|
24
|
-
async function initiateDeviceFlow(uaaUrl, clientId, scope, logger) {
|
|
25
|
-
try {
|
|
26
|
-
const deviceUrl = `${uaaUrl}/oauth/device_authorization`;
|
|
27
|
-
const params = new URLSearchParams();
|
|
28
|
-
params.append('client_id', clientId);
|
|
29
|
-
if (scope) {
|
|
30
|
-
params.append('scope', scope);
|
|
31
|
-
}
|
|
32
|
-
logger?.info(`Initiating device flow: ${deviceUrl}`);
|
|
33
|
-
const response = await (0, axios_1.default)({
|
|
34
|
-
method: 'post',
|
|
35
|
-
url: deviceUrl,
|
|
36
|
-
headers: {
|
|
37
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
38
|
-
},
|
|
39
|
-
data: params.toString(),
|
|
40
|
-
timeout: 30000,
|
|
41
|
-
});
|
|
42
|
-
if (response.data?.device_code &&
|
|
43
|
-
response.data?.user_code &&
|
|
44
|
-
response.data?.verification_uri) {
|
|
45
|
-
return {
|
|
46
|
-
deviceCode: response.data.device_code,
|
|
47
|
-
userCode: response.data.user_code,
|
|
48
|
-
verificationUri: response.data.verification_uri,
|
|
49
|
-
verificationUriComplete: response.data.verification_uri_complete,
|
|
50
|
-
expiresIn: response.data.expires_in || 1800, // Default 30 minutes
|
|
51
|
-
interval: response.data.interval || 5, // Default 5 seconds
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
throw new Error('Response does not contain required device flow fields');
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
if (error &&
|
|
60
|
-
typeof error === 'object' &&
|
|
61
|
-
'response' in error &&
|
|
62
|
-
error.response &&
|
|
63
|
-
typeof error.response === 'object' &&
|
|
64
|
-
'status' in error.response &&
|
|
65
|
-
'data' in error.response) {
|
|
66
|
-
const axiosError = error;
|
|
67
|
-
throw new Error(`Device flow initiation failed (${axiosError.response.status}): ${JSON.stringify(axiosError.response.data)}`);
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
71
|
-
throw new Error(`Device flow initiation failed: ${errorMessage}`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Poll for tokens using device code
|
|
77
|
-
* @param uaaUrl UAA URL
|
|
78
|
-
* @param clientId Client ID
|
|
79
|
-
* @param clientSecret Client secret (optional for public clients)
|
|
80
|
-
* @param deviceCode Device code from initiateDeviceFlow
|
|
81
|
-
* @param interval Polling interval in seconds
|
|
82
|
-
* @param logger Optional logger
|
|
83
|
-
* @returns Promise that resolves to tokens
|
|
84
|
-
* @internal - Internal function, not exported from package
|
|
85
|
-
*/
|
|
86
|
-
async function pollForDeviceTokens(uaaUrl, clientId, clientSecret, deviceCode, interval = 5, logger) {
|
|
87
|
-
const tokenUrl = `${uaaUrl}/oauth/token`;
|
|
88
|
-
const params = new URLSearchParams();
|
|
89
|
-
params.append('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
|
|
90
|
-
params.append('device_code', deviceCode);
|
|
91
|
-
params.append('client_id', clientId);
|
|
92
|
-
const headers = {
|
|
93
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
94
|
-
};
|
|
95
|
-
// Add client secret if provided (for confidential clients)
|
|
96
|
-
if (clientSecret) {
|
|
97
|
-
const authString = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
98
|
-
headers.Authorization = `Basic ${authString}`;
|
|
99
|
-
}
|
|
100
|
-
// Poll until authorization is complete or expires
|
|
101
|
-
const maxAttempts = 120; // 10 minutes max (120 * 5 seconds)
|
|
102
|
-
let attempts = 0;
|
|
103
|
-
while (attempts < maxAttempts) {
|
|
104
|
-
try {
|
|
105
|
-
logger?.debug(`Polling for device tokens (attempt ${attempts + 1})`);
|
|
106
|
-
const response = await (0, axios_1.default)({
|
|
107
|
-
method: 'post',
|
|
108
|
-
url: tokenUrl,
|
|
109
|
-
headers,
|
|
110
|
-
data: params.toString(),
|
|
111
|
-
timeout: 30000,
|
|
112
|
-
});
|
|
113
|
-
if (response.data?.access_token) {
|
|
114
|
-
logger?.info('Device flow authorization successful');
|
|
115
|
-
return {
|
|
116
|
-
accessToken: response.data.access_token,
|
|
117
|
-
refreshToken: response.data.refresh_token,
|
|
118
|
-
expiresIn: response.data.expires_in,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
else {
|
|
122
|
-
throw new Error('Response does not contain access_token');
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
catch (error) {
|
|
126
|
-
// Check if it's an "authorization_pending" error (expected during polling)
|
|
127
|
-
if (error &&
|
|
128
|
-
typeof error === 'object' &&
|
|
129
|
-
'response' in error &&
|
|
130
|
-
error.response &&
|
|
131
|
-
typeof error.response === 'object' &&
|
|
132
|
-
'status' in error.response &&
|
|
133
|
-
'data' in error.response) {
|
|
134
|
-
const axiosError = error;
|
|
135
|
-
if (axiosError.response.data?.error === 'authorization_pending') {
|
|
136
|
-
// Still waiting for user authorization - continue polling
|
|
137
|
-
attempts++;
|
|
138
|
-
if (attempts >= maxAttempts) {
|
|
139
|
-
throw new Error('Device flow authorization timeout - user did not authorize in time');
|
|
140
|
-
}
|
|
141
|
-
// Wait for interval before next poll
|
|
142
|
-
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
else if (axiosError.response.data?.error === 'slow_down') {
|
|
146
|
-
// Server requests slower polling - increase interval by 5 seconds
|
|
147
|
-
interval += 5;
|
|
148
|
-
attempts++;
|
|
149
|
-
if (attempts >= maxAttempts) {
|
|
150
|
-
throw new Error('Device flow authorization timeout - user did not authorize in time');
|
|
151
|
-
}
|
|
152
|
-
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
else if (axiosError.response.data?.error === 'expired_token') {
|
|
156
|
-
throw new Error('Device code expired - please restart device flow');
|
|
157
|
-
}
|
|
158
|
-
else if (axiosError.response.data?.error === 'access_denied') {
|
|
159
|
-
throw new Error('User denied device authorization');
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
// Other error - rethrow
|
|
163
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
164
|
-
throw new Error(`Device flow polling failed: ${errorMessage}`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
throw new Error('Device flow authorization timeout - maximum polling attempts reached');
|
|
168
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Device Flow Token Provider
|
|
3
|
-
*
|
|
4
|
-
* Uses OAuth2 Device Flow for devices without browser or input capabilities.
|
|
5
|
-
* User authorizes on another device by entering a code.
|
|
6
|
-
*/
|
|
7
|
-
import type { ITokenResult, OAuth2GrantType } from '@mcp-abap-adt/interfaces-auth';
|
|
8
|
-
import type { ILogger } from '@mcp-abap-adt/interfaces-utils';
|
|
9
|
-
import { BaseTokenProvider } from './BaseTokenProvider';
|
|
10
|
-
export interface DeviceFlowProviderConfig {
|
|
11
|
-
uaaUrl: string;
|
|
12
|
-
clientId: string;
|
|
13
|
-
clientSecret?: string;
|
|
14
|
-
scope?: string;
|
|
15
|
-
accessToken?: string;
|
|
16
|
-
refreshToken?: string;
|
|
17
|
-
logger?: ILogger;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Device Flow token provider
|
|
21
|
-
*
|
|
22
|
-
* Uses OAuth2 Device Flow - user authorizes on another device.
|
|
23
|
-
* Supports refresh token if provided by server.
|
|
24
|
-
*/
|
|
25
|
-
export declare class DeviceFlowProvider extends BaseTokenProvider {
|
|
26
|
-
private config;
|
|
27
|
-
constructor(config: DeviceFlowProviderConfig);
|
|
28
|
-
protected getAuthType(): OAuth2GrantType;
|
|
29
|
-
protected performLogin(): Promise<ITokenResult>;
|
|
30
|
-
protected performRefresh(): Promise<ITokenResult>;
|
|
31
|
-
}
|
|
32
|
-
//# sourceMappingURL=DeviceFlowProvider.d.ts.map
|