@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
@@ -4,10 +4,13 @@
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.validateSamlConfig = validateSamlConfig;
7
+ exports.resolveAssertionValidator = resolveAssertionValidator;
7
8
  exports.resolveTokenUrl = resolveTokenUrl;
8
9
  exports.getSamlAssertion = getSamlAssertion;
9
10
  const saml2Auth_1 = require("../auth/saml2Auth");
11
+ const TokenProviderErrors_1 = require("../errors/TokenProviderErrors");
10
12
  const strategies_1 = require("../strategies");
13
+ const assertionValidator_1 = require("../validation/assertionValidator");
11
14
  /** Throw at construction rather than half-verify at runtime. */
12
15
  function validateSamlConfig(config) {
13
16
  if (config.authorizationUrl && !config.acsUrl) {
@@ -15,6 +18,48 @@ function validateSamlConfig(config) {
15
18
  'pre-built SAML request cannot be read, so it must be declared.');
16
19
  }
17
20
  }
21
+ /**
22
+ * The consumer's validator when supplied, otherwise the provider's default —
23
+ * `createSignedAssertionValidator` for `"bearer"`, since the token endpoint
24
+ * receives the Assertion alone (#40) and its own signature is what that
25
+ * endpoint verifies; `createSignedResponseValidator` for `"pure"`, since the
26
+ * whole response is handed on and `Status`/`Destination` must be inside a
27
+ * signature. See the spec's "A bare Assertion, and which validator each
28
+ * provider defaults to".
29
+ */
30
+ function resolveAssertionValidator(config, provider) {
31
+ if (config.assertionValidator) {
32
+ // A shipped validator fails closed without expectedIssuer, so supplying
33
+ // one without idpEntityId would construct fine and then refuse every
34
+ // login at `issuer` — after the browser step. A custom validator needs
35
+ // no idpEntityId: it may establish trust some other way.
36
+ if ((0, assertionValidator_1.isShippedValidator)(config.assertionValidator) && !config.idpEntityId) {
37
+ throw new TokenProviderErrors_1.ValidationError('The supplied assertionValidator is a shipped one ' +
38
+ '(createSignedResponseValidator or createSignedAssertionValidator), ' +
39
+ 'which refuses every assertion without an expected issuer: missing ' +
40
+ 'idpEntityId.', ['idpEntityId']);
41
+ }
42
+ return config.assertionValidator;
43
+ }
44
+ const missing = [];
45
+ if (!config.idpCertificates?.length)
46
+ missing.push('idpCertificates');
47
+ if (!config.idpEntityId)
48
+ missing.push('idpEntityId');
49
+ if (missing.length > 0) {
50
+ throw new TokenProviderErrors_1.ValidationError(`The default assertion validator needs the identity provider it should ` +
51
+ `trust: missing ${missing.join(', ')}. Supply these, or supply an ` +
52
+ `assertionValidator of your own.`, missing);
53
+ }
54
+ const options = {
55
+ idpCertificates: config.idpCertificates,
56
+ clockSkewMs: config.clockSkewMs,
57
+ replayStore: config.assertionReplayStore,
58
+ };
59
+ return provider === 'bearer'
60
+ ? (0, assertionValidator_1.createSignedAssertionValidator)(options)
61
+ : (0, assertionValidator_1.createSignedResponseValidator)(options);
62
+ }
18
63
  function resolveTokenUrl(config) {
19
64
  if (config.tokenUrl) {
20
65
  return config.tokenUrl;
@@ -26,9 +71,21 @@ function resolveTokenUrl(config) {
26
71
  }
27
72
  async function getSamlAssertion(config) {
28
73
  const declaredAcs = config.acsUrl;
74
+ let mintedRequestId;
29
75
  const request = {
30
76
  logger: config.logger,
31
77
  buildAuthorizationUrl: async (redirectUri) => {
78
+ // An IdP-initiated login sends no AuthnRequest, and without a pre-built
79
+ // authorizationUrl the only URL this could produce is one carrying a
80
+ // freshly minted request. Refused here, before any URL exists, so the
81
+ // mistake surfaces before a browser opens rather than after a login.
82
+ if (config.idpInitiated && !config.authorizationUrl) {
83
+ throw new TokenProviderErrors_1.ValidationError('SAML idpInitiated is true and no authorizationUrl is configured, ' +
84
+ 'but the authorization strategy asked for an authorization URL: ' +
85
+ 'the only one this package can build carries an AuthnRequest. ' +
86
+ 'Configure the IdP-initiated SSO URL as authorizationUrl, or use a ' +
87
+ 'strategy that does not call buildAuthorizationUrl.', ['authorizationUrl']);
88
+ }
32
89
  // A declared ACS is registered with the IdP; the strategy must be
33
90
  // listening exactly there, and an ephemeral port cannot be.
34
91
  const acsUrl = declaredAcs ?? redirectUri;
@@ -36,13 +93,15 @@ async function getSamlAssertion(config) {
36
93
  throw new Error(`SAML acsUrl is ${declaredAcs}, but the authorization strategy is ` +
37
94
  `listening on ${redirectUri}. They must match.`);
38
95
  }
39
- return (0, saml2Auth_1.buildSamlAuthorizationUrl)({
96
+ const built = (0, saml2Auth_1.buildSamlAuthorizationUrl)({
40
97
  idpSsoUrl: config.idpSsoUrl,
41
98
  spEntityId: config.spEntityId,
42
99
  acsUrl,
43
100
  relayState: config.relayState,
44
101
  authorizationUrl: config.authorizationUrl,
45
102
  });
103
+ mintedRequestId = built.requestId;
104
+ return built.url;
46
105
  },
47
106
  };
48
107
  const supplied = config.authorization;
@@ -55,7 +114,12 @@ async function getSamlAssertion(config) {
55
114
  throw new Error(`SAML acsUrl is ${declaredAcs}, but the authorization strategy used ` +
56
115
  `${outcome.redirectUri}. They must match.`);
57
116
  }
58
- return outcome.payload;
117
+ const requestId = resolveExpectedRequestId(config, mintedRequestId);
118
+ return {
119
+ payload: outcome.payload,
120
+ requestId,
121
+ acsUrl: outcome.redirectUri,
122
+ };
59
123
  }
60
124
  finally {
61
125
  if (!supplied) {
@@ -67,3 +131,31 @@ async function getSamlAssertion(config) {
67
131
  }
68
132
  }
69
133
  }
134
+ /**
135
+ * The ID `InResponseTo` must answer, from the three sources the spec allows:
136
+ * minted, declared, or none by explicit `idpInitiated: true`. Anything else —
137
+ * no ID and no declaration, or `idpInitiated` combined with an ID from either
138
+ * of the other two sources — is a configuration error, not a validation
139
+ * failure blamed on the assertion.
140
+ */
141
+ function resolveExpectedRequestId(config, mintedRequestId) {
142
+ const declaredRequestId = config.authnRequestId;
143
+ if (config.idpInitiated) {
144
+ if (mintedRequestId || declaredRequestId) {
145
+ throw new TokenProviderErrors_1.ValidationError('SAML idpInitiated is true, but a request ID was also minted or ' +
146
+ 'configured (an authorization strategy called buildAuthorizationUrl, ' +
147
+ 'or authnRequestId is set). An IdP-initiated login sends no request, ' +
148
+ 'so an ID means the configuration describes two different logins.', ['idpInitiated']);
149
+ }
150
+ return undefined;
151
+ }
152
+ const requestId = mintedRequestId ?? declaredRequestId;
153
+ if (!requestId) {
154
+ throw new TokenProviderErrors_1.ValidationError('Cannot validate InResponseTo: this login did not build its own AuthnRequest, ' +
155
+ 'so authnRequestId must be configured — or, if the identity provider ' +
156
+ 'starts this login itself, idpInitiated: true. This happens with a ' +
157
+ 'pre-built authorizationUrl, or an authorization strategy that supplies an ' +
158
+ 'assertion without asking for a URL.', ['authnRequestId']);
159
+ }
160
+ return requestId;
161
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The shipped assertion validator: the spec's check table, in order.
3
+ *
4
+ * Two properties matter more than any individual check. First, the signature
5
+ * is resolved to an element and every assertion-level field is read *from that
6
+ * element* — a document holding a validly signed fragment beside a forged one
7
+ * is the wrapping attack, and reading the wrong node is how it succeeds.
8
+ * Second, no two refusals share a distinguishing phrase, so a test cannot pass
9
+ * for a neighbouring check's reason.
10
+ *
11
+ * Three fields live on the Response rather than the assertion: Status,
12
+ * Response/Issuer and Destination. The signed-Response validator reads them,
13
+ * because there they are inside the signature. The assertion-only validator
14
+ * does not read them at all — not weakly. That is safe because a declined
15
+ * login carries no assertion, so flipping Status buys an attacker nothing they
16
+ * can sign, and addressing rests on Recipient inside the signed assertion.
17
+ */
18
+ import type { IAssertionReplayStore, IAssertionValidator } from '@mcp-abap-adt/interfaces-auth';
19
+ export interface ShippedValidatorOptions {
20
+ readonly idpCertificates: readonly string[];
21
+ readonly clockSkewMs?: number;
22
+ readonly replayStore?: IAssertionReplayStore;
23
+ }
24
+ /** Whether this validator came from one of the two shipped factories. */
25
+ export declare function isShippedValidator(validator: IAssertionValidator): boolean;
26
+ export declare const createSignedResponseValidator: (options: ShippedValidatorOptions) => IAssertionValidator;
27
+ export declare const createSignedAssertionValidator: (options: ShippedValidatorOptions) => IAssertionValidator;
28
+ //# sourceMappingURL=assertionValidator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assertionValidator.d.ts","sourceRoot":"","sources":["../../src/validation/assertionValidator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAEV,qBAAqB,EACrB,mBAAmB,EAEpB,MAAM,+BAA+B,CAAC;AAiBvC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5C,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,qBAAqB,CAAC;CAC9C;AAgCD,yEAAyE;AACzE,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,mBAAmB,GAAG,OAAO,CAE1E;AAED,eAAO,MAAM,6BAA6B,GACxC,SAAS,uBAAuB,KAC/B,mBAAkE,CAAC;AAEtE,eAAO,MAAM,8BAA8B,GACzC,SAAS,uBAAuB,KAC/B,mBAAmE,CAAC"}
@@ -0,0 +1,444 @@
1
+ "use strict";
2
+ /**
3
+ * The shipped assertion validator: the spec's check table, in order.
4
+ *
5
+ * Two properties matter more than any individual check. First, the signature
6
+ * is resolved to an element and every assertion-level field is read *from that
7
+ * element* — a document holding a validly signed fragment beside a forged one
8
+ * is the wrapping attack, and reading the wrong node is how it succeeds.
9
+ * Second, no two refusals share a distinguishing phrase, so a test cannot pass
10
+ * for a neighbouring check's reason.
11
+ *
12
+ * Three fields live on the Response rather than the assertion: Status,
13
+ * Response/Issuer and Destination. The signed-Response validator reads them,
14
+ * because there they are inside the signature. The assertion-only validator
15
+ * does not read them at all — not weakly. That is safe because a declined
16
+ * login carries no assertion, so flipping Status buys an attacker nothing they
17
+ * can sign, and addressing rests on Recipient inside the signed assertion.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.createSignedAssertionValidator = exports.createSignedResponseValidator = void 0;
21
+ exports.isShippedValidator = isShippedValidator;
22
+ const xmldom_1 = require("@xmldom/xmldom");
23
+ const strictXml_1 = require("../auth/strictXml");
24
+ const AssertionValidationError_1 = require("../errors/AssertionValidationError");
25
+ const documentIds_1 = require("./documentIds");
26
+ const inMemoryReplayStore_1 = require("./inMemoryReplayStore");
27
+ const signedNode_1 = require("./signedNode");
28
+ const xsdDateTime_1 = require("./xsdDateTime");
29
+ const SAML_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';
30
+ const PROTOCOL_NS = 'urn:oasis:names:tc:SAML:2.0:protocol';
31
+ const BEARER = 'urn:oasis:names:tc:SAML:2.0:cm:bearer';
32
+ const SUCCESS = 'urn:oasis:names:tc:SAML:2.0:status:Success';
33
+ /**
34
+ * Marks a validator as one of the two shipped here. Module-private and
35
+ * non-enumerable, so it is neither part of the public surface nor visible to
36
+ * a consumer spreading or serialising the object.
37
+ *
38
+ * It exists for one reason: a shipped validator fails closed without
39
+ * `expectedIssuer`, so a provider handed one must insist on `idpEntityId` at
40
+ * construction — otherwise the mistake surfaces only as an `issuer` refusal
41
+ * after a human has finished a browser login. A custom validator carries no
42
+ * brand and may establish trust however it likes.
43
+ */
44
+ const SHIPPED = Symbol('mcp-abap-adt.shippedAssertionValidator');
45
+ function brand(validator) {
46
+ Object.defineProperty(validator, SHIPPED, {
47
+ value: true,
48
+ enumerable: false,
49
+ });
50
+ return validator;
51
+ }
52
+ /** Whether this validator came from one of the two shipped factories. */
53
+ function isShippedValidator(validator) {
54
+ return validator[SHIPPED] === true;
55
+ }
56
+ const createSignedResponseValidator = (options) => brand(createValidator('response', options));
57
+ exports.createSignedResponseValidator = createSignedResponseValidator;
58
+ const createSignedAssertionValidator = (options) => brand(createValidator('assertion', options));
59
+ exports.createSignedAssertionValidator = createSignedAssertionValidator;
60
+ function createValidator(require, options) {
61
+ const skew = options.clockSkewMs ?? 0;
62
+ if (!Number.isInteger(skew) || skew < 0) {
63
+ throw new Error(`clockSkewMs must be a finite non-negative integer, got ${String(options.clockSkewMs)}`);
64
+ }
65
+ if (options.idpCertificates.length === 0) {
66
+ throw new Error('idpCertificates must not be empty: nothing could be verified');
67
+ }
68
+ // Normalised and proved here, once, rather than inside verification. Three
69
+ // reasons, and the last bites hardest: a malformed entry standing first in
70
+ // the list would abort the rotation loop before a later valid certificate
71
+ // was tried; a constructor is where this package already refuses a bad
72
+ // configuration; and a login happens after a human has used a browser, so a
73
+ // formatting mistake found then wastes their work, not ours.
74
+ const certificates = options.idpCertificates.map(signedNode_1.toPem);
75
+ const store = options.replayStore ?? inMemoryReplayStore_1.defaultReplayStore;
76
+ return {
77
+ async validate(samlResponse, context) {
78
+ // 1. Parses, and the document element is a samlp:Response.
79
+ const xml = Buffer.from(samlResponse, 'base64').toString('utf8');
80
+ // No DTD, ever. A SAML message has no use for one, and a DOCTYPE is
81
+ // where parsers diverge — entity expansion, internal subsets — and this
82
+ // document is parsed twice: by @xmldom/xmldom 0.9 here and by the 0.8
83
+ // nested inside xml-crypto. Refused before either parse is trusted.
84
+ if (/<!DOCTYPE/i.test(xml)) {
85
+ return fail('document', 'the SAMLResponse carries a DOCTYPE declaration, which is never accepted');
86
+ }
87
+ let doc;
88
+ try {
89
+ doc = (0, strictXml_1.parseStrictXml)(xml);
90
+ }
91
+ catch {
92
+ return fail('document', 'the SAMLResponse did not parse as XML');
93
+ }
94
+ const root = doc.documentElement;
95
+ if (!root)
96
+ return fail('document', 'the SAMLResponse did not parse as XML');
97
+ const rootIsResponse = root.localName === 'Response' && root.namespaceURI === PROTOCOL_NS;
98
+ const rootIsAssertion = root.localName === 'Assertion' && root.namespaceURI === SAML_NS;
99
+ // A bare Assertion is a document only the assertion-only validator
100
+ // accepts: the saml2-bearer grant exchanges an Assertion, and 3.0.0
101
+ // already takes one as Saml2BearerProvider's payload.
102
+ if (!rootIsResponse && !(require === 'assertion' && rootIsAssertion)) {
103
+ return fail('document', require === 'assertion'
104
+ ? `expected a samlp:Response or a saml:Assertion, got ${(0, signedNode_1.quoteUntrusted)(root.localName ?? '')}`
105
+ : `expected the document element to be a samlp:Response, got ${(0, signedNode_1.quoteUntrusted)(root.localName ?? '')}`);
106
+ }
107
+ // 1b. Unique IDs, before any reference is resolved.
108
+ const duplicate = (0, documentIds_1.findDuplicateId)(doc);
109
+ if (duplicate) {
110
+ return fail('duplicateId', `the document uses the ID ${(0, signedNode_1.quoteUntrusted)(duplicate)} more than once, so which element is signed is ambiguous`);
111
+ }
112
+ // 2 + 3. Verify every signature, then take the element this validator
113
+ // requires from among those they cover. A response signed at both
114
+ // levels — as many identity providers send — satisfies either validator.
115
+ let covered;
116
+ try {
117
+ covered = (0, signedNode_1.resolveSignedElements)(xml, doc, certificates);
118
+ }
119
+ catch (error) {
120
+ return fail('signature', error.message);
121
+ }
122
+ // The element this validator reads is fixed by the document's shape,
123
+ // not by which signature happens to come first: the Response itself, or
124
+ // for the assertion-only validator the bare root Assertion or the
125
+ // Response's single direct-child Assertion. Taking "the first covered
126
+ // Assertion" instead would pick a signed assertion nested in Advice
127
+ // whenever its signature precedes the outer one's in document order.
128
+ const target = require === 'response'
129
+ ? root
130
+ : rootIsAssertion
131
+ ? root
132
+ : (() => {
133
+ const children = directChildren(root, SAML_NS, 'Assertion');
134
+ return children.length === 1 ? children[0] : null;
135
+ })();
136
+ const signed = target
137
+ ? covered.find((element) => element === target)
138
+ : undefined;
139
+ // The signed element must be the Assertion, or a Response holding exactly
140
+ // one. Everything below is read from `assertion` and nowhere else.
141
+ const assertion = signed ? assertionInside(signed, root, require) : null;
142
+ if (!signed || !assertion) {
143
+ return fail('signedNode', 'the signature does not cover the assertion this response carries');
144
+ }
145
+ // 3b. Nothing assertion-shaped outside the one read. Wherever the
146
+ // signature sits, the payload travels on whole — Saml2PureProvider hands
147
+ // it to the cookie provider — so an Assertion or EncryptedAssertion in
148
+ // an unsigned part of it (Extensions, a sibling, a wrapper) is something
149
+ // a later reader may take for the real one.
150
+ if (!everyAssertionWithin(doc, assertion)) {
151
+ return fail('signedNode', 'the document carries a saml:Assertion or saml:EncryptedAssertion outside the one the signature covers');
152
+ }
153
+ // 4. Status. Only when the Response is the signed element: otherwise it
154
+ // lies outside the signature, and checking a field an attacker sets is
155
+ // worse than not checking it — it reads like verification.
156
+ if (require === 'response') {
157
+ const status = directChild(root, PROTOCOL_NS, 'Status');
158
+ const codeValue = status
159
+ ? directChild(status, PROTOCOL_NS, 'StatusCode')?.getAttribute('Value')
160
+ : null;
161
+ if (!codeValue)
162
+ return fail('status', 'the response must carry exactly one samlp:Status holding exactly one StatusCode with a Value');
163
+ if (codeValue !== SUCCESS) {
164
+ return fail('status', `the identity provider declined the login: ${codeValue}`);
165
+ }
166
+ }
167
+ // 4b. The assertion's own ID.
168
+ const assertionId = (0, documentIds_1.readRequiredId)(assertion);
169
+ if (!assertionId)
170
+ return fail('assertionId', 'the assertion carries no ID');
171
+ // 5. The assertion's Issuer — inside the signature either way, so both
172
+ // validators check it.
173
+ const issuer = directChild(assertion, SAML_NS, 'Issuer')?.textContent ?? '';
174
+ if (!issuer) {
175
+ return fail('issuer', 'the assertion must carry exactly one non-empty saml:Issuer');
176
+ }
177
+ // Fail closed: with nothing to compare against, any issuer whose key is
178
+ // configured would pass, which is not what this validator promises.
179
+ if (!context.expectedIssuer) {
180
+ return fail('issuer', 'no expectedIssuer was configured, so the assertion issuer cannot be trusted');
181
+ }
182
+ if (issuer !== context.expectedIssuer) {
183
+ return fail('issuer', `the assertion was issued by ${issuer}, not the trusted issuer`);
184
+ }
185
+ // 5b. The cross-check against the Response's Issuer belongs to the
186
+ // signed-Response validator alone: only there are both inside the
187
+ // signature.
188
+ if (require === 'response') {
189
+ // Optional, so none is fine; but two are an ambiguity, and one that is
190
+ // present must agree — empty included, since empty is not absent.
191
+ const responseIssuers = directChildren(root, SAML_NS, 'Issuer');
192
+ if (responseIssuers.length > 1) {
193
+ return fail('issuer', 'the response must carry at most one saml:Issuer');
194
+ }
195
+ if (responseIssuers.length === 1 &&
196
+ (responseIssuers[0].textContent ?? '') !== issuer) {
197
+ return fail('issuer', 'the response and the assertion name different issuers');
198
+ }
199
+ }
200
+ // 6, 7, 8. Conditions and their window.
201
+ const conditions = directChild(assertion, SAML_NS, 'Conditions');
202
+ if (!conditions)
203
+ return fail('conditions', 'the assertion must carry exactly one saml:Conditions');
204
+ const notBeforeRaw = conditions.getAttribute('NotBefore');
205
+ if (notBeforeRaw) {
206
+ const notBefore = (0, xsdDateTime_1.parseXsdDateTime)(notBeforeRaw);
207
+ if (!notBefore) {
208
+ return fail('notBefore', `Conditions NotBefore is not a valid xsd:dateTime: ${notBeforeRaw}`);
209
+ }
210
+ if (notBefore.getTime() - skew > Date.now()) {
211
+ return fail('notBefore', 'the assertion is not valid yet');
212
+ }
213
+ }
214
+ const conditionsExpiry = (0, xsdDateTime_1.parseXsdDateTime)(conditions.getAttribute('NotOnOrAfter'));
215
+ if (!conditionsExpiry) {
216
+ return fail('notOnOrAfter', 'Conditions carries no usable NotOnOrAfter, so the assertion states no lifetime');
217
+ }
218
+ if (conditionsExpiry.getTime() + skew <= Date.now()) {
219
+ return fail('notOnOrAfter', 'the assertion has expired');
220
+ }
221
+ // 9. Every AudienceRestriction must name us; several Audience inside one
222
+ // are alternatives.
223
+ const restrictions = directChildren(conditions, SAML_NS, 'AudienceRestriction');
224
+ if (restrictions.length === 0) {
225
+ return fail('audience', 'the assertion restricts no audience');
226
+ }
227
+ for (const restriction of restrictions) {
228
+ const names = directChildren(restriction, SAML_NS, 'Audience').map((a) => a.textContent ?? '');
229
+ if (!names.includes(context.audience)) {
230
+ return fail('audience', 'an AudienceRestriction on this assertion does not name us');
231
+ }
232
+ }
233
+ // 10. One bearer confirmation satisfying everything together.
234
+ const chosen = chooseBearerConfirmation(assertion, context, skew);
235
+ if (!chosen) {
236
+ return fail('bearerConfirmation', 'no single bearer SubjectConfirmation, under exactly one saml:Subject and with exactly one SubjectConfirmationData, answers our request, names our ACS and is still open');
237
+ }
238
+ // 11. Destination — the signed-Response validator only, for the same
239
+ // reason as Status. Addressing in the other flow rests on Recipient,
240
+ // which step 10 required and which sits inside the signed assertion.
241
+ if (require === 'response') {
242
+ const destination = root.getAttribute('Destination');
243
+ if (!destination) {
244
+ return fail('destination', 'the response carries no Destination');
245
+ }
246
+ if (destination !== context.acsUrl) {
247
+ return fail('destination', `the response is addressed to ${destination}, not to us`);
248
+ }
249
+ }
250
+ // Expiry: the earlier of the two windows.
251
+ const expiresAt = new Date(Math.min(conditionsExpiry.getTime(), chosen.notOnOrAfter.getTime()));
252
+ // 12. Replay. Retention is NOT expiresAt: it must last for as long as
253
+ // this validator could accept the assertion again. Conditions bound
254
+ // that, but so does the LATEST bearer confirmation that can qualify —
255
+ // with confirmations closing at +120 s and +600 s the session ends at
256
+ // +120 s, yet at +200 s the second one still qualifies, and an entry
257
+ // dropped at +120 s would let the same assertion in a second time. The
258
+ // skew is added on top, since inside it the assertion is still accepted.
259
+ const retainUntil = new Date(Math.min(conditionsExpiry.getTime(), chosen.latestNotOnOrAfter.getTime()) + skew);
260
+ const fresh = await store.recordIfUnseen({ issuer, assertionId }, retainUntil);
261
+ if (!fresh) {
262
+ return fail('replay', 'this assertion has been presented before');
263
+ }
264
+ return {
265
+ expiresAt,
266
+ assertionId,
267
+ issuer,
268
+ nameId: (() => {
269
+ // Subject, then NameID — no `?? assertion` fallback, which would
270
+ // read a NameID from outside the Subject when the Subject is absent.
271
+ const subject = directChild(assertion, SAML_NS, 'Subject');
272
+ return subject
273
+ ? (directChild(subject, SAML_NS, 'NameID')?.textContent ??
274
+ undefined)
275
+ : undefined;
276
+ })(),
277
+ raw: samlResponse,
278
+ // The signed element, not the response: this is what a consumer may
279
+ // parse without re-deriving what the signature covered.
280
+ signedXml: new xmldom_1.XMLSerializer().serializeToString(signed),
281
+ };
282
+ },
283
+ };
284
+ }
285
+ function fail(check, message) {
286
+ throw new AssertionValidationError_1.AssertionValidationError(check, message);
287
+ }
288
+ /**
289
+ * Direct children with this namespace and local name — **not** descendants.
290
+ *
291
+ * `getElementsByTagNameNS` searches the whole subtree, and that is the wrong
292
+ * tool for a structural path. An assertion with no `Conditions` of its own but
293
+ * a `Conditions` buried somewhere inside it would answer the descendant search
294
+ * and satisfy a check it does not meet; the same trick works for `Issuer`,
295
+ * `Status` and `Subject`. Each segment of a SAML path is therefore walked
296
+ * explicitly, one level at a time.
297
+ */
298
+ function directChildren(parent, ns, local) {
299
+ const out = [];
300
+ const nodes = parent.childNodes;
301
+ for (let i = 0; i < nodes.length; i++) {
302
+ const node = nodes[i];
303
+ // nodeType 1 is ELEMENT_NODE; the constant is unavailable without the dom
304
+ // lib, which this project deliberately does not use.
305
+ if (node.nodeType === 1 &&
306
+ node.namespaceURI === ns &&
307
+ node.localName === local) {
308
+ out.push(node);
309
+ }
310
+ }
311
+ return out;
312
+ }
313
+ /** The single direct child with this name, or null when there is not exactly one. */
314
+ function directChild(parent, ns, local) {
315
+ const found = directChildren(parent, ns, local);
316
+ // Not "the first": two siblings sharing a name is an ambiguity, and
317
+ // resolving it silently in favour of the first is how a forged element comes
318
+ // to be read in preference to a real one.
319
+ return found.length === 1 ? found[0] : null;
320
+ }
321
+ /**
322
+ * Whether every `saml:Assertion` and `saml:EncryptedAssertion` in the
323
+ * document is `assertion` itself or lies inside it.
324
+ */
325
+ function everyAssertionWithin(doc, assertion) {
326
+ for (const local of ['Assertion', 'EncryptedAssertion']) {
327
+ const found = doc.getElementsByTagNameNS(SAML_NS, local);
328
+ for (let i = 0; i < found.length; i++) {
329
+ let node = found[i];
330
+ while (node && node !== assertion) {
331
+ node = node.parentNode;
332
+ }
333
+ if (!node)
334
+ return false;
335
+ }
336
+ }
337
+ return true;
338
+ }
339
+ /**
340
+ * The assertion the signature covers, or null when the signed element is not
341
+ * one and does not contain exactly one.
342
+ *
343
+ * "Exactly one" matters: a signed Response wrapping two assertions leaves
344
+ * "which did we verify" ambiguous, which is the wrapping question again.
345
+ */
346
+ function assertionInside(signed, root, require) {
347
+ // The signature must cover what this validator was built to require. A
348
+ // signed-Response validator handed an assertion-signed document refuses
349
+ // here, and vice versa — that refusal is the whole point of shipping two.
350
+ const signedIsResponse = signed.localName === 'Response' && signed.namespaceURI === PROTOCOL_NS;
351
+ const signedIsAssertion = signed.localName === 'Assertion' && signed.namespaceURI === SAML_NS;
352
+ if (require === 'response' && !signedIsResponse)
353
+ return null;
354
+ if (require === 'assertion' && !signedIsAssertion)
355
+ return null;
356
+ // A bare Assertion is its own document: the only assertion there is, and
357
+ // it must be the element signed.
358
+ if (root.localName === 'Assertion' && root.namespaceURI === SAML_NS) {
359
+ return signed === root ? root : null;
360
+ }
361
+ // Whatever was signed, the response must carry exactly one assertion.
362
+ //
363
+ // Reading only from the signed element is not enough: Saml2PureProvider
364
+ // hands the whole response to the cookie provider, which reads whatever is
365
+ // in it. (toBearerAssertion refuses a second assertion on the bearer path
366
+ // too, but the validator does not lean on its caller.) A forged assertion
367
+ // placed beside the signed one must therefore end the login, not merely be
368
+ // ignored here.
369
+ const assertions = directChildren(root, SAML_NS, 'Assertion');
370
+ if (assertions.length !== 1)
371
+ return null;
372
+ const only = assertions[0];
373
+ if (signed.localName === 'Assertion' && signed.namespaceURI === SAML_NS) {
374
+ return signed === only ? only : null;
375
+ }
376
+ if (signed.localName === 'Response' && signed.namespaceURI === PROTOCOL_NS) {
377
+ return signed === root ? only : null;
378
+ }
379
+ return null;
380
+ }
381
+ /**
382
+ * The bearer confirmation this login may rely on.
383
+ *
384
+ * Every part must hold on the **same** element: gathering `InResponseTo` from
385
+ * one confirmation and `Recipient` from another is how a document satisfies a
386
+ * check nothing in it actually satisfies. When several qualify — which a real
387
+ * identity provider does not produce — the earliest window wins, so the
388
+ * outcome is a shorter session rather than a longer one.
389
+ *
390
+ * `latestNotOnOrAfter` answers a different question: until when could some
391
+ * confirmation let this assertion in? It is the latest `NotOnOrAfter` among
392
+ * the confirmations that satisfy every non-temporal part — including one whose
393
+ * `NotBefore` has not arrived yet, since it qualifies once it does. Replay
394
+ * retention needs that bound, not the session's; see the caller.
395
+ */
396
+ function chooseBearerConfirmation(assertion, context, skew) {
397
+ const now = Date.now();
398
+ let best = null;
399
+ let latest = null;
400
+ const subject = directChild(assertion, SAML_NS, 'Subject');
401
+ if (!subject)
402
+ return null;
403
+ for (const confirmation of directChildren(subject, SAML_NS, 'SubjectConfirmation')) {
404
+ if (confirmation.getAttribute('Method') !== BEARER)
405
+ continue;
406
+ const data = directChild(confirmation, SAML_NS, 'SubjectConfirmationData');
407
+ if (!data)
408
+ continue;
409
+ // Option B: an expected ID must be matched exactly; no expected ID — an
410
+ // IdP-initiated login — means the attribute must not be there at all.
411
+ if (context.expectedInResponseTo === undefined) {
412
+ if (data.hasAttribute('InResponseTo'))
413
+ continue;
414
+ }
415
+ else if (data.getAttribute('InResponseTo') !== context.expectedInResponseTo) {
416
+ continue;
417
+ }
418
+ if (data.getAttribute('Recipient') !== context.acsUrl)
419
+ continue;
420
+ const notOnOrAfter = (0, xsdDateTime_1.parseXsdDateTime)(data.getAttribute('NotOnOrAfter'));
421
+ if (!notOnOrAfter)
422
+ continue;
423
+ const notBeforeRaw = data.getAttribute('NotBefore');
424
+ const notBefore = notBeforeRaw ? (0, xsdDateTime_1.parseXsdDateTime)(notBeforeRaw) : null;
425
+ if (notBeforeRaw && !notBefore)
426
+ continue;
427
+ // Could qualify at some instant: counts towards how long to remember.
428
+ if (!latest || notOnOrAfter.getTime() > latest.getTime()) {
429
+ latest = notOnOrAfter;
430
+ }
431
+ // Qualifies now: a candidate for the session's window.
432
+ if (notOnOrAfter.getTime() + skew <= now)
433
+ continue;
434
+ if (notBefore && notBefore.getTime() - skew > now)
435
+ continue;
436
+ if (!best || notOnOrAfter.getTime() < best.getTime())
437
+ best = notOnOrAfter;
438
+ }
439
+ // `latest` is set whenever `best` is: every qualifying confirmation was
440
+ // counted towards it first.
441
+ return best && latest
442
+ ? { notOnOrAfter: best, latestNotOnOrAfter: latest }
443
+ : null;
444
+ }
@@ -0,0 +1,15 @@
1
+ import type { Document, Element } from '@xmldom/xmldom';
2
+ /**
3
+ * The `ID` rules, which run before any signature reference is resolved.
4
+ *
5
+ * XML-DSig resolves its reference by `ID`. Two elements sharing one make
6
+ * "which element is signed" a question the parser answers rather than the
7
+ * specification, and that ambiguity is the classic lever for signature
8
+ * wrapping. So uniqueness is established first, across the whole document —
9
+ * not only across the two elements this validator happens to read.
10
+ */
11
+ /** The first ID value appearing more than once, or null when all are unique. */
12
+ export declare function findDuplicateId(doc: Document): string | null;
13
+ /** The element's ID, or null when it is absent or empty. */
14
+ export declare function readRequiredId(element: Element): string | null;
15
+ //# sourceMappingURL=documentIds.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"documentIds.d.ts","sourceRoot":"","sources":["../../src/validation/documentIds.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAExD;;;;;;;;GAQG;AAEH,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAU5D;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAG9D"}