@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.
- package/CHANGELOG.md +105 -0
- package/README.md +620 -77
- 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.map +1 -1
- package/dist/auth/samlBearerAssertion.js +2 -1
- 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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/providers/Saml2BearerProvider.d.ts +1 -0
- package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
- package/dist/providers/Saml2BearerProvider.js +17 -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/saml2Utils.d.ts +49 -2
- package/dist/providers/saml2Utils.d.ts.map +1 -1
- package/dist/providers/saml2Utils.js +94 -2
- 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 +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
|
-
|
|
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
|
-
|
|
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"}
|