@mcp-abap-adt/auth-providers 4.0.0 → 4.1.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 CHANGED
@@ -7,6 +7,88 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.1.0] - 2026-09-26
11
+
12
+ Three refusals get stricter; no export, configuration field or error class
13
+ changes. The README's *Upgrading from 4.0 to 4.1* lists what now fails.
14
+
15
+ ### Changed
16
+
17
+ - **Every refusal names its rule.** No two rules under one `check` share a
18
+ message. An element that must appear exactly once says which way it failed
19
+ — absent, or more than one — for the Response's direct-child `Assertion`,
20
+ each signature's `ds:Reference`, `Status`, `StatusCode`, the assertion's
21
+ `Issuer`, `Conditions`, `Subject` and each bearer candidate's
22
+ `SubjectConfirmationData`. An empty `Issuer`, a `StatusCode` without a
23
+ `Value`, an `AudienceRestriction` naming no audience, and an absent versus
24
+ an invalid `Conditions/@NotOnOrAfter` each get their own message. The
25
+ signed-node refusal names the element the validator requires
26
+ (`samlp:Response` or `saml:Assertion`). `check` is unchanged for every
27
+ refusal, and every document 4.0.0 refused is still refused; code matching
28
+ on message text must match on `check`. The README lists every message
29
+ the shipped validators produce.
30
+ - **`bearerConfirmation` says why every candidate failed.** Still
31
+ existential: one confirmation passing every sub-rule is enough, and every
32
+ candidate is evaluated. A refusal lists each candidate in document order
33
+ with the first sub-rule it failed, in a fixed order, at most five, then
34
+ `and N more`. A `Subject` absent or repeated, and a `Subject` holding no
35
+ `SubjectConfirmation`, have messages of their own.
36
+ - **Every document value a message interpolates is quoted and cut**
37
+ (`quoteUntrusted`: JSON-quoted, 64 characters at most), including the
38
+ post-signature `Status` code, issuer and `Destination`, the `Conditions`
39
+ dates, xml-crypto's own messages about a malformed `Signature`, which embed
40
+ the offending element, and the parser message in `Saml2BearerProvider`'s
41
+ bearer conversion.
42
+
43
+ ### Fixed
44
+
45
+ - A SAML 1.x `Assertion` (`urn:oasis:names:tc:SAML:1.0:assertion`) outside
46
+ the signed assertion is refused at `signedNode`, as a SAML 2.0 one already
47
+ was. Before, one in `Extensions` passed either validator.
48
+ - An `Assertion` or `EncryptedAssertion` (SAML 2.0) or a SAML 1.x `Assertion`
49
+ inside a `ds:Signature` is refused at `signedNode`. An enveloped signature
50
+ leaves its own subtree out of the digest, so an element in `ds:Object`
51
+ there is unsigned however deep inside the signed assertion it sits; before,
52
+ such an element inside the signed assertion's own `ds:Signature` passed
53
+ either validator, `Saml2BearerProvider`'s default included.
54
+ - `idpInitiated: true` with `authnRequestId` is refused when the provider is
55
+ constructed — a `ValidationError` with `missingFields: ['idpInitiated']` —
56
+ not after `authorize()` returns, so before the user has been through the
57
+ browser.
58
+ - A malformed `Signature` whose `loadSignature` throws something other than
59
+ an `Error` is refused at `signature` quoting what was thrown. Before, the
60
+ refusal carried an empty message, and a thrown `null` or `undefined`
61
+ escaped as a `TypeError` instead of an `AssertionValidationError`.
62
+
63
+ ### Removed
64
+
65
+ - The `bin` commands `auth-authorization-code` and `auth-client-credentials`
66
+ (the `bin` field, and `bin` in `files`), and the devDependency `tsx`. They
67
+ never ran from an npm install: they were `.ts` files with a `tsx` shebang,
68
+ importing `src/`, which is not published. Not a breaking change for that
69
+ reason.
70
+
71
+ ### Documentation
72
+
73
+ - `ValidatedAssertion.nameId` is `undefined` when the `Subject` carries no
74
+ `NameID` or more than one. `NameID` is surfaced, never refused.
75
+
76
+ ### Development
77
+
78
+ - `@mcp-abap-adt/interfaces-auth` `^2.0.1`, whose
79
+ `IAssertionReplayStore.recordIfUnseen` JSDoc now describes the retention this
80
+ package implements: until the last instant a validator would still accept
81
+ the assertion, not its expiry.
82
+ - `@mcp-abap-adt/auth-stores` stays a devDependency: three test suites import
83
+ `AbapServiceKeyStore` from it.
84
+ - The end-to-end SAML suite (`samlValidation.test.ts`) runs about 6× faster,
85
+ about 41 s down to about 7 s: it starts one mock identity provider per
86
+ signed element in `beforeAll` and switches variants, instead of one per
87
+ test, each generating an RSA key. Every test still gets its own replay
88
+ store.
89
+ - The Keycloak suite trusts only the certificates under `KeyDescriptor
90
+ use="signing"` in Keycloak's metadata.
91
+
10
92
  ## [4.0.0] - 2026-09-25
11
93
 
12
94
  ### Breaking
package/README.md CHANGED
@@ -43,7 +43,8 @@ and whether it has been seen before — and must therefore be told which identit
43
43
  provider to trust. This is a breaking change: a 3.x SAML configuration fails at
44
44
  construction. See [SAML assertion validation](#saml-assertion-validation).
45
45
 
46
- If you are on an earlier major, see
46
+ If you are on an earlier version, see
47
+ [Upgrading from 4.0 to 4.1](#upgrading-from-40-to-41),
47
48
  [Migrating from 3.x to 4.0](#migrating-from-3x-to-40),
48
49
  [Migrating from 2.x to 3.0](#migrating-from-2x-to-30) and
49
50
  [Migrating from 1.x to 2.0](#migrating-from-1x-to-20).
@@ -609,17 +610,17 @@ names the row. Rows marked *(signed-Response only)* are not performed by
609
610
  |---|---|---|---|
610
611
  | 1 | Parses as XML, with no `DOCTYPE`; the document element is `samlp:Response` — or, for the assertion-only validator, a bare `saml:Assertion` | it is not, or it carries a `<!DOCTYPE` declaration | `document` |
611
612
  | 1b | Every `ID` attribute in the document is unique | any value appears twice | `duplicateId` |
612
- | 2 | Every signature is valid against `idpCertificates` — never against a certificate the document carries in its own `KeyInfo` | none, wrong key, content altered after signing, more than one reference, a reference outside the document, or a signature not inside the element it references | `signature` |
613
- | 3 | The signed node is the node read | the signature does not cover the element this validator requires — the `Response`, or the bare root `Assertion` or the Response's direct-child `Assertion` — the response does not hold exactly one `Assertion`, or any `saml:Assertion` / `saml:EncryptedAssertion` lies outside the signed assertion | `signedNode` |
614
- | 4 | `samlp:Status` *(signed-Response only)* | absent, or its `StatusCode` is not `…:status:Success` | `status` |
613
+ | 2 | Every signature is valid against `idpCertificates` — never against a certificate the document carries in its own `KeyInfo` | none, wrong key, content altered after signing, a malformed `Signature` element, no `ds:Reference` or more than one, a reference that is not same-document or names no element by `ID`, or a signature not inside the element it references | `signature` |
614
+ | 3 | The signed node is the node read | the Response carries no direct-child `Assertion`, or more than one; the signature does not cover the element this validator requires — the `Response`, or the bare root `Assertion` or the Response's direct-child `Assertion`; or any SAML 2.0 `Assertion` / `EncryptedAssertion`, or SAML 1.x `Assertion`, lies outside the signed assertion or inside a `ds:Signature` | `signedNode` |
615
+ | 4 | `samlp:Status` *(signed-Response only)* | absent or more than one; its `StatusCode` absent or more than one; the `StatusCode` without a `Value`; or a `Value` other than `…:status:Success` | `status` |
615
616
  | 4b | `Assertion/@ID` | absent or empty | `assertionId` |
616
- | 5 | `Assertion/Issuer` | absent, not the expected issuer, or no expected issuer was given | `issuer` |
617
+ | 5 | `Assertion/Issuer` | absent, more than one, empty, not the expected issuer, or no expected issuer was given | `issuer` |
617
618
  | 5b | `Response/Issuer` *(signed-Response only; optional)* | present and disagreeing with `Assertion/Issuer`, or present twice — absent is accepted | `issuer` |
618
- | 6 | `Conditions` | absent | `conditions` |
619
+ | 6 | `Conditions` | absent, or more than one | `conditions` |
619
620
  | 7 | `Conditions/@NotBefore` *(optional)* | present and not a valid `xsd:dateTime`, or in the future beyond `clockSkewMs` — absent is accepted | `notBefore` |
620
621
  | 8 | `Conditions/@NotOnOrAfter` | absent, not a valid `xsd:dateTime`, or in the past beyond `clockSkewMs` | `notOnOrAfter` |
621
- | 9 | `Conditions/AudienceRestriction` | absent, or **any one** restriction fails to name `spEntityId` | `audience` |
622
- | 10 | One bearer `SubjectConfirmation` | no single confirmation satisfies every part of it — see below | `bearerConfirmation` |
622
+ | 9 | `Conditions/AudienceRestriction` | absent, **any one** restriction naming no `Audience` at all, or **any one** failing to name `spEntityId` | `audience` |
623
+ | 10 | One bearer `SubjectConfirmation` | the `Subject` absent or more than one, holding no `SubjectConfirmation`, or no confirmation satisfying every part — see below | `bearerConfirmation` |
623
624
  | 11 | `Response/@Destination` *(signed-Response only)* | absent, or not the ACS the response arrived at | `destination` |
624
625
  | 12 | Replay | the store has already recorded this `{issuer, ID}` | `replay` |
625
626
 
@@ -643,10 +644,13 @@ What the table compresses:
643
644
  the validator resolves which element each signature covers and reads the
644
645
  assertion's fields from that element only. And since a payload travels on
645
646
  whole — `Saml2PureProvider` hands it to `cookieProvider` — **every**
646
- SAML-namespace `Assertion` or `EncryptedAssertion` anywhere in the document
647
- must be the signed assertion or inside it, under both validators. An extra
648
- assertion in `Extensions`, a sibling or a wrapper ends the login rather than
649
- being ignored. Encrypted assertions are not supported.
647
+ SAML 2.0 `Assertion` or `EncryptedAssertion`, and every SAML 1.x
648
+ `Assertion` (`urn:oasis:names:tc:SAML:1.0:assertion`), anywhere in the
649
+ document must be the signed assertion or inside it, under both validators,
650
+ but never inside a `ds:Signature`, whose subtree an enveloped signature
651
+ leaves unsigned. An extra assertion in `Extensions`, a sibling or a wrapper
652
+ ends the login rather than being ignored. Encrypted assertions are not
653
+ supported.
650
654
  - **Several signatures are accepted** when every one verifies against
651
655
  `idpCertificates`, carries exactly one same-document reference, and sits
652
656
  directly inside the element it references. A signature that fails refuses the
@@ -655,14 +659,21 @@ What the table compresses:
655
659
  - **Unique IDs (1b)** are the wrapping defence again: XML-DSig resolves its
656
660
  reference by `ID`, so a duplicate makes "which element is signed" ambiguous.
657
661
  They are refused wherever they appear, before any reference is resolved.
658
- - **Check 10 is one element, not four fields.** There must be a single
659
- `SubjectConfirmation` with `Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"`,
660
- under exactly one `Subject`, whose own `SubjectConfirmationData` satisfies
661
- all of: `InResponseTo` equal to the expected request ID — or **absent** for a
662
- login declared `idpInitiated`; `Recipient` equal to the ACS the response
663
- arrived at; `NotOnOrAfter` present, a valid `xsd:dateTime` and not past beyond
664
- `clockSkewMs`; `NotBefore`, if present, not in the future beyond it. Values
665
- scattered across several confirmations do not add up to one.
662
+ - **Check 10 is one element, not four fields — and one is enough.** The
663
+ assertion must carry exactly one `Subject`, holding at least one
664
+ `SubjectConfirmation`. It is accepted when **at least one** confirmation
665
+ passes every sub-rule on its own; values scattered across several
666
+ confirmations do not add up to one. A candidate's sub-rules, in the order
667
+ they are evaluated: `Method` is `urn:oasis:names:tc:SAML:2.0:cm:bearer`; it
668
+ holds exactly one `SubjectConfirmationData`; `InResponseTo` equals the
669
+ expected request ID — or is **absent** for a login declared `idpInitiated`;
670
+ `Recipient` equals the ACS the response arrived at; `NotOnOrAfter` is
671
+ present and a valid `xsd:dateTime`; `NotBefore`, if present, is a valid
672
+ `xsd:dateTime`; `NotOnOrAfter` has not passed beyond `clockSkewMs`;
673
+ `NotBefore` has arrived within it. When none qualifies, the refusal names
674
+ every candidate in document order with the first sub-rule it failed —
675
+ `no bearer confirmation qualifies: #1 Recipient is not the ACS; #2
676
+ NotOnOrAfter has passed` — listing at most five, then `and N more`.
666
677
  - **Check 9 is AND across restrictions, OR within one**, as SAML Core §2.5.1.4
667
678
  says: every `AudienceRestriction` must name you; the `Audience` elements
668
679
  inside one are alternatives.
@@ -686,6 +697,98 @@ What the table compresses:
686
697
  delegating to a shipped validator — and keep `idpEntityId` configured, since
687
698
  the shipped validator inside still refuses without an expected issuer.
688
699
 
700
+ #### Refusal messages
701
+
702
+ Since 4.1.0 no two rules under one `check` share a message, and an element
703
+ that must appear exactly once says which way it failed — absent, or more than
704
+ one. Every value a message takes from the document is JSON-quoted and cut to
705
+ 64 characters, so a newline smuggled in as `&#10;` shows as `\n` and cannot
706
+ forge a log line. Match on `check` in code; the message is for the person
707
+ reading the log. `<n>` is a count of two or more; `"…"` is a quoted document
708
+ value.
709
+
710
+ | `check` | message |
711
+ |---|---|
712
+ | `document` | `the SAMLResponse carries a DOCTYPE declaration, which is never accepted` |
713
+ | `document` | `the SAMLResponse did not parse as XML` |
714
+ | `document` | `expected a samlp:Response or a saml:Assertion, got "…"` |
715
+ | `document` | `expected the document element to be a samlp:Response, got "…"` |
716
+ | `duplicateId` | `the document uses the ID "…" more than once, so which element is signed is ambiguous` |
717
+ | `signature` | `the document carries no signature` |
718
+ | `signature` | `the signature element is malformed: "…"` |
719
+ | `signature` | `the signature does not verify against any configured certificate` |
720
+ | `signature` | `the signature carries no ds:Reference` |
721
+ | `signature` | `the signature carries <n> ds:Reference; exactly one is allowed` |
722
+ | `signature` | `the signature reference is not a same-document URI: "…"` |
723
+ | `signature` | `the signature references "…", which is not in the document` |
724
+ | `signature` | `the signature is not inside the element it references, so it does not envelope it` |
725
+ | `signedNode` | `the response carries no direct-child saml:Assertion` |
726
+ | `signedNode` | `the response carries <n> direct-child saml:Assertion; exactly one is allowed` |
727
+ | `signedNode` | `the signature does not cover the samlp:Response this validator requires` |
728
+ | `signedNode` | `the signature does not cover the saml:Assertion this validator requires` |
729
+ | `signedNode` | `the document carries an Assertion or EncryptedAssertion, SAML 2.0 or 1.x, outside the one the signature covers` |
730
+ | `signedNode` | `the document carries an Assertion or EncryptedAssertion inside a ds:Signature, where no signature covers it` |
731
+ | `status` | `the response carries no samlp:Status` |
732
+ | `status` | `the response carries <n> samlp:Status; exactly one is allowed` |
733
+ | `status` | `the samlp:Status carries no samlp:StatusCode` |
734
+ | `status` | `the samlp:Status carries <n> samlp:StatusCode; exactly one is allowed` |
735
+ | `status` | `the samlp:StatusCode carries no Value` |
736
+ | `status` | `the identity provider declined the login: "…"` |
737
+ | `assertionId` | `the assertion carries no ID` |
738
+ | `issuer` | `the assertion carries no saml:Issuer` |
739
+ | `issuer` | `the assertion carries <n> saml:Issuer; exactly one is allowed` |
740
+ | `issuer` | `the assertion's saml:Issuer is empty` |
741
+ | `issuer` | `no expectedIssuer was configured, so the assertion issuer cannot be trusted` |
742
+ | `issuer` | `the assertion was issued by "…", not the trusted issuer` |
743
+ | `issuer` | `the response must carry at most one saml:Issuer` |
744
+ | `issuer` | `the response and the assertion name different issuers` |
745
+ | `conditions` | `the assertion carries no saml:Conditions` |
746
+ | `conditions` | `the assertion carries <n> saml:Conditions; exactly one is allowed` |
747
+ | `notBefore` | `Conditions NotBefore is not a valid xsd:dateTime: "…"` |
748
+ | `notBefore` | `the assertion is not valid yet` |
749
+ | `notOnOrAfter` | `Conditions carries no NotOnOrAfter, so the assertion states no lifetime` |
750
+ | `notOnOrAfter` | `Conditions NotOnOrAfter is not a valid xsd:dateTime: "…"` |
751
+ | `notOnOrAfter` | `the assertion has expired` |
752
+ | `audience` | `the assertion restricts no audience` |
753
+ | `audience` | `an AudienceRestriction names no audience` |
754
+ | `audience` | `an AudienceRestriction on this assertion does not name us` |
755
+ | `bearerConfirmation` | `the assertion carries no saml:Subject` |
756
+ | `bearerConfirmation` | `the assertion carries <n> saml:Subject; exactly one is allowed` |
757
+ | `bearerConfirmation` | `the saml:Subject holds no SubjectConfirmation` |
758
+ | `bearerConfirmation` | `no bearer confirmation qualifies: #1 <reason>; #2 <reason>; …` |
759
+ | `destination` | `the response carries no Destination` |
760
+ | `destination` | `the response is addressed to "…", not to us` |
761
+ | `replay` | `this assertion has been presented before` |
762
+
763
+ A `bearerConfirmation` refusal naming candidates lists each one's first failed
764
+ sub-rule, in document order, joined by `; `; past five candidates it ends
765
+ `; and N more`, N being how many were not listed. The eleven reasons:
766
+
767
+ | # | `<reason>`, in the order a candidate is tested |
768
+ |---|---|
769
+ | 1 | `Method is not bearer` |
770
+ | 2 | `carries no SubjectConfirmationData` |
771
+ | 3 | `carries <n> SubjectConfirmationData; exactly one is allowed` |
772
+ | 4 | `InResponseTo is present, but this login sent no request` |
773
+ | 5 | `InResponseTo does not answer our request` |
774
+ | 6 | `Recipient is not the ACS` |
775
+ | 7 | `SubjectConfirmationData has no NotOnOrAfter` |
776
+ | 8 | `SubjectConfirmationData NotOnOrAfter is not a valid xsd:dateTime` |
777
+ | 9 | `SubjectConfirmationData NotBefore is not a valid xsd:dateTime` |
778
+ | 10 | `NotOnOrAfter has passed` |
779
+ | 11 | `NotBefore has not arrived` |
780
+
781
+ Two messages from outside a validator changed in 4.1.0 and carry no `check`.
782
+ A provider configured with both `idpInitiated: true` and `authnRequestId`
783
+ throws a `ValidationError` (`missingFields: ['idpInitiated']`) at
784
+ construction: `SAML idpInitiated is true and authnRequestId is set: an
785
+ IdP-initiated login sends no request, so the two describe different logins.
786
+ Remove one of them.` And `Saml2BearerProvider`'s conversion of a validated
787
+ payload into the bearer grant's Assertion throws a plain `Error` whose parser
788
+ text is quoted the same way — reachable only when a custom validator accepted
789
+ a payload that does not parse: `SAML bearer payload is not well-formed XML:
790
+ "…"`.
791
+
689
792
  **Expiry comes from the verified document.** A validated assertion's
690
793
  `expiresAt` is the earlier of `Conditions/@NotOnOrAfter` and the `NotOnOrAfter`
691
794
  of the bearer confirmation accepted — the earliest, if several qualify — so a
@@ -740,9 +843,10 @@ assertion without `InResponseTo` does not make a login IdP-initiated; only
740
843
  `idpInitiated: true` does. The other checks apply unchanged.
741
844
 
742
845
  `idpInitiated: true` together with a request ID is a configuration error too:
743
- the two describe different logins. With a declared `authnRequestId` it is a
744
- `ValidationError` (`missingFields: ['idpInitiated']`) after the strategy
745
- returns. A strategy that calls `buildAuthorizationUrl` with no
846
+ the two describe different logins. With a declared `authnRequestId` the
847
+ provider refuses at construction — a `ValidationError` (`missingFields:
848
+ ['idpInitiated']`) — before any browser opens. A strategy that calls
849
+ `buildAuthorizationUrl` with no
746
850
  `authorizationUrl` configured is refused inside the builder, before a URL — and
747
851
  so a request ID — exists: a `ValidationError` with `missingFields:
748
852
  ['authorizationUrl']`. Use a strategy that does not call the builder, and leave
@@ -810,7 +914,9 @@ must outlive it. Neither window nor tolerance cuts a hole in replay detection.
810
914
 
811
915
  You meet a `ValidatedAssertion` when you call a validator yourself or wrap one
812
916
  in an `IAssertionValidator` of your own. The shipped validators fill
813
- `expiresAt`, `assertionId`, `issuer`, `nameId` (when the `Subject` has one),
917
+ `expiresAt`, `assertionId`, `issuer`, `nameId` (the `Subject`'s `NameID`;
918
+ `undefined` when it carries none or more than one — `NameID` is surfaced,
919
+ never refused),
814
920
  `raw` and `signedXml`; they leave `sessionIndex` and `attributes` unset.
815
921
 
816
922
  - **`raw`** is the validator's input, unchanged — a `samlp:Response`, or a bare
@@ -869,7 +975,7 @@ assertion must answer it; absent, the assertion must carry no `InResponseTo`.
869
975
  | Error | When |
870
976
  |---|---|
871
977
  | `AssertionValidationError` | an assertion was refused. `check` (type `AssertionCheck`) names the row above — tell "your IdP declined" (`status`) from "not addressed to us" (`audience`, `bearerConfirmation`, `destination`) without parsing the message. `code` is `'ASSERTION_VALIDATION_ERROR'` (`ASSERTION_ERROR_CODES.VALIDATION_ERROR` from `@mcp-abap-adt/interfaces-auth`) |
872
- | `ValidationError` | configuration: `idpCertificates` or `idpEntityId` missing with no `assertionValidator`, or `idpEntityId` missing with a shipped validator supplied as `assertionValidator` (at construction); `idpInitiated` with no `authorizationUrl` and a strategy that calls `buildAuthorizationUrl` (inside the builder, before any URL is produced); `authnRequestId` missing, or `idpInitiated` combined with a declared `authnRequestId` (at login, after the strategy returns and before the assertion is read). `missingFields` names the field |
978
+ | `ValidationError` | configuration: `idpCertificates` or `idpEntityId` missing with no `assertionValidator`, or `idpEntityId` missing with a shipped validator supplied as `assertionValidator` (at construction); `idpInitiated` with no `authorizationUrl` and a strategy that calls `buildAuthorizationUrl` (inside the builder, before any URL is produced); `idpInitiated` combined with a declared `authnRequestId` (at construction); `authnRequestId` missing (at login, after the strategy returns and before the assertion is read). `missingFields` names the field |
873
979
  | `Error` | a certificate that is neither PEM nor base64 DER, or not a valid X.509 certificate; a `clockSkewMs` that is not a finite non-negative integer; and, for a shipped validator called directly, an empty `idpCertificates` (*"must not be empty"*) — all at construction. Through a provider, an empty `idpCertificates` is a `ValidationError` instead |
874
980
 
875
981
  ### With Stores
@@ -1160,6 +1266,53 @@ try {
1160
1266
 
1161
1267
  All error codes are defined in `@mcp-abap-adt/interfaces-auth` package as `TOKEN_PROVIDER_ERROR_CODES`, and `AssertionValidationError`'s as `ASSERTION_ERROR_CODES`.
1162
1268
 
1269
+ ## Upgrading from 4.0 to 4.1
1270
+
1271
+ 4.1.0 changes no export, configuration field or error class. It closes what
1272
+ the 4.0.0 reviews deferred, and three refusals get stricter.
1273
+
1274
+ **What now fails that used to pass:**
1275
+
1276
+ - a document carrying a SAML 1.x `Assertion`
1277
+ (`urn:oasis:names:tc:SAML:1.0:assertion`) outside the signed assertion — in
1278
+ an unsigned `Extensions`, say — under either validator (`signedNode`). 4.0
1279
+ refused a stray SAML 2.0 assertion but not a SAML 1.x one;
1280
+ - a document carrying an `Assertion` or `EncryptedAssertion` (SAML 2.0) or a
1281
+ SAML 1.x `Assertion` inside a `ds:Signature` — in `ds:Object`, say — under
1282
+ either validator (`signedNode`). An enveloped signature leaves its own
1283
+ subtree unsigned, and 4.0's assertion-only validator accepted such an
1284
+ element inside the signed assertion's signature, `Saml2BearerProvider`'s
1285
+ default included;
1286
+ - a provider configured with both `idpInitiated: true` and `authnRequestId`.
1287
+ Its constructor now throws a `ValidationError` (`missingFields:
1288
+ ['idpInitiated']`); in 4.0 it constructed, and a `ValidationError` with the
1289
+ same `missingFields` came only after `authorize()` returned. It could never
1290
+ log in. A `Saml2BearerProvider` seeded with a `refreshToken` did work in 4.0
1291
+ until that token lapsed, since a refresh never reaches the strategy; it now
1292
+ fails at construction.
1293
+
1294
+ Nothing else 4.0 accepted is refused now, and no refusal moved to a different
1295
+ `check`. Every other count rule — one `ds:Reference` per signature, one
1296
+ `Status`, `StatusCode`, `Issuer`, `Conditions`, `Subject` and
1297
+ `SubjectConfirmationData`, an `AudienceRestriction` naming an `Audience` —
1298
+ refused the same documents in 4.0; only its message is new.
1299
+
1300
+ **Also changed:**
1301
+
1302
+ - **Refusal messages are reworded** so each names its rule — see
1303
+ [Refusal messages](#refusal-messages). `check` is unchanged for every
1304
+ refusal. Code matching on message text must match on `check` instead.
1305
+ - `bearerConfirmation` refusals list why each candidate failed, in document
1306
+ order, at most five.
1307
+ - Values from the document are quoted and cut in every message, including
1308
+ the `Status` code, the issuer, `Destination`, the `Conditions` dates,
1309
+ xml-crypto's own messages about a malformed signature, and the parser
1310
+ message in `Saml2BearerProvider`'s bearer conversion.
1311
+ - The `bin` commands `auth-authorization-code` and `auth-client-credentials`
1312
+ are removed. They never ran from an npm install: they pointed at `.ts`
1313
+ files needing `tsx`, a devDependency, and imported `src/`, which is not
1314
+ published.
1315
+
1163
1316
  ## Migrating from 3.x to 4.0
1164
1317
 
1165
1318
  4.0.0 changes nothing outside the two SAML providers. For those, it validates
@@ -1622,7 +1775,7 @@ Example output:
1622
1775
 
1623
1776
  ## Dependencies
1624
1777
 
1625
- - `@mcp-abap-adt/interfaces-auth` (^2.0.0) - Token provider, authorization and assertion-validation contracts (`ITokenProvider`, `IAuthorizationStrategy`, `CallbackServerFactory`, `IAssertionValidator`, `IAssertionReplayStore`) and error code constants
1778
+ - `@mcp-abap-adt/interfaces-auth` (^2.0.1) - Token provider, authorization and assertion-validation contracts (`ITokenProvider`, `IAuthorizationStrategy`, `CallbackServerFactory`, `IAssertionValidator`, `IAssertionReplayStore`) and error code constants
1626
1779
  - `@mcp-abap-adt/interfaces-auth-sap` (^1.0.1) - XSUAA authorization configuration (`IAuthorizationConfig`)
1627
1780
  - `@mcp-abap-adt/interfaces-utils` (^1.1.0) - `ILogger`
1628
1781
  - `@xmldom/xmldom` - XML parsing: SAML assertion validation, and taking the Assertion out of a SAMLResponse for the saml2-bearer grant
@@ -1 +1 @@
1
- {"version":3,"file":"samlBearerAssertion.d.ts","sourceRoot":"","sources":["../../src/auth/samlBearerAssertion.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAQH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAmDzD"}
1
+ {"version":3,"file":"samlBearerAssertion.d.ts","sourceRoot":"","sources":["../../src/auth/samlBearerAssertion.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AASH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAqDzD"}
@@ -24,6 +24,7 @@
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.toBearerAssertion = toBearerAssertion;
26
26
  const xmldom_1 = require("@xmldom/xmldom");
27
+ const signedNode_1 = require("../validation/signedNode");
27
28
  const strictXml_1 = require("./strictXml");
28
29
  const SAML_ASSERTION_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';
29
30
  const SAML_PROTOCOL_NS = 'urn:oasis:names:tc:SAML:2.0:protocol';
@@ -38,7 +39,9 @@ function toBearerAssertion(payload) {
38
39
  root = (0, strictXml_1.parseStrictXml)(xml).documentElement;
39
40
  }
40
41
  catch (error) {
41
- throw new Error(`SAML bearer payload is not well-formed XML: ${error instanceof Error ? error.message : String(error)}`);
42
+ // The parser quotes the document (an element name, for one), so its
43
+ // message is quoted and cut like any other document value in a message.
44
+ throw new Error(`SAML bearer payload is not well-formed XML: ${(0, signedNode_1.quoteUntrusted)(error instanceof Error ? error.message : String(error))}`);
42
45
  }
43
46
  if (isElement(root, SAML_ASSERTION_NS, 'Assertion')) {
44
47
  return Buffer.from(xml, 'utf8').toString('base64url');
@@ -35,8 +35,9 @@ export interface Saml2CommonConfig {
35
35
  authnRequestId?: string;
36
36
  /**
37
37
  * Declares that no AuthnRequest is sent: the assertion must carry no
38
- * `InResponseTo`. Default `false`. Combining this with a minted or declared
39
- * request ID is a configuration error: the two describe different logins.
38
+ * `InResponseTo`. Default `false`. Combining this with a request ID is a
39
+ * configuration error, since the two describe different logins: with
40
+ * `authnRequestId` a provider refuses at construction.
40
41
  */
41
42
  idpInitiated?: boolean;
42
43
  /** Which validator to use. Omitted means the provider's own default. */
@@ -1 +1 @@
1
- {"version":3,"file":"saml2Utils.d.ts","sourceRoot":"","sources":["../../src/providers/saml2Utils.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EACV,qBAAqB,EACrB,mBAAmB,EACnB,sBAAsB,EACvB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAU9D,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oEAAoE;IACpE,aAAa,CAAC,EAAE,sBAAsB,CAAC,MAAM,CAAC,CAAC;IAC/C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,wEAAwE;IACxE,kBAAkB,CAAC,EAAE,mBAAmB,CAAC;IACzC,qFAAqF;IACrF,oBAAoB,CAAC,EAAE,qBAAqB,CAAC;CAC9C;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,gEAAgE;AAChE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAOlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,iBAAiB,EACzB,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAC1B,mBAAmB,CAsCrB;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAQzE;AAED,gGAAgG;AAChG,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,2FAA2F;IAC3F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,mBAAmB,CAAC,CAuE9B"}
1
+ {"version":3,"file":"saml2Utils.d.ts","sourceRoot":"","sources":["../../src/providers/saml2Utils.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EACV,qBAAqB,EACrB,mBAAmB,EACnB,sBAAsB,EACvB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAU9D,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oEAAoE;IACpE,aAAa,CAAC,EAAE,sBAAsB,CAAC,MAAM,CAAC,CAAC;IAC/C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,wEAAwE;IACxE,kBAAkB,CAAC,EAAE,mBAAmB,CAAC;IACzC,qFAAqF;IACrF,oBAAoB,CAAC,EAAE,qBAAqB,CAAC;CAC9C;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,gEAAgE;AAChE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAkBlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,iBAAiB,EACzB,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAC1B,mBAAmB,CAsCrB;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAQzE;AAED,gGAAgG;AAChG,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,2FAA2F;IAC3F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,mBAAmB,CAAC,CAuE9B"}
@@ -17,6 +17,14 @@ function validateSamlConfig(config) {
17
17
  throw new Error('acsUrl is required when authorizationUrl is set: the ACS inside a ' +
18
18
  'pre-built SAML request cannot be read, so it must be declared.');
19
19
  }
20
+ // The runtime check in resolveExpectedRequestId stays for a direct caller
21
+ // of getSamlAssertion; through a provider this refuses first, before any
22
+ // browser opens.
23
+ if (config.idpInitiated && config.authnRequestId) {
24
+ throw new TokenProviderErrors_1.ValidationError('SAML idpInitiated is true and authnRequestId is set: an IdP-initiated ' +
25
+ 'login sends no request, so the two describe different logins. ' +
26
+ 'Remove one of them.', ['idpInitiated']);
27
+ }
20
28
  }
21
29
  /**
22
30
  * The consumer's validator when supplied, otherwise the provider's default —
@@ -1 +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"}
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;AA8BvC,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;AAsCD,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"}