@mcp-abap-adt/auth-providers 3.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +773 -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 +6 -2
  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 +50 -2
  27. package/dist/providers/saml2Utils.d.ts.map +1 -1
  28. package/dist/providers/saml2Utils.js +102 -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 +519 -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 +182 -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 -10
  45. package/bin/auth-authorization-code.ts +0 -147
  46. package/bin/auth-client-credentials.ts +0 -109
  47. package/bin/utils/parseConfig.ts +0 -270
package/README.md CHANGED
@@ -35,9 +35,19 @@ Since 2.0.0 an interactive login is conducted by an **authorization strategy**
35
35
  and the token exchange; everything between them (reaching the URL, receiving
36
36
  what comes back, the port, the timeout) belongs to the strategy, which a
37
37
  consumer may replace wholesale. See
38
- [Choosing an authorization strategy](#choosing-an-authorization-strategy) and,
39
- if you are on an earlier major, [Migrating from 2.x to 3.0](#migrating-from-2x-to-30)
40
- and [Migrating from 1.x to 2.0](#migrating-from-1x-to-20).
38
+ [Choosing an authorization strategy](#choosing-an-authorization-strategy).
39
+
40
+ Since 4.0.0 both SAML providers **validate the assertion before trusting it** —
41
+ its signature, issuer, audience, recipient, time window, the request it answers,
42
+ and whether it has been seen before — and must therefore be told which identity
43
+ provider to trust. This is a breaking change: a 3.x SAML configuration fails at
44
+ construction. See [SAML assertion validation](#saml-assertion-validation).
45
+
46
+ If you are on an earlier version, see
47
+ [Upgrading from 4.0 to 4.1](#upgrading-from-40-to-41),
48
+ [Migrating from 3.x to 4.0](#migrating-from-3x-to-40),
49
+ [Migrating from 2.x to 3.0](#migrating-from-2x-to-30) and
50
+ [Migrating from 1.x to 2.0](#migrating-from-1x-to-20).
41
51
 
42
52
  ## Responsibilities and Design Principles
43
53
 
@@ -65,6 +75,7 @@ This package is responsible for:
65
75
  2. **Token acquisition**: Handles OAuth2 flows (browser-based, refresh token, client credentials) to obtain JWT tokens
66
76
  3. **Token validation**: Validates JWT locally by checking exp claim (no HTTP requests)
67
77
  4. **OAuth2 flows**: Manages browser-based OAuth2 authorization code flow and refresh token flow
78
+ 5. **SAML assertion validation**: Verifies a SAML assertion — signature, issuer, audience, recipient, time window, request ID, replay — before either SAML provider uses it
68
79
 
69
80
  #### What This Package Does
70
81
 
@@ -73,6 +84,7 @@ This package is responsible for:
73
84
  - **Obtains tokens**: Makes HTTP requests to UAA endpoints to obtain JWT tokens
74
85
  - **Validates tokens**: Validates JWT locally by checking exp claim (no HTTP requests)
75
86
  - **Returns tokens**: Returns `ITokenResult` with `authorizationToken` and optional `refreshToken`
87
+ - **Validates SAML assertions**: Ships two validators (`createSignedResponseValidator`, `createSignedAssertionValidator`) and an in-memory replay store; both SAML providers use one by default, and a consumer may supply its own `IAssertionValidator` or `IAssertionReplayStore`
76
88
 
77
89
  #### What This Package Does NOT Do
78
90
 
@@ -81,6 +93,7 @@ This package is responsible for:
81
93
  - **Does NOT know about service keys**: Service key loading is handled by stores
82
94
  - **Does NOT manage sessions**: Session management is handled by stores
83
95
  - **Does NOT return `serviceUrl` if unknown**: Providers may not return `serviceUrl` because they only handle token acquisition, not connection configuration
96
+ - **Does NOT fetch identity provider metadata**: The certificates and entity ID a SAML assertion is checked against come from configuration; reading them from a file or a metadata URL is the consumer's job
84
97
 
85
98
  ### External Dependencies
86
99
 
@@ -328,88 +341,91 @@ The redirect URI is no longer a provider field: it belongs to the strategy,
328
341
  because with an ephemeral port nothing knows it until the socket is bound. The
329
342
  one the strategy reports is the one sent to the token endpoint.
330
343
 
331
- SAML bearer example (manual paste):
332
-
333
- ```typescript
334
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
335
- import {
336
- Saml2BearerProvider,
337
- manualSamlResponseStrategy,
338
- } from '@mcp-abap-adt/auth-providers';
339
-
340
- const acsUrl = 'https://sp.example.com/saml/acs';
341
-
342
- const provider = new Saml2BearerProvider({
343
- idpSsoUrl: 'https://idp.example.com/sso',
344
- spEntityId: 'my-sp-entity',
345
- acsUrl,
346
- uaaUrl: 'https://uaa.example.com',
347
- clientId: '...',
348
- clientSecret: '...',
349
- // `redirectUri` must equal `acsUrl`, or the provider refuses the mismatch.
350
- authorization: manualSamlResponseStrategy({ redirectUri: acsUrl, read: promptUser }),
351
- });
352
-
353
- const broker = new AuthBroker({ tokenProvider: provider }, 'none');
354
- ```
344
+ Both SAML providers validate every assertion before using it, so every SAML
345
+ example below says whom to trust: `idpCertificates` and `idpEntityId`. See
346
+ [SAML assertion validation](#saml-assertion-validation) for what is checked and
347
+ what else can be configured.
355
348
 
356
- **Read that `redirectUri` twice.** A SAML strategy defaults its redirect URI to
357
- `http://localhost:61001/callback`, and the provider requires the assertion
358
- consumer service the IdP posts to be exactly the one the strategy names. If you
359
- declare a real `acsUrl` and leave `redirectUri` off, the login fails with
360
- *"SAML acsUrl is … but the authorization strategy is listening on …"* before
361
- anything is opened. Declare neither and the default is used for both, which is
362
- consistent — and only reachable when the IdP will post to your localhost.
363
-
364
- SAML bearer example (headless, assertion fetched elsewhere):
349
+ SAML bearer example (UAA or XSUAA — an IdP-initiated assertion):
365
350
 
366
351
  ```typescript
352
+ import { readFileSync } from 'node:fs';
367
353
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
368
- import {
369
- Saml2BearerProvider,
370
- externalCodeStrategy,
371
- } from '@mcp-abap-adt/auth-providers';
372
-
373
- const acsUrl = 'https://sp.example.com/saml/acs';
354
+ import type { IAuthorizationStrategy } from '@mcp-abap-adt/interfaces-auth';
355
+ import { Saml2BearerProvider } from '@mcp-abap-adt/auth-providers';
356
+
357
+ // The Recipient the assertion names: the URI-binding assertion consumer
358
+ // service in the token endpoint's SAML metadata (UAA's is /oauth/token/alias/…).
359
+ const acsUrl = 'https://uaa.example.com/oauth/token/alias/uaa.example';
360
+
361
+ // An IdP-initiated login answers no AuthnRequest, so this strategy never calls
362
+ // request.buildAuthorizationUrl — with idpInitiated: true and no
363
+ // authorizationUrl, the builder refuses before producing a URL, since the only
364
+ // one it could build carries an AuthnRequest. It fetches a fresh assertion on every login;
365
+ // the same assertion presented twice is refused as a replay.
366
+ const fromSsoProxy: IAuthorizationStrategy<string> = {
367
+ async authorize() {
368
+ return { payload: await getSamlResponseFromSsoProxy(), redirectUri: acsUrl };
369
+ },
370
+ };
374
371
 
375
372
  const provider = new Saml2BearerProvider({
376
373
  idpSsoUrl: 'https://idp.example.com/sso',
377
- spEntityId: 'my-sp-entity',
374
+ spEntityId: 'uaa.example', // the entityID in that metadata: the Audience
378
375
  acsUrl,
379
376
  uaaUrl: 'https://uaa.example.com',
380
377
  clientId: '...',
381
378
  clientSecret: '...',
382
- authorization: externalCodeStrategy({
383
- redirectUri: acsUrl,
384
- provide: async (_authorizationUrl) => getSamlResponseFromSsoProxy(),
385
- }),
379
+ // Whom to trust: the identity provider's signing certificate and entity ID.
380
+ idpCertificates: [readFileSync('idp-signing.pem', 'utf8')],
381
+ idpEntityId: 'https://idp.example.com/metadata',
382
+ // UAA and XSUAA refuse an assertion carrying InResponseTo.
383
+ idpInitiated: true,
384
+ authorization: fromSsoProxy,
386
385
  });
387
386
 
388
387
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
389
388
  ```
390
389
 
391
390
  **Who starts the login matters.** An identity provider answering an
392
- `AuthnRequest` — which is what the provider's own URL, and every shipped SAML
393
- strategy, sends — puts `InResponseTo` on the assertion's subject
394
- confirmation. UAA's saml2-bearer grant refuses any assertion that carries it:
395
- there is no request on its side to match it against, and UAA's
396
- `disableInResponseToCheck` applies to web SSO only. Measured against Cloud
397
- Foundry UAA with Keycloak as the identity provider, an SP-initiated login is
398
- refused with *"SubjectConfirmationData/@InResponseTo … did not match the valid
399
- value: null"*, and an IdP-initiated one — started at the IdP, answering no
400
- request — is accepted. Against UAA, supply the assertion from an IdP-initiated
401
- login, for instance through `externalCodeStrategy` whose `provide` ignores the
402
- URL it is handed. Whether XSUAA behaves the same has not been verified.
391
+ `AuthnRequest` — which is what the provider's own URL carries, and so what every
392
+ shipped strategy that opens or shows that URL sends — puts `InResponseTo` on
393
+ the assertion's subject confirmation. The saml2-bearer grant of Cloud Foundry UAA and of SAP
394
+ XSUAA refuses any assertion that carries it: there is no request on their side
395
+ to match it against, and UAA's `disableInResponseToCheck` applies to web SSO
396
+ only. Measured: UAA, with Keycloak as the identity provider, refuses the answer
397
+ to an SP-initiated login with *"SubjectConfirmationData/@InResponseTo … did not
398
+ match the valid value: null"*, and XSUAA — measured with 3.x, which sent such an
399
+ assertion on — refuses one carrying `InResponseTo` with *"No subject
400
+ confirmation methods were met"*; both accept an IdP-initiated one — started at
401
+ the IdP, answering no request. (4.0 refuses that case itself, at
402
+ `bearerConfirmation`, before XSUAA sees it.) Against either,
403
+ supply an IdP-initiated assertion, declare `idpInitiated: true`, and use a
404
+ strategy that does not call
405
+ `buildAuthorizationUrl`: `staticCodeStrategy`, or your own as above.
406
+ `samlCallbackStrategy`, `manualSamlResponseStrategy` and `externalCodeStrategy`
407
+ all call it, and with `idpInitiated: true` and no `authorizationUrl` the builder
408
+ refuses: a `ValidationError` (`missingFields: ['authorizationUrl']`) thrown
409
+ before any URL is produced, so before a browser opens. (3.0's advice —
410
+ `externalCodeStrategy` whose `provide` ignores the URL — no longer works for
411
+ that reason.) See
412
+ [Where the expected request ID comes from](#where-the-expected-request-id-comes-from).
413
+
414
+ The `redirectUri` your strategy reports is the ACS the assertion is checked
415
+ against — its `SubjectConfirmationData/@Recipient` must equal it — so for the
416
+ bearer grant it is the token endpoint's bearer ACS, not a local callback.
403
417
 
404
418
  **What is sent.** The saml2-bearer grant takes one SAML Assertion,
405
419
  base64url-encoded (RFC 7522 §2.1). A strategy may deliver either that or the
406
420
  whole `SAMLResponse` an identity provider posts, in standard base64 —
407
- `Saml2BearerProvider` takes the Assertion out of a Response and re-encodes it,
408
- copying onto it every namespace declaration it inherited — including one used
409
- only inside a value such as `xsi:type="xs:string"`. The Assertion must carry
410
- its own signature: one over the Response alone does not survive the cut, and
411
- the token endpoint refuses the Assertion. An `EncryptedAssertion` is refused
412
- before anything is sent.
421
+ `Saml2BearerProvider` validates what it received, then takes the Assertion out
422
+ of a Response and re-encodes it, copying onto it every namespace declaration it
423
+ inherited — including one used only inside a value such as
424
+ `xsi:type="xs:string"`. The Assertion must carry its own signature: one over the
425
+ Response alone does not survive the cut, and the token endpoint refuses the
426
+ Assertion — which is why this provider's default validator is the one that
427
+ requires the Assertion to be signed. An `EncryptedAssertion` is refused before
428
+ anything is sent.
413
429
 
414
430
  **Refresh.** When the token endpoint returns a `refresh_token` with the SAML
415
431
  bearer exchange, `Saml2BearerProvider` spends it once the access token expires:
@@ -419,9 +435,10 @@ browser involved. Pass a stored one back as `refreshToken` in the config and the
419
435
  next `getTokens()` uses it. If the grant is refused, or no refresh token was
420
436
  ever issued, the provider falls back to a full login through `authorization`.
421
437
 
422
- Pure SAML example (cookie-based):
438
+ Pure SAML example (cookie-based, SP-initiated):
423
439
 
424
440
  ```typescript
441
+ import { readFileSync } from 'node:fs';
425
442
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
426
443
  import {
427
444
  Saml2PureProvider,
@@ -434,6 +451,10 @@ const provider = new Saml2PureProvider({
434
451
  idpSsoUrl: 'https://idp.example.com/sso',
435
452
  spEntityId: 'my-sp-entity',
436
453
  acsUrl,
454
+ idpCertificates: [readFileSync('idp-signing.pem', 'utf8')],
455
+ idpEntityId: 'https://idp.example.com/metadata',
456
+ // Shows the URL the provider builds — so the response must answer that
457
+ // request's ID — and reads the pasted SAMLResponse.
437
458
  authorization: manualSamlResponseStrategy({ redirectUri: acsUrl, read: promptUser }),
438
459
  // Convert SAMLResponse to session cookies for SAP (implementation-specific)
439
460
  cookieProvider: async (samlResponse) => {
@@ -444,6 +465,17 @@ const provider = new Saml2PureProvider({
444
465
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
445
466
  ```
446
467
 
468
+ `cookieProvider` receives the payload unchanged, only after it has been
469
+ validated, and the session's `expiresAt` is the validated assertion's expiry.
470
+
471
+ **Read that `redirectUri` twice.** A SAML strategy defaults its redirect URI to
472
+ `http://localhost:61001/callback`, and the provider requires the assertion
473
+ consumer service the IdP posts to be exactly the one the strategy names. If you
474
+ declare a real `acsUrl` and leave `redirectUri` off, the login fails with
475
+ *"SAML acsUrl is … but the authorization strategy is listening on …"* before
476
+ anything is opened. Declare neither and the default is used for both, which is
477
+ consistent — and only reachable when the IdP will post to your localhost.
478
+
447
479
  Both SAML providers now reject at construction when `authorizationUrl` is set
448
480
  without `acsUrl`:
449
481
 
@@ -455,7 +487,496 @@ SAML request cannot be read, so it must be declared.
455
487
  The ACS is buried in a deflated `SAMLRequest` this package did not build and
456
488
  cannot read, so it cannot be verified against whatever the strategy binds. 1.x
457
489
  accepted the combination and defaulted the ACS to
458
- `http://localhost:3001/callback` — usually not where the IdP posted.
490
+ `http://localhost:3001/callback` — usually not where the IdP posted. The same
491
+ holds for the request ID: this package cannot read it out of a URL it did not
492
+ build, so a pre-built `authorizationUrl` also needs `authnRequestId` — unless
493
+ the login is declared `idpInitiated`.
494
+
495
+ ### SAML assertion validation
496
+
497
+ Since 4.0.0, `Saml2BearerProvider` and `Saml2PureProvider` validate the
498
+ assertion a login delivers before anything else happens to it — before the
499
+ token exchange, before `cookieProvider`. Until 3.x nothing verified it: the
500
+ callback checked only that the payload was non-empty, and `Saml2PureProvider`
501
+ took its session lifetime from a regular expression over the unverified XML.
502
+
503
+ **This is a breaking change.** Each provider constructs its validator in its
504
+ constructor, and a configuration that does not say whom to trust fails there —
505
+ a `ValidationError` whose `missingFields` names what is missing — before any
506
+ browser opens or any request is sent. Supply `idpCertificates` and
507
+ `idpEntityId`, or an `assertionValidator` of your own.
508
+
509
+ #### Configuration
510
+
511
+ On both providers' configuration (`Saml2BearerProviderConfig`, `Saml2PureProviderConfig`):
512
+
513
+ | Field | Default | Meaning |
514
+ |---|---|---|
515
+ | `idpCertificates` | — | The identity provider's signing certificates, PEM or bare base64 DER — the form `<X509Certificate>` has in IdP metadata. A list, because providers rotate keys and two are live during a rotation. **Required unless `assertionValidator` is supplied.** Each entry is parsed at construction, so a malformed one fails there, not at login |
516
+ | `idpEntityId` | — | The `Issuer` the assertion must name, passed to the validator as `expectedIssuer`. **Required unless the `assertionValidator` supplied is your own**: a shipped validator (`createSignedResponseValidator`, `createSignedAssertionValidator`) refuses every assertion without an expected issuer, so supplying one without `idpEntityId` fails at construction. A custom validator does not need it, and receives it when given |
517
+ | `spEntityId` | — | Your entity ID. The assertion's `AudienceRestriction` must name it — whichever validator is in play |
518
+ | `assertionValidator` | the provider's default | An `IAssertionValidator`: `createSignedResponseValidator(…)`, `createSignedAssertionValidator(…)`, or your own. When supplied, the provider builds no default, and `idpCertificates`, `clockSkewMs` and `assertionReplayStore` are not used — set them on the validator's own options. `idpEntityId` is still required with a shipped one |
519
+ | `assertionReplayStore` | the process-wide in-memory store | An `IAssertionReplayStore` for the default validator — see [Replay](#replay) |
520
+ | `clockSkewMs` | `0` | Tolerance for the default validator's time checks — see [Clock skew](#clock-skew) |
521
+ | `authnRequestId` | — | The AuthnRequest ID this login answers, when the package did not build the request — see [Where the expected request ID comes from](#where-the-expected-request-id-comes-from) |
522
+ | `idpInitiated` | `false` | Declares that no AuthnRequest was sent, so the assertion must carry no `InResponseTo`. Required for `Saml2BearerProvider` against UAA or XSUAA |
523
+
524
+ The package performs no I/O for any of these: it fetches no metadata and reads
525
+ no file. Reading the certificate is the consumer's job.
526
+
527
+ #### Choosing a validator
528
+
529
+ This is the first decision to make, and **the default differs by provider**:
530
+
531
+ | | `createSignedResponseValidator` | `createSignedAssertionValidator` |
532
+ |---|---|---|
533
+ | The signature must cover | the `Response` | the `Assertion` — bare, or inside a Response |
534
+ | Default of | `Saml2PureProvider` | `Saml2BearerProvider` |
535
+ | Reads `Status`, `Response/Issuer`, `Destination` | yes — inside the signature | **not at all** |
536
+ | Accepts a bare `saml:Assertion` | no | yes |
537
+ | Checks performed | all twelve below | all but rows 4, 5b and 11 |
538
+
539
+ Both take the same options (`ShippedValidatorOptions`) and return the same
540
+ interface, so switching is one identifier:
541
+
542
+ ```typescript
543
+ import { readFileSync } from 'node:fs';
544
+ import {
545
+ Saml2PureProvider,
546
+ createSignedAssertionValidator,
547
+ } from '@mcp-abap-adt/auth-providers';
548
+
549
+ const idpCertificates = [readFileSync('idp-signing.pem', 'utf8')];
550
+
551
+ const provider = new Saml2PureProvider({
552
+ idpSsoUrl: 'https://idp.example.com/sso',
553
+ spEntityId: 'my-sp-entity',
554
+ acsUrl: 'https://sp.example.com/saml/acs',
555
+ // Still required with a shipped validator, which refuses every assertion
556
+ // without an expected issuer; construction fails without it.
557
+ idpEntityId: 'https://idp.example.com/metadata',
558
+ // Our identity provider signs only its assertions.
559
+ assertionValidator: createSignedAssertionValidator({
560
+ idpCertificates,
561
+ clockSkewMs: 30_000,
562
+ }),
563
+ cookieProvider: exchangeSamlForCookies,
564
+ });
565
+ ```
566
+
567
+ **`createSignedResponseValidator`** requires the identity provider to sign the
568
+ `Response`. Every field all twelve checks read is then inside the signature, so
569
+ every check is a control. It is `Saml2PureProvider`'s default because there the
570
+ whole response is handed on to `cookieProvider`, and `Status` and `Destination`
571
+ must be inside a signature.
572
+
573
+ **`createSignedAssertionValidator`** accepts a signature over the `Assertion`,
574
+ and **does not read** `Status`, `Response/Issuer` or `Destination` at all — not
575
+ weakly: with an assertion-only signature those fields sit outside it, where
576
+ anyone able to deliver a response sets them to whatever is expected, and a check
577
+ on a field an attacker controls reads in the code and the logs as if something
578
+ had been verified. It is `Saml2BearerProvider`'s default because the token
579
+ endpoint receives the Assertion alone, taken out of any Response, so the
580
+ Assertion's own signature is what counts there. Configuring the signed-Response
581
+ validator on the bearer path would refuse a bare Assertion, and accept responses
582
+ signed only at the Response level, which the token endpoint then refuses.
583
+
584
+ **Who needs the second one.** Identity providers that sign only assertions —
585
+ which is many. A `Saml2PureProvider` consumer whose IdP does so gets a
586
+ `signedNode` refusal from the default, and selects
587
+ `createSignedAssertionValidator` explicitly. What that gives up is the three
588
+ checks above. It is still sound:
589
+
590
+ - **`Status`** — a declined login carries no assertion. An identity provider
591
+ that refuses does not mint one, so flipping `Status` to `Success` leaves an
592
+ attacker with nothing signed to put beneath it. Success is established by a
593
+ signed assertion passing every assertion-level check.
594
+ - **`Destination`** — addressing rests on
595
+ `SubjectConfirmationData/@Recipient`, which is inside the signed assertion and
596
+ required by check 10.
597
+ - **`Response/Issuer`** — the assertion's own `Issuer`, inside the signature, is
598
+ checked against `idpEntityId`.
599
+
600
+ An identity provider that signs both the Response and the Assertion — Keycloak
601
+ does by default — satisfies either validator.
602
+
603
+ #### What the validators check
604
+
605
+ In this order; each refusal is an `AssertionValidationError` whose `check`
606
+ names the row. Rows marked *(signed-Response only)* are not performed by
607
+ `createSignedAssertionValidator`.
608
+
609
+ | # | Check | Refused when | `check` |
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` |
612
+ | 1b | Every `ID` attribute in the document is unique | any value appears twice | `duplicateId` |
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` |
616
+ | 4b | `Assertion/@ID` | absent or empty | `assertionId` |
617
+ | 5 | `Assertion/Issuer` | absent, more than one, empty, not the expected issuer, or no expected issuer was given | `issuer` |
618
+ | 5b | `Response/Issuer` *(signed-Response only; optional)* | present and disagreeing with `Assertion/Issuer`, or present twice — absent is accepted | `issuer` |
619
+ | 6 | `Conditions` | absent, or more than one | `conditions` |
620
+ | 7 | `Conditions/@NotBefore` *(optional)* | present and not a valid `xsd:dateTime`, or in the future beyond `clockSkewMs` — absent is accepted | `notBefore` |
621
+ | 8 | `Conditions/@NotOnOrAfter` | absent, not a valid `xsd:dateTime`, or in the past beyond `clockSkewMs` | `notOnOrAfter` |
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` |
624
+ | 11 | `Response/@Destination` *(signed-Response only)* | absent, or not the ACS the response arrived at | `destination` |
625
+ | 12 | Replay | the store has already recorded this `{issuer, ID}` | `replay` |
626
+
627
+ What the table compresses:
628
+
629
+ - **Every required field is refused when absent**, not skipped: a rule
630
+ phrased "present and not X" is one an attacker satisfies by deleting the
631
+ field. That covers `Status` and `Destination` (signed-Response only),
632
+ `Assertion/@ID`, `Assertion/Issuer`, `Conditions`, `Conditions/@NotOnOrAfter`,
633
+ the `AudienceRestriction`, and, in the bearer confirmation, `Recipient`,
634
+ `NotOnOrAfter` and — when a request ID is expected — `InResponseTo`. Of the
635
+ fields the validators read, only these may be missing, each for a reason:
636
+ - `Conditions/@NotBefore` and `SubjectConfirmationData/@NotBefore` — a
637
+ missing `NotBefore` only means "valid from issue"; when present it is
638
+ checked;
639
+ - `Response/Issuer` — optional in SAML Core, and the assertion's own `Issuer`
640
+ is checked inside the signature; when present it must agree (5b);
641
+ - `NameID` — surfaced on the result, not trusted for anything.
642
+ - **The signature must cover the element that is read.** A wrapping attack
643
+ supplies a document holding a genuinely signed fragment beside a forged one;
644
+ the validator resolves which element each signature covers and reads the
645
+ assertion's fields from that element only. And since a payload travels on
646
+ whole — `Saml2PureProvider` hands it to `cookieProvider` — **every**
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.
654
+ - **Several signatures are accepted** when every one verifies against
655
+ `idpCertificates`, carries exactly one same-document reference, and sits
656
+ directly inside the element it references. A signature that fails refuses the
657
+ whole document, even when another covers the element read. A document with no
658
+ signature is refused.
659
+ - **Unique IDs (1b)** are the wrapping defence again: XML-DSig resolves its
660
+ reference by `ID`, so a duplicate makes "which element is signed" ambiguous.
661
+ They are refused wherever they appear, before any reference is resolved.
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`.
677
+ - **Check 9 is AND across restrictions, OR within one**, as SAML Core §2.5.1.4
678
+ says: every `AudienceRestriction` must name you; the `Audience` elements
679
+ inside one are alternatives.
680
+ - **Dates are parsed strictly.** An `xsd:dateTime` must have real calendar
681
+ components — `2026-02-30T00:00:00Z`, which `Date.parse` quietly turns into
682
+ 2 March, is refused.
683
+ - **No DTD.** A `<!DOCTYPE` anywhere in the payload is refused at `document`
684
+ before it is parsed: a SAML message has no use for one, and the document is
685
+ parsed twice — by `@xmldom/xmldom` 0.9 here and by the 0.8 inside
686
+ `xml-crypto` — where a DTD is exactly what parsers disagree about.
687
+ - **Any XML fault is a refusal, and nothing reaches the console.** The parser
688
+ is given an error handler that throws on every level, so a payload it would
689
+ have repaired — an undeclared entity, say — is refused at `document` rather
690
+ than validated in its repaired form, and a malformed callback never writes
691
+ to stderr past your `ILogger`. The same holds for the bearer conversion.
692
+ - **SHA-1 is accepted.** RSA-SHA1 signatures and SHA-1 digests verify, as they
693
+ do under `xml-crypto`'s defaults, because identity providers still emit them
694
+ and refusing them would refuse genuine logins. To refuse them, supply an
695
+ `assertionValidator` of your own that rejects a `SignatureMethod` or
696
+ `DigestMethod` naming `…xmldsig#rsa-sha1` or `…xmldsig#sha1` before
697
+ delegating to a shipped validator — and keep `idpEntityId` configured, since
698
+ the shipped validator inside still refuses without an expected issuer.
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
+
792
+ **Expiry comes from the verified document.** A validated assertion's
793
+ `expiresAt` is the earlier of `Conditions/@NotOnOrAfter` and the `NotOnOrAfter`
794
+ of the bearer confirmation accepted — the earliest, if several qualify — so a
795
+ session cannot outlive a window the assertion itself closed.
796
+ `Saml2PureProvider` takes its session's `expiresAt` from it.
797
+ `parseSamlNotOnOrAfter`, the regular expression over unverified XML it replaces,
798
+ is gone.
799
+
800
+ **What remains unproven.** The validators verify signatures with `xml-crypto`.
801
+ They are tested against signatures `xml-crypto` itself produced (through
802
+ `@mcp-abap-adt/auth-mocks`) and against Keycloak, a real identity provider, on
803
+ the provider stand. Whether every other identity provider's canonicalisation
804
+ matches is not proven; a refusal at `signature` from a genuine response is the
805
+ symptom to report.
806
+
807
+ #### Where the expected request ID comes from
808
+
809
+ `InResponseTo` must answer the request that was sent — or, where none was sent
810
+ by explicit choice, be absent. The expected ID is decided before validation,
811
+ from one of three sources, and never inferred from the assertion:
812
+
813
+ | Source | When | `InResponseTo` must be |
814
+ |---|---|---|
815
+ | minted | the strategy called `buildAuthorizationUrl`, and the package built the AuthnRequest — `samlCallbackStrategy`, `manualSamlResponseStrategy`, `externalCodeStrategy`, or the default | equal to the ID the package minted |
816
+ | declared | `authnRequestId` is configured | equal to `authnRequestId` |
817
+ | none, by declaration | `idpInitiated: true`, and no request was sent | **absent** |
818
+
819
+ `authnRequestId` is **required** whenever the package did not build the request
820
+ and the login is not declared IdP-initiated. Two flows trigger it:
821
+
822
+ - a pre-built `authorizationUrl` — the package cannot read the ID out of a
823
+ request it did not build;
824
+ - a strategy that returns a payload without calling `buildAuthorizationUrl` —
825
+ `staticCodeStrategy`, or your own — after a request you sent some other way.
826
+
827
+ Without it, the login fails with a `ValidationError` (`missingFields:
828
+ ['authnRequestId']`) after the strategy returns and before the assertion is
829
+ read — as a configuration fault, not a refusal blamed on the assertion. A
830
+ strategy that merely forgot to call the builder must not silently switch the
831
+ provider into accepting unsolicited responses.
832
+
833
+ **`idpInitiated: true`** declares that the identity provider started the login
834
+ and no AuthnRequest exists, so the assertion must carry no `InResponseTo`.
835
+ `Saml2BearerProvider` against UAA or XSUAA needs it: both refuse an assertion
836
+ carrying `InResponseTo` on the saml2-bearer grant. What it gives up is the
837
+ **login-CSRF defence** of a request ID: with one, a response must answer the
838
+ request just sent; without it, whoever can deliver a validly signed response
839
+ of their own to your receiver can log your user in as themselves. That is
840
+ sometimes the right trade — for UAA and XSUAA it is the only one — but it must
841
+ be a decision visible in your configuration. **It is never inferred**: an
842
+ assertion without `InResponseTo` does not make a login IdP-initiated; only
843
+ `idpInitiated: true` does. The other checks apply unchanged.
844
+
845
+ `idpInitiated: true` together with a request ID is a configuration error too:
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
850
+ `authorizationUrl` configured is refused inside the builder, before a URL — and
851
+ so a request ID — exists: a `ValidationError` with `missingFields:
852
+ ['authorizationUrl']`. Use a strategy that does not call the builder, and leave
853
+ `authnRequestId` unset; or configure the identity provider's IdP-initiated SSO
854
+ URL as `authorizationUrl`, which the builder hands over without minting
855
+ anything.
856
+
857
+ #### Replay
858
+
859
+ The default replay store is **process-wide**: one module-level in-memory store,
860
+ `defaultReplayStore`, shared by every default validator in the process — both
861
+ providers, every instance. An assertion accepted once is refused as a replay
862
+ (`check: 'replay'`) for as long as it could still be accepted, however many
863
+ providers are constructed; a store per provider would let a second provider
864
+ accept what the first had seen. It is keyed by `{issuer, assertionId}`, since an
865
+ ID is unique only within the identity provider that minted it. Only an assertion
866
+ that passed every other check is recorded.
867
+
868
+ What it does **not** protect: anything across processes. A second process, a
869
+ restart, or a horizontally scaled deployment each start with an empty memory.
870
+ For those, supply a shared store — `assertionReplayStore` on the provider, or
871
+ `replayStore` on a shipped validator's options. Its `recordIfUnseen` must be
872
+ atomic — a single conditional write, never a read followed by a write — because
873
+ that race is exactly the one a replay exploits:
874
+
875
+ ```typescript
876
+ import type { IAssertionReplayStore } from '@mcp-abap-adt/interfaces-auth';
877
+
878
+ const sharedReplayStore: IAssertionReplayStore = {
879
+ async recordIfUnseen({ issuer, assertionId }, retainUntil) {
880
+ // e.g. Redis `SET key 1 NX PXAT <ms>`: true only when newly written.
881
+ return setIfAbsent(
882
+ `saml-replay:${issuer.length}:${issuer}:${assertionId}`,
883
+ retainUntil,
884
+ );
885
+ },
886
+ };
887
+ ```
888
+
889
+ The issuer is length-prefixed, as in the in-memory store, because both parts
890
+ may contain `:` — without the length, issuer `a:b` with ID `c` and issuer `a`
891
+ with ID `b:c` would share one key, and one would be refused as the other's
892
+ replay.
893
+
894
+ `createInMemoryReplayStore()` returns a store of your own, for isolation — a
895
+ test, or a component that must not share memory with the rest of the process.
896
+ The in-memory store prunes lazily when consulted, so it holds no timer and
897
+ needs no disposal.
898
+
899
+ #### Clock skew
900
+
901
+ `clockSkewMs` defaults to **`0`**: this package applies no leniency you did not
902
+ choose. It must be a finite, non-negative integer; anything else fails at
903
+ construction. It widens the `NotBefore` and `NotOnOrAfter` checks of both
904
+ `Conditions` and the bearer confirmation. A replay entry is retained until
905
+ the earlier of `Conditions/@NotOnOrAfter` and the **latest** `NotOnOrAfter` of
906
+ a bearer confirmation that answers the request and names the ACS — one not
907
+ open yet included — plus `clockSkewMs`: the last instant the assertion could
908
+ still be accepted. That is not `expiresAt`, which takes the earliest
909
+ confirmation; with confirmations closing at +120 s and +600 s the session ends
910
+ at +120 s, but the second still admits the assertion at +200 s, so the entry
911
+ must outlive it. Neither window nor tolerance cuts a hole in replay detection.
912
+
913
+ #### What a validated assertion carries: `raw` and `signedXml`
914
+
915
+ You meet a `ValidatedAssertion` when you call a validator yourself or wrap one
916
+ in an `IAssertionValidator` of your own. The shipped validators fill
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),
920
+ `raw` and `signedXml`; they leave `sessionIndex` and `attributes` unset.
921
+
922
+ - **`raw`** is the validator's input, unchanged — a `samlp:Response`, or a bare
923
+ `saml:Assertion` where the validator accepts one. It makes no promise about
924
+ what a provider forwards: `Saml2PureProvider` hands the payload to
925
+ `cookieProvider` as it is, while `Saml2BearerProvider` sends the extracted
926
+ Assertion, not `raw`. **Holding a `ValidatedAssertion` does not make all of
927
+ `raw` trustworthy**: a Response validated by `createSignedAssertionValidator`
928
+ carries `Status`, `Response/Issuer` and `Destination`, which nothing read and
929
+ nothing checked.
930
+ - **`signedXml`** is what the signature covered, serialised: the `Assertion`,
931
+ or the `Response` when that is what was signed. Anything this interface does
932
+ not surface — attributes, a session index — must be parsed from `signedXml`,
933
+ never from `raw`. The difference between the two is the difference between
934
+ "signed" and "arrived".
935
+
936
+ #### Using a shipped validator directly
937
+
938
+ ```typescript
939
+ import { readFileSync } from 'node:fs';
940
+ import {
941
+ AssertionValidationError,
942
+ createSignedResponseValidator,
943
+ } from '@mcp-abap-adt/auth-providers';
944
+
945
+ const validator = createSignedResponseValidator({
946
+ idpCertificates: [readFileSync('idp-signing.pem', 'utf8')],
947
+ });
948
+
949
+ try {
950
+ const validated = await validator.validate(samlResponseBase64, {
951
+ expectedInResponseTo: requestId, // omit only for an IdP-initiated login
952
+ audience: 'my-sp-entity',
953
+ acsUrl: 'https://sp.example.com/saml/acs',
954
+ expectedIssuer: 'https://idp.example.com/metadata', // required — see below
955
+ });
956
+ console.error(validated.nameId, validated.expiresAt);
957
+ } catch (error) {
958
+ if (error instanceof AssertionValidationError) {
959
+ console.error(`refused at ${error.check}: ${error.message}`);
960
+ }
961
+ throw error;
962
+ }
963
+ ```
964
+
965
+ **Pass `expectedIssuer`.** It is optional on `AssertionContext`, for custom
966
+ validators that establish trust some other way, but the shipped validators
967
+ **fail closed** without it: every assertion is refused at `issuer`, since
968
+ otherwise any issuer holding a key on your list would pass. The providers
969
+ always pass `idpEntityId` there; a caller of a shipped validator must pass it
970
+ itself. `expectedInResponseTo` follows the request-ID rule: given, the
971
+ assertion must answer it; absent, the assertion must carry no `InResponseTo`.
972
+
973
+ #### Errors
974
+
975
+ | Error | When |
976
+ |---|---|
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`) |
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 |
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 |
459
980
 
460
981
  ### With Stores
461
982
 
@@ -708,12 +1229,17 @@ import {
708
1229
  SessionDataError,
709
1230
  ServiceKeyError,
710
1231
  BrowserAuthError,
1232
+ AssertionValidationError,
711
1233
  } from '@mcp-abap-adt/auth-providers';
712
1234
 
713
1235
  try {
714
1236
  const result = await provider.getTokens();
715
1237
  } catch (error) {
716
- if (error instanceof ValidationError) {
1238
+ if (error instanceof AssertionValidationError) {
1239
+ // A SAML provider refused the assertion; `check` says which check failed
1240
+ console.error('Assertion refused at:', error.check); // e.g. 'audience'
1241
+ console.error('Error code:', error.code); // 'ASSERTION_VALIDATION_ERROR'
1242
+ } else if (error instanceof ValidationError) {
717
1243
  // provider config validation failed
718
1244
  console.error('Missing required fields:', error.missingFields);
719
1245
  console.error('Error code:', error.code); // 'VALIDATION_ERROR'
@@ -736,8 +1262,163 @@ try {
736
1262
  - `SessionDataError` - Session data invalid, includes `missingFields: string[]`
737
1263
  - `ServiceKeyError` - Service key data invalid, includes `missingFields: string[]`
738
1264
  - `BrowserAuthError` - Browser auth failed, includes `cause?: Error`
1265
+ - `AssertionValidationError` - a SAML assertion was refused, includes `check: AssertionCheck` naming the check that failed — see [SAML assertion validation](#errors)
1266
+
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`.
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
+
1316
+ ## Migrating from 3.x to 4.0
1317
+
1318
+ 4.0.0 changes nothing outside the two SAML providers. For those, it validates
1319
+ every assertion — see [SAML assertion validation](#saml-assertion-validation) —
1320
+ and a 3.x configuration no longer constructs:
739
1321
 
740
- All error codes are defined in `@mcp-abap-adt/interfaces-auth` package as `TOKEN_PROVIDER_ERROR_CODES`.
1322
+ ```
1323
+ The default assertion validator needs the identity provider it should trust:
1324
+ missing idpCertificates, idpEntityId. Supply these, or supply an
1325
+ assertionValidator of your own.
1326
+ ```
1327
+
1328
+ What to add, on `Saml2BearerProvider` and `Saml2PureProvider` alike:
1329
+
1330
+ - **Whom to trust: `idpCertificates` and `idpEntityId`, or an
1331
+ `assertionValidator`.** The certificates are the identity provider's signing
1332
+ certificates, PEM or the bare base64 of `<X509Certificate>` in its metadata;
1333
+ `idpEntityId` is the `Issuer` its assertions carry — its `entityID`. A
1334
+ shipped validator supplied as `assertionValidator` still needs
1335
+ `idpEntityId`; only a validator of your own does without.
1336
+ - **`spEntityId` must be your real entity ID.** It was already required, but in
1337
+ 3.x it only named the issuer of the AuthnRequest, and a login that never built
1338
+ one never used it. It is now the `Audience` every `AudienceRestriction` must
1339
+ name — for the bearer grant against UAA or XSUAA, the `entityID` in their SAML
1340
+ metadata.
1341
+ - **`idpInitiated: true` for `Saml2BearerProvider` against UAA or XSUAA**, whose
1342
+ saml2-bearer grant refuses an assertion carrying `InResponseTo`. With it, use
1343
+ a strategy that does not call `buildAuthorizationUrl` — `staticCodeStrategy`
1344
+ or your own. The 3.0 advice, `externalCodeStrategy` whose `provide` ignores
1345
+ the URL, now fails: with `idpInitiated: true` and no `authorizationUrl`, the
1346
+ builder refuses before producing a URL, since the only one it could build
1347
+ carries an AuthnRequest.
1348
+ - **`authnRequestId` when the package does not build the request**: with a
1349
+ pre-built `authorizationUrl`, or a strategy that returns a payload without
1350
+ calling `buildAuthorizationUrl` after a request you sent — unless the login is
1351
+ `idpInitiated`. Without either, the login fails before the assertion is read.
1352
+ - **The strategy's `redirectUri` must be the ACS the assertion names** in
1353
+ `SubjectConfirmationData/@Recipient` — for the bearer grant, the token
1354
+ endpoint's bearer ACS. `staticCodeStrategy` defaults it to
1355
+ `http://localhost:61001/callback`, which such an assertion does not name.
1356
+
1357
+ For the bearer grant against UAA or XSUAA, a 3.x configuration becomes:
1358
+
1359
+ ```typescript
1360
+ // 3.x
1361
+ new Saml2BearerProvider({
1362
+ idpSsoUrl, spEntityId, uaaUrl, clientId, clientSecret,
1363
+ authorization: externalCodeStrategy({ provide: async () => fetchAssertion() }),
1364
+ });
1365
+
1366
+ // 4.0
1367
+ new Saml2BearerProvider({
1368
+ idpSsoUrl, uaaUrl, clientId, clientSecret,
1369
+ spEntityId: uaaEntityId, // the entityID in UAA's SAML metadata
1370
+ acsUrl: uaaBearerAcs, // its bearer ACS: the Recipient
1371
+ idpCertificates: [idpSigningCertPem],
1372
+ idpEntityId: 'https://idp.example.com/metadata',
1373
+ idpInitiated: true,
1374
+ authorization: {
1375
+ // Never calls buildAuthorizationUrl; a fresh assertion per login.
1376
+ authorize: async () => ({ payload: await fetchAssertion(), redirectUri: uaaBearerAcs }),
1377
+ },
1378
+ });
1379
+ ```
1380
+
1381
+ **What now fails that used to pass:**
1382
+
1383
+ - an unsigned assertion, one signed with a key not in `idpCertificates`, or one
1384
+ altered after signing (`signature`);
1385
+ - under `Saml2PureProvider`'s default, a response whose `Response` is not
1386
+ signed — an identity provider that signs only assertions. Select
1387
+ `createSignedAssertionValidator` for it (`signedNode`);
1388
+ - under `Saml2PureProvider`'s default, a response whose `Status` is absent or
1389
+ not `Success` (`status`), or whose `Destination` is absent or not the ACS it
1390
+ arrived at (`destination`). `Saml2BearerProvider`'s default,
1391
+ `createSignedAssertionValidator`, does not read `Status`, so a bearer
1392
+ consumer sees no change there — a declining identity provider mints no
1393
+ signed assertion, and the login is refused for want of one;
1394
+ - an assertion from another issuer (`issuer`), for another audience
1395
+ (`audience`), expired or not yet valid (`notOnOrAfter`, `notBefore`,
1396
+ `bearerConfirmation`), or whose bearer confirmation names another ACS as
1397
+ `Recipient`, or none (`bearerConfirmation`);
1398
+ - an `InResponseTo` that does not answer the request sent, one present on a
1399
+ login declared `idpInitiated`, or one missing from a login that sent a
1400
+ request (`bearerConfirmation`);
1401
+ - the same assertion presented twice while it is still valid (`replay`) — for
1402
+ instance a `staticCodeStrategy` payload reused by a second login;
1403
+ - a document carrying a second assertion, or an `EncryptedAssertion`, outside
1404
+ the signed one (`signedNode`), or a duplicated `ID` (`duplicateId`).
1405
+
1406
+ **Also changed:**
1407
+
1408
+ - `Saml2PureProvider`'s `expiresAt` comes from the validated assertion — the
1409
+ earlier of the `Conditions` and bearer-confirmation windows — not from the
1410
+ first `NotOnOrAfter` a regular expression found. It can be earlier than
1411
+ under 3.x.
1412
+ - `parseSamlNotOnOrAfter` is removed; `buildSamlAuthorizationUrl` returns
1413
+ `{ url, requestId? }` instead of a string, and `getSamlAssertion` a
1414
+ `SamlAssertionResult` instead of the payload string. None was exported from
1415
+ the package root; only a deep import of `dist/auth/saml2Auth` or
1416
+ `dist/providers/saml2Utils` is affected.
1417
+ - `@mcp-abap-adt/interfaces-auth` is `^2.0.0`, where
1418
+ `AssertionContext.expectedInResponseTo` is optional. That matters only to an
1419
+ implementer of `IAssertionValidator`, which must refuse an assertion carrying
1420
+ `InResponseTo` when it is absent.
1421
+ - `xml-crypto` is a new runtime dependency, for signature verification.
741
1422
 
742
1423
  ## Migrating from 2.x to 3.0
743
1424
 
@@ -880,6 +1561,13 @@ The package includes both unit tests (with mocks) and integration tests (with re
880
1561
  npm test
881
1562
  ```
882
1563
 
1564
+ `npm test` also runs both shipped assertion validators end to end, through
1565
+ `Saml2PureProvider` and a real callback, against responses produced by a
1566
+ separately published mock identity provider, `@mcp-abap-adt/auth-mocks`. Every
1567
+ corruption variant it ships is refused at the check it targets — except
1568
+ `statusFailure` and `wrongDestination`, which the assertion-only validator,
1569
+ reading neither field, accepts; both halves are asserted.
1570
+
883
1571
  ### Integration Tests
884
1572
 
885
1573
  Integration tests work with real files from `tests/test-config.yaml`:
@@ -949,9 +1637,9 @@ it.
949
1637
 
950
1638
  | provider | server | what the suite proves |
951
1639
  |---|---|---|
952
- | `Saml2BearerProvider` | UAA | a bearer assertion — and a whole `SAMLResponse` — is exchanged for a token; UAA issues a refresh token exactly when the client may hold one, and the provider refreshes without its authorization strategy |
953
- | `Saml2BearerProvider` | Keycloak → UAA | end to end with no assertion built by the tests: an IdP-initiated Keycloak login becomes a UAA token; the answer to the provider's own AuthnRequest is refused for its `InResponseTo` |
954
- | `Saml2PureProvider` | Keycloak | the identity-provider half: Keycloak accepts the provider's AuthnRequest and posts a signed response for that service provider to the ACS it named, which reaches `cookieProvider` unchanged |
1640
+ | `Saml2BearerProvider` | UAA | a bearer assertion — and a whole `SAMLResponse` — passes the default validator, declared `idpInitiated`, and is exchanged for a token; UAA issues a refresh token exactly when the client may hold one, and the provider refreshes without its authorization strategy |
1641
+ | `Saml2BearerProvider` | Keycloak → UAA | end to end with no assertion built by the tests: an IdP-initiated Keycloak login passes validation and becomes a UAA token; the answer to the provider's own AuthnRequest passes validation against the ID it minted, and UAA refuses it for its `InResponseTo` |
1642
+ | `Saml2PureProvider` | Keycloak | the identity-provider half: Keycloak accepts the provider's AuthnRequest and posts a response, signed at both levels, to the ACS it named; the default signed-Response validator accepts it against the ID the provider minted, and it reaches `cookieProvider` unchanged |
955
1643
  | `ClientCredentialsProvider` | UAA | a client token |
956
1644
  | `UaaPasscodeProvider` | UAA | a code fetched from `/passcode` after logging in there, exchanged for tokens; a refresh that does not ask for another code; a spent code refused |
957
1645
  | `AuthorizationCodeProvider` | UAA | a login through UAA's own form, and a refresh without logging in again |
@@ -1021,13 +1709,20 @@ not-found message counts as absence.
1021
1709
  `XSUAA_KEEP=1` keeps the environment for another run. A full run takes about a minute and
1022
1710
  a half. It is not part of CI.
1023
1711
 
1712
+ Results of the 4.0 suite on a BTP trial subaccount, 2026-09-25 — 4 passed,
1713
+ 1 skipped. Every SAML login is validated first, by `Saml2BearerProvider`'s default
1714
+ assertion-only validator, against the per-run test identity provider's
1715
+ certificate, declared `idpInitiated`:
1716
+
1024
1717
  | check | result on XSUAA |
1025
1718
  |---|---|
1026
- | `Saml2BearerProvider`, assertion without `InResponseTo` (IdP-initiated) | token and refresh token |
1719
+ | `Saml2BearerProvider`, assertion without `InResponseTo` (IdP-initiated) | passes validation; token and refresh token |
1027
1720
  | `Saml2BearerProvider`, a whole `SAMLResponse` | converted by the provider, accepted |
1028
1721
  | `Saml2BearerProvider`, refresh | never reaches the strategy |
1029
- | `Saml2BearerProvider`, assertion with `InResponseTo` | refused — as UAA does |
1030
- | `UaaPasscodeProvider` (with `XSUAA_PASSCODE=<code from /passcode>`) | token, refresh |
1722
+ | `Saml2BearerProvider`, assertion with `InResponseTo` | refused locally at `bearerConfirmation`, before any request reaches XSUAA |
1723
+ | `UaaPasscodeProvider` (with `XSUAA_PASSCODE=<code from /passcode>`) | skipped — no `XSUAA_PASSCODE` was set |
1724
+
1725
+ Teardown removed everything setup had created.
1031
1726
 
1032
1727
  `UaaPasscodeProvider` was also checked by hand with an ABAP environment's own
1033
1728
  service key: its client accepts the passcode, and the token opens ADT. That
@@ -1080,10 +1775,11 @@ Example output:
1080
1775
 
1081
1776
  ## Dependencies
1082
1777
 
1083
- - `@mcp-abap-adt/interfaces-auth` (^1.2.0) - Token provider and authorization contracts (`ITokenProvider`, `IAuthorizationStrategy`, `CallbackServerFactory`) and error code constants
1084
- - `@mcp-abap-adt/interfaces-auth-sap` (^1.0.0) - XSUAA authorization configuration (`IAuthorizationConfig`)
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
1779
+ - `@mcp-abap-adt/interfaces-auth-sap` (^1.0.1) - XSUAA authorization configuration (`IAuthorizationConfig`)
1085
1780
  - `@mcp-abap-adt/interfaces-utils` (^1.1.0) - `ILogger`
1086
- - `@xmldom/xmldom` - XML parsing, to take the Assertion out of a SAMLResponse for the saml2-bearer grant
1781
+ - `@xmldom/xmldom` - XML parsing: SAML assertion validation, and taking the Assertion out of a SAMLResponse for the saml2-bearer grant
1782
+ - `xml-crypto` - XML-DSig signature verification for SAML assertion validation
1087
1783
  - `axios` - HTTP client
1088
1784
  - `express` - OAuth2 callback server
1089
1785
  - `open` - Browser opening utility