@mcp-abap-adt/auth-providers 3.0.0 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +620 -77
  3. package/dist/auth/saml2Auth.d.ts +6 -2
  4. package/dist/auth/saml2Auth.d.ts.map +1 -1
  5. package/dist/auth/saml2Auth.js +9 -20
  6. package/dist/auth/samlBearerAssertion.d.ts.map +1 -1
  7. package/dist/auth/samlBearerAssertion.js +2 -1
  8. package/dist/auth/strictXml.d.ts +13 -0
  9. package/dist/auth/strictXml.d.ts.map +1 -0
  10. package/dist/auth/strictXml.js +21 -0
  11. package/dist/errors/AssertionValidationError.d.ts +15 -0
  12. package/dist/errors/AssertionValidationError.d.ts.map +1 -0
  13. package/dist/errors/AssertionValidationError.js +24 -0
  14. package/dist/errors/TokenProviderErrors.d.ts +2 -0
  15. package/dist/errors/TokenProviderErrors.d.ts.map +1 -1
  16. package/dist/errors/TokenProviderErrors.js +3 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +11 -1
  20. package/dist/providers/Saml2BearerProvider.d.ts +1 -0
  21. package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
  22. package/dist/providers/Saml2BearerProvider.js +17 -2
  23. package/dist/providers/Saml2PureProvider.d.ts +1 -0
  24. package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
  25. package/dist/providers/Saml2PureProvider.js +19 -5
  26. package/dist/providers/saml2Utils.d.ts +49 -2
  27. package/dist/providers/saml2Utils.d.ts.map +1 -1
  28. package/dist/providers/saml2Utils.js +94 -2
  29. package/dist/validation/assertionValidator.d.ts +28 -0
  30. package/dist/validation/assertionValidator.d.ts.map +1 -0
  31. package/dist/validation/assertionValidator.js +444 -0
  32. package/dist/validation/documentIds.d.ts +15 -0
  33. package/dist/validation/documentIds.d.ts.map +1 -0
  34. package/dist/validation/documentIds.js +32 -0
  35. package/dist/validation/inMemoryReplayStore.d.ts +22 -0
  36. package/dist/validation/inMemoryReplayStore.d.ts.map +1 -0
  37. package/dist/validation/inMemoryReplayStore.js +49 -0
  38. package/dist/validation/signedNode.d.ts +54 -0
  39. package/dist/validation/signedNode.d.ts.map +1 -0
  40. package/dist/validation/signedNode.js +171 -0
  41. package/dist/validation/xsdDateTime.d.ts +17 -0
  42. package/dist/validation/xsdDateTime.d.ts.map +1 -0
  43. package/dist/validation/xsdDateTime.js +67 -0
  44. package/package.json +5 -4
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findDuplicateId = findDuplicateId;
4
+ exports.readRequiredId = readRequiredId;
5
+ /**
6
+ * The `ID` rules, which run before any signature reference is resolved.
7
+ *
8
+ * XML-DSig resolves its reference by `ID`. Two elements sharing one make
9
+ * "which element is signed" a question the parser answers rather than the
10
+ * specification, and that ambiguity is the classic lever for signature
11
+ * wrapping. So uniqueness is established first, across the whole document —
12
+ * not only across the two elements this validator happens to read.
13
+ */
14
+ /** The first ID value appearing more than once, or null when all are unique. */
15
+ function findDuplicateId(doc) {
16
+ const seen = new Set();
17
+ const elements = doc.getElementsByTagName('*');
18
+ for (let i = 0; i < elements.length; i++) {
19
+ const id = elements[i].getAttribute('ID');
20
+ if (!id)
21
+ continue;
22
+ if (seen.has(id))
23
+ return id;
24
+ seen.add(id);
25
+ }
26
+ return null;
27
+ }
28
+ /** The element's ID, or null when it is absent or empty. */
29
+ function readRequiredId(element) {
30
+ const id = element.getAttribute('ID');
31
+ return id ? id : null;
32
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Remembering assertions so a replay is refused.
3
+ *
4
+ * In memory, and therefore per process. That is honest rather than sufficient:
5
+ * a deployment running several processes needs a shared store, which is why
6
+ * the interface exists at all. What this must not be is per provider instance —
7
+ * a store an attacker escapes by causing a second provider to be constructed
8
+ * is no store.
9
+ *
10
+ * Pruning is lazy, on access, so nothing here holds a timer and nothing needs
11
+ * disposing.
12
+ */
13
+ import type { IAssertionReplayStore } from '@mcp-abap-adt/interfaces-auth';
14
+ /** A store of its own, for a test or a consumer wanting isolation. */
15
+ export declare function createInMemoryReplayStore(): IAssertionReplayStore;
16
+ /**
17
+ * The store the shipped validator uses when the consumer supplies none.
18
+ *
19
+ * Module-level, so every default validator in the process shares it.
20
+ */
21
+ export declare const defaultReplayStore: IAssertionReplayStore;
22
+ //# sourceMappingURL=inMemoryReplayStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inMemoryReplayStore.d.ts","sourceRoot":"","sources":["../../src/validation/inMemoryReplayStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAEV,qBAAqB,EACtB,MAAM,+BAA+B,CAAC;AAOvC,sEAAsE;AACtE,wBAAgB,yBAAyB,IAAI,qBAAqB,CAuBjE;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,EAAE,qBACJ,CAAC"}
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ /**
3
+ * Remembering assertions so a replay is refused.
4
+ *
5
+ * In memory, and therefore per process. That is honest rather than sufficient:
6
+ * a deployment running several processes needs a shared store, which is why
7
+ * the interface exists at all. What this must not be is per provider instance —
8
+ * a store an attacker escapes by causing a second provider to be constructed
9
+ * is no store.
10
+ *
11
+ * Pruning is lazy, on access, so nothing here holds a timer and nothing needs
12
+ * disposing.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.defaultReplayStore = void 0;
16
+ exports.createInMemoryReplayStore = createInMemoryReplayStore;
17
+ const compositeKey = (key) =>
18
+ // The issuer is length-prefixed so that two different pairs cannot collide
19
+ // by putting the separator inside an identifier.
20
+ `${key.issuer.length}:${key.issuer}:${key.assertionId}`;
21
+ /** A store of its own, for a test or a consumer wanting isolation. */
22
+ function createInMemoryReplayStore() {
23
+ const seen = new Map();
24
+ return {
25
+ async recordIfUnseen(key, retainUntil) {
26
+ const now = Date.now();
27
+ // Lazy prune: drop everything whose retention has passed, so the map
28
+ // cannot grow without bound and no timer is needed.
29
+ for (const [existing, until] of seen) {
30
+ if (until <= now)
31
+ seen.delete(existing);
32
+ }
33
+ const composite = compositeKey(key);
34
+ if (seen.has(composite))
35
+ return false;
36
+ // Nothing awaits between the check and the write, so this is atomic
37
+ // against other callers on the same event loop. A shared-store
38
+ // implementation must achieve the same with a conditional write.
39
+ seen.set(composite, retainUntil.getTime());
40
+ return true;
41
+ },
42
+ };
43
+ }
44
+ /**
45
+ * The store the shipped validator uses when the consumer supplies none.
46
+ *
47
+ * Module-level, so every default validator in the process shares it.
48
+ */
49
+ exports.defaultReplayStore = createInMemoryReplayStore();
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which element the signature covers.
3
+ *
4
+ * Not "is there a valid signature" — that question has a true answer in a
5
+ * document built for a wrapping attack, where a genuinely signed fragment sits
6
+ * beside a forged one. The caller must read the element this returns and no
7
+ * other.
8
+ */
9
+ import type { Document, Element } from '@xmldom/xmldom';
10
+ /**
11
+ * PEM in, PEM out; bare base64 DER gets its armour — and the result is proved
12
+ * to be a certificate before anything uses it.
13
+ *
14
+ * The spec promises `idpCertificates` accepts either, and a consumer copying
15
+ * `<X509Certificate>` out of identity-provider metadata has bare base64 DER in
16
+ * their hand — the armour is not in the metadata. `xml-crypto` accepts only
17
+ * PEM or a Buffer: measured, a bare base64 certificate makes OpenSSL throw
18
+ * `DECODER routines::unsupported`, while the same bytes re-armoured verify.
19
+ *
20
+ * Left unnormalised that throw would be swallowed by the verification loop and
21
+ * reported as "the signature does not verify against any configured
22
+ * certificate" — blaming the assertion for the consumer's formatting.
23
+ *
24
+ * Base64 syntax alone does not make a string a certificate: `"AAAA"` passes
25
+ * the character class, armours cleanly, and fails only inside OpenSSL, which
26
+ * lands back at the same misleading message. So the armoured result is parsed
27
+ * with `node:crypto`'s `X509Certificate`, which rejects `"AAAA"` with
28
+ * `asn1 encoding routines::wrong tag` — measured, not assumed. Called once per
29
+ * certificate at construction, so the cost never falls on a login.
30
+ */
31
+ export declare function toPem(certificate: string): string;
32
+ /**
33
+ * A value taken from the document before any signature has been verified,
34
+ * made safe to put in a message: JSON-quoted, so a newline smuggled in as
35
+ * `&#10;` shows as `\n` rather than forging a line in a log, and cut to 64
36
+ * characters, so an attacker cannot fill a log with it.
37
+ */
38
+ export declare function quoteUntrusted(value: string): string;
39
+ /**
40
+ * Verifies every signature in the document against the certificates and
41
+ * returns the elements they reference. Throws when there is no signature, or
42
+ * when any one of them fails a rule below.
43
+ *
44
+ * `doc` must be the parse of `xml`, and nothing else: signatures are found and
45
+ * their references resolved in `doc`, while `xml-crypto` verifies the digests
46
+ * over `xml`. Handed a `doc` from different bytes, the element returned would
47
+ * not be the one whose bytes were verified.
48
+ *
49
+ * Several signatures are normal — identity providers often sign the Response
50
+ * and the Assertion both. Each is held to every rule on its own; the caller
51
+ * then takes the element it requires from the returned list.
52
+ */
53
+ export declare function resolveSignedElements(xml: string, doc: Document, certificates: readonly string[]): Element[];
54
+ //# sourceMappingURL=signedNode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signedNode.d.ts","sourceRoot":"","sources":["../../src/validation/signedNode.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAmB,MAAM,gBAAgB,CAAC;AAKzE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,KAAK,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAuBjD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,QAAQ,EACb,YAAY,EAAE,SAAS,MAAM,EAAE,GAC9B,OAAO,EAAE,CAeX"}
@@ -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
+ * `&#10;` 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.0.0",
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",
@@ -62,13 +62,14 @@
62
62
  "node": "^22 || ^24"
63
63
  },
64
64
  "dependencies": {
65
- "@mcp-abap-adt/interfaces-auth": "^1.2.0",
66
- "@mcp-abap-adt/interfaces-auth-sap": "^1.0.0",
65
+ "@mcp-abap-adt/interfaces-auth": "^2.0.0",
66
+ "@mcp-abap-adt/interfaces-auth-sap": "^1.0.1",
67
67
  "@mcp-abap-adt/interfaces-utils": "^1.1.0",
68
68
  "@xmldom/xmldom": "^0.9.12",
69
69
  "axios": "^1.13.5",
70
70
  "express": "^5.1.0",
71
- "open": "^11.0.0"
71
+ "open": "^11.0.0",
72
+ "xml-crypto": "^6.1.2"
72
73
  },
73
74
  "devDependencies": {
74
75
  "@biomejs/biome": "^2.3.14",