@mcp-abap-adt/auth-providers 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +620 -77
  3. package/dist/auth/saml2Auth.d.ts +6 -2
  4. package/dist/auth/saml2Auth.d.ts.map +1 -1
  5. package/dist/auth/saml2Auth.js +9 -20
  6. package/dist/auth/samlBearerAssertion.d.ts.map +1 -1
  7. package/dist/auth/samlBearerAssertion.js +2 -1
  8. package/dist/auth/strictXml.d.ts +13 -0
  9. package/dist/auth/strictXml.d.ts.map +1 -0
  10. package/dist/auth/strictXml.js +21 -0
  11. package/dist/errors/AssertionValidationError.d.ts +15 -0
  12. package/dist/errors/AssertionValidationError.d.ts.map +1 -0
  13. package/dist/errors/AssertionValidationError.js +24 -0
  14. package/dist/errors/TokenProviderErrors.d.ts +2 -0
  15. package/dist/errors/TokenProviderErrors.d.ts.map +1 -1
  16. package/dist/errors/TokenProviderErrors.js +3 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +11 -1
  20. package/dist/providers/Saml2BearerProvider.d.ts +1 -0
  21. package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
  22. package/dist/providers/Saml2BearerProvider.js +17 -2
  23. package/dist/providers/Saml2PureProvider.d.ts +1 -0
  24. package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
  25. package/dist/providers/Saml2PureProvider.js +19 -5
  26. package/dist/providers/saml2Utils.d.ts +49 -2
  27. package/dist/providers/saml2Utils.d.ts.map +1 -1
  28. package/dist/providers/saml2Utils.js +94 -2
  29. package/dist/validation/assertionValidator.d.ts +28 -0
  30. package/dist/validation/assertionValidator.d.ts.map +1 -0
  31. package/dist/validation/assertionValidator.js +444 -0
  32. package/dist/validation/documentIds.d.ts +15 -0
  33. package/dist/validation/documentIds.d.ts.map +1 -0
  34. package/dist/validation/documentIds.js +32 -0
  35. package/dist/validation/inMemoryReplayStore.d.ts +22 -0
  36. package/dist/validation/inMemoryReplayStore.d.ts.map +1 -0
  37. package/dist/validation/inMemoryReplayStore.js +49 -0
  38. package/dist/validation/signedNode.d.ts +54 -0
  39. package/dist/validation/signedNode.d.ts.map +1 -0
  40. package/dist/validation/signedNode.js +171 -0
  41. package/dist/validation/xsdDateTime.d.ts +17 -0
  42. package/dist/validation/xsdDateTime.d.ts.map +1 -0
  43. package/dist/validation/xsdDateTime.js +67 -0
  44. package/package.json +5 -4
package/README.md CHANGED
@@ -35,9 +35,18 @@ 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 major, see
47
+ [Migrating from 3.x to 4.0](#migrating-from-3x-to-40),
48
+ [Migrating from 2.x to 3.0](#migrating-from-2x-to-30) and
49
+ [Migrating from 1.x to 2.0](#migrating-from-1x-to-20).
41
50
 
42
51
  ## Responsibilities and Design Principles
43
52
 
@@ -65,6 +74,7 @@ This package is responsible for:
65
74
  2. **Token acquisition**: Handles OAuth2 flows (browser-based, refresh token, client credentials) to obtain JWT tokens
66
75
  3. **Token validation**: Validates JWT locally by checking exp claim (no HTTP requests)
67
76
  4. **OAuth2 flows**: Manages browser-based OAuth2 authorization code flow and refresh token flow
77
+ 5. **SAML assertion validation**: Verifies a SAML assertion — signature, issuer, audience, recipient, time window, request ID, replay — before either SAML provider uses it
68
78
 
69
79
  #### What This Package Does
70
80
 
@@ -73,6 +83,7 @@ This package is responsible for:
73
83
  - **Obtains tokens**: Makes HTTP requests to UAA endpoints to obtain JWT tokens
74
84
  - **Validates tokens**: Validates JWT locally by checking exp claim (no HTTP requests)
75
85
  - **Returns tokens**: Returns `ITokenResult` with `authorizationToken` and optional `refreshToken`
86
+ - **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
87
 
77
88
  #### What This Package Does NOT Do
78
89
 
@@ -81,6 +92,7 @@ This package is responsible for:
81
92
  - **Does NOT know about service keys**: Service key loading is handled by stores
82
93
  - **Does NOT manage sessions**: Session management is handled by stores
83
94
  - **Does NOT return `serviceUrl` if unknown**: Providers may not return `serviceUrl` because they only handle token acquisition, not connection configuration
95
+ - **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
96
 
85
97
  ### External Dependencies
86
98
 
@@ -328,88 +340,91 @@ The redirect URI is no longer a provider field: it belongs to the strategy,
328
340
  because with an ephemeral port nothing knows it until the socket is bound. The
329
341
  one the strategy reports is the one sent to the token endpoint.
330
342
 
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
- ```
343
+ Both SAML providers validate every assertion before using it, so every SAML
344
+ example below says whom to trust: `idpCertificates` and `idpEntityId`. See
345
+ [SAML assertion validation](#saml-assertion-validation) for what is checked and
346
+ what else can be configured.
355
347
 
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):
348
+ SAML bearer example (UAA or XSUAA — an IdP-initiated assertion):
365
349
 
366
350
  ```typescript
351
+ import { readFileSync } from 'node:fs';
367
352
  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';
353
+ import type { IAuthorizationStrategy } from '@mcp-abap-adt/interfaces-auth';
354
+ import { Saml2BearerProvider } from '@mcp-abap-adt/auth-providers';
355
+
356
+ // The Recipient the assertion names: the URI-binding assertion consumer
357
+ // service in the token endpoint's SAML metadata (UAA's is /oauth/token/alias/…).
358
+ const acsUrl = 'https://uaa.example.com/oauth/token/alias/uaa.example';
359
+
360
+ // An IdP-initiated login answers no AuthnRequest, so this strategy never calls
361
+ // request.buildAuthorizationUrl — with idpInitiated: true and no
362
+ // authorizationUrl, the builder refuses before producing a URL, since the only
363
+ // one it could build carries an AuthnRequest. It fetches a fresh assertion on every login;
364
+ // the same assertion presented twice is refused as a replay.
365
+ const fromSsoProxy: IAuthorizationStrategy<string> = {
366
+ async authorize() {
367
+ return { payload: await getSamlResponseFromSsoProxy(), redirectUri: acsUrl };
368
+ },
369
+ };
374
370
 
375
371
  const provider = new Saml2BearerProvider({
376
372
  idpSsoUrl: 'https://idp.example.com/sso',
377
- spEntityId: 'my-sp-entity',
373
+ spEntityId: 'uaa.example', // the entityID in that metadata: the Audience
378
374
  acsUrl,
379
375
  uaaUrl: 'https://uaa.example.com',
380
376
  clientId: '...',
381
377
  clientSecret: '...',
382
- authorization: externalCodeStrategy({
383
- redirectUri: acsUrl,
384
- provide: async (_authorizationUrl) => getSamlResponseFromSsoProxy(),
385
- }),
378
+ // Whom to trust: the identity provider's signing certificate and entity ID.
379
+ idpCertificates: [readFileSync('idp-signing.pem', 'utf8')],
380
+ idpEntityId: 'https://idp.example.com/metadata',
381
+ // UAA and XSUAA refuse an assertion carrying InResponseTo.
382
+ idpInitiated: true,
383
+ authorization: fromSsoProxy,
386
384
  });
387
385
 
388
386
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
389
387
  ```
390
388
 
391
389
  **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.
390
+ `AuthnRequest` — which is what the provider's own URL carries, and so what every
391
+ shipped strategy that opens or shows that URL sends — puts `InResponseTo` on
392
+ the assertion's subject confirmation. The saml2-bearer grant of Cloud Foundry UAA and of SAP
393
+ XSUAA refuses any assertion that carries it: there is no request on their side
394
+ to match it against, and UAA's `disableInResponseToCheck` applies to web SSO
395
+ only. Measured: UAA, with Keycloak as the identity provider, refuses the answer
396
+ to an SP-initiated login with *"SubjectConfirmationData/@InResponseTo … did not
397
+ match the valid value: null"*, and XSUAA — measured with 3.x, which sent such an
398
+ assertion on — refuses one carrying `InResponseTo` with *"No subject
399
+ confirmation methods were met"*; both accept an IdP-initiated one — started at
400
+ the IdP, answering no request. (4.0 refuses that case itself, at
401
+ `bearerConfirmation`, before XSUAA sees it.) Against either,
402
+ supply an IdP-initiated assertion, declare `idpInitiated: true`, and use a
403
+ strategy that does not call
404
+ `buildAuthorizationUrl`: `staticCodeStrategy`, or your own as above.
405
+ `samlCallbackStrategy`, `manualSamlResponseStrategy` and `externalCodeStrategy`
406
+ all call it, and with `idpInitiated: true` and no `authorizationUrl` the builder
407
+ refuses: a `ValidationError` (`missingFields: ['authorizationUrl']`) thrown
408
+ before any URL is produced, so before a browser opens. (3.0's advice —
409
+ `externalCodeStrategy` whose `provide` ignores the URL — no longer works for
410
+ that reason.) See
411
+ [Where the expected request ID comes from](#where-the-expected-request-id-comes-from).
412
+
413
+ The `redirectUri` your strategy reports is the ACS the assertion is checked
414
+ against — its `SubjectConfirmationData/@Recipient` must equal it — so for the
415
+ bearer grant it is the token endpoint's bearer ACS, not a local callback.
403
416
 
404
417
  **What is sent.** The saml2-bearer grant takes one SAML Assertion,
405
418
  base64url-encoded (RFC 7522 §2.1). A strategy may deliver either that or the
406
419
  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.
420
+ `Saml2BearerProvider` validates what it received, then takes the Assertion out
421
+ of a Response and re-encodes it, copying onto it every namespace declaration it
422
+ inherited — including one used only inside a value such as
423
+ `xsi:type="xs:string"`. The Assertion must carry its own signature: one over the
424
+ Response alone does not survive the cut, and the token endpoint refuses the
425
+ Assertion — which is why this provider's default validator is the one that
426
+ requires the Assertion to be signed. An `EncryptedAssertion` is refused before
427
+ anything is sent.
413
428
 
414
429
  **Refresh.** When the token endpoint returns a `refresh_token` with the SAML
415
430
  bearer exchange, `Saml2BearerProvider` spends it once the access token expires:
@@ -419,9 +434,10 @@ browser involved. Pass a stored one back as `refreshToken` in the config and the
419
434
  next `getTokens()` uses it. If the grant is refused, or no refresh token was
420
435
  ever issued, the provider falls back to a full login through `authorization`.
421
436
 
422
- Pure SAML example (cookie-based):
437
+ Pure SAML example (cookie-based, SP-initiated):
423
438
 
424
439
  ```typescript
440
+ import { readFileSync } from 'node:fs';
425
441
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
426
442
  import {
427
443
  Saml2PureProvider,
@@ -434,6 +450,10 @@ const provider = new Saml2PureProvider({
434
450
  idpSsoUrl: 'https://idp.example.com/sso',
435
451
  spEntityId: 'my-sp-entity',
436
452
  acsUrl,
453
+ idpCertificates: [readFileSync('idp-signing.pem', 'utf8')],
454
+ idpEntityId: 'https://idp.example.com/metadata',
455
+ // Shows the URL the provider builds — so the response must answer that
456
+ // request's ID — and reads the pasted SAMLResponse.
437
457
  authorization: manualSamlResponseStrategy({ redirectUri: acsUrl, read: promptUser }),
438
458
  // Convert SAMLResponse to session cookies for SAP (implementation-specific)
439
459
  cookieProvider: async (samlResponse) => {
@@ -444,6 +464,17 @@ const provider = new Saml2PureProvider({
444
464
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
445
465
  ```
446
466
 
467
+ `cookieProvider` receives the payload unchanged, only after it has been
468
+ validated, and the session's `expiresAt` is the validated assertion's expiry.
469
+
470
+ **Read that `redirectUri` twice.** A SAML strategy defaults its redirect URI to
471
+ `http://localhost:61001/callback`, and the provider requires the assertion
472
+ consumer service the IdP posts to be exactly the one the strategy names. If you
473
+ declare a real `acsUrl` and leave `redirectUri` off, the login fails with
474
+ *"SAML acsUrl is … but the authorization strategy is listening on …"* before
475
+ anything is opened. Declare neither and the default is used for both, which is
476
+ consistent — and only reachable when the IdP will post to your localhost.
477
+
447
478
  Both SAML providers now reject at construction when `authorizationUrl` is set
448
479
  without `acsUrl`:
449
480
 
@@ -455,7 +486,391 @@ SAML request cannot be read, so it must be declared.
455
486
  The ACS is buried in a deflated `SAMLRequest` this package did not build and
456
487
  cannot read, so it cannot be verified against whatever the strategy binds. 1.x
457
488
  accepted the combination and defaulted the ACS to
458
- `http://localhost:3001/callback` — usually not where the IdP posted.
489
+ `http://localhost:3001/callback` — usually not where the IdP posted. The same
490
+ holds for the request ID: this package cannot read it out of a URL it did not
491
+ build, so a pre-built `authorizationUrl` also needs `authnRequestId` — unless
492
+ the login is declared `idpInitiated`.
493
+
494
+ ### SAML assertion validation
495
+
496
+ Since 4.0.0, `Saml2BearerProvider` and `Saml2PureProvider` validate the
497
+ assertion a login delivers before anything else happens to it — before the
498
+ token exchange, before `cookieProvider`. Until 3.x nothing verified it: the
499
+ callback checked only that the payload was non-empty, and `Saml2PureProvider`
500
+ took its session lifetime from a regular expression over the unverified XML.
501
+
502
+ **This is a breaking change.** Each provider constructs its validator in its
503
+ constructor, and a configuration that does not say whom to trust fails there —
504
+ a `ValidationError` whose `missingFields` names what is missing — before any
505
+ browser opens or any request is sent. Supply `idpCertificates` and
506
+ `idpEntityId`, or an `assertionValidator` of your own.
507
+
508
+ #### Configuration
509
+
510
+ On both providers' configuration (`Saml2BearerProviderConfig`, `Saml2PureProviderConfig`):
511
+
512
+ | Field | Default | Meaning |
513
+ |---|---|---|
514
+ | `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 |
515
+ | `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 |
516
+ | `spEntityId` | — | Your entity ID. The assertion's `AudienceRestriction` must name it — whichever validator is in play |
517
+ | `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 |
518
+ | `assertionReplayStore` | the process-wide in-memory store | An `IAssertionReplayStore` for the default validator — see [Replay](#replay) |
519
+ | `clockSkewMs` | `0` | Tolerance for the default validator's time checks — see [Clock skew](#clock-skew) |
520
+ | `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) |
521
+ | `idpInitiated` | `false` | Declares that no AuthnRequest was sent, so the assertion must carry no `InResponseTo`. Required for `Saml2BearerProvider` against UAA or XSUAA |
522
+
523
+ The package performs no I/O for any of these: it fetches no metadata and reads
524
+ no file. Reading the certificate is the consumer's job.
525
+
526
+ #### Choosing a validator
527
+
528
+ This is the first decision to make, and **the default differs by provider**:
529
+
530
+ | | `createSignedResponseValidator` | `createSignedAssertionValidator` |
531
+ |---|---|---|
532
+ | The signature must cover | the `Response` | the `Assertion` — bare, or inside a Response |
533
+ | Default of | `Saml2PureProvider` | `Saml2BearerProvider` |
534
+ | Reads `Status`, `Response/Issuer`, `Destination` | yes — inside the signature | **not at all** |
535
+ | Accepts a bare `saml:Assertion` | no | yes |
536
+ | Checks performed | all twelve below | all but rows 4, 5b and 11 |
537
+
538
+ Both take the same options (`ShippedValidatorOptions`) and return the same
539
+ interface, so switching is one identifier:
540
+
541
+ ```typescript
542
+ import { readFileSync } from 'node:fs';
543
+ import {
544
+ Saml2PureProvider,
545
+ createSignedAssertionValidator,
546
+ } from '@mcp-abap-adt/auth-providers';
547
+
548
+ const idpCertificates = [readFileSync('idp-signing.pem', 'utf8')];
549
+
550
+ const provider = new Saml2PureProvider({
551
+ idpSsoUrl: 'https://idp.example.com/sso',
552
+ spEntityId: 'my-sp-entity',
553
+ acsUrl: 'https://sp.example.com/saml/acs',
554
+ // Still required with a shipped validator, which refuses every assertion
555
+ // without an expected issuer; construction fails without it.
556
+ idpEntityId: 'https://idp.example.com/metadata',
557
+ // Our identity provider signs only its assertions.
558
+ assertionValidator: createSignedAssertionValidator({
559
+ idpCertificates,
560
+ clockSkewMs: 30_000,
561
+ }),
562
+ cookieProvider: exchangeSamlForCookies,
563
+ });
564
+ ```
565
+
566
+ **`createSignedResponseValidator`** requires the identity provider to sign the
567
+ `Response`. Every field all twelve checks read is then inside the signature, so
568
+ every check is a control. It is `Saml2PureProvider`'s default because there the
569
+ whole response is handed on to `cookieProvider`, and `Status` and `Destination`
570
+ must be inside a signature.
571
+
572
+ **`createSignedAssertionValidator`** accepts a signature over the `Assertion`,
573
+ and **does not read** `Status`, `Response/Issuer` or `Destination` at all — not
574
+ weakly: with an assertion-only signature those fields sit outside it, where
575
+ anyone able to deliver a response sets them to whatever is expected, and a check
576
+ on a field an attacker controls reads in the code and the logs as if something
577
+ had been verified. It is `Saml2BearerProvider`'s default because the token
578
+ endpoint receives the Assertion alone, taken out of any Response, so the
579
+ Assertion's own signature is what counts there. Configuring the signed-Response
580
+ validator on the bearer path would refuse a bare Assertion, and accept responses
581
+ signed only at the Response level, which the token endpoint then refuses.
582
+
583
+ **Who needs the second one.** Identity providers that sign only assertions —
584
+ which is many. A `Saml2PureProvider` consumer whose IdP does so gets a
585
+ `signedNode` refusal from the default, and selects
586
+ `createSignedAssertionValidator` explicitly. What that gives up is the three
587
+ checks above. It is still sound:
588
+
589
+ - **`Status`** — a declined login carries no assertion. An identity provider
590
+ that refuses does not mint one, so flipping `Status` to `Success` leaves an
591
+ attacker with nothing signed to put beneath it. Success is established by a
592
+ signed assertion passing every assertion-level check.
593
+ - **`Destination`** — addressing rests on
594
+ `SubjectConfirmationData/@Recipient`, which is inside the signed assertion and
595
+ required by check 10.
596
+ - **`Response/Issuer`** — the assertion's own `Issuer`, inside the signature, is
597
+ checked against `idpEntityId`.
598
+
599
+ An identity provider that signs both the Response and the Assertion — Keycloak
600
+ does by default — satisfies either validator.
601
+
602
+ #### What the validators check
603
+
604
+ In this order; each refusal is an `AssertionValidationError` whose `check`
605
+ names the row. Rows marked *(signed-Response only)* are not performed by
606
+ `createSignedAssertionValidator`.
607
+
608
+ | # | Check | Refused when | `check` |
609
+ |---|---|---|---|
610
+ | 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
+ | 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` |
615
+ | 4b | `Assertion/@ID` | absent or empty | `assertionId` |
616
+ | 5 | `Assertion/Issuer` | absent, not the expected issuer, or no expected issuer was given | `issuer` |
617
+ | 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
+ | 7 | `Conditions/@NotBefore` *(optional)* | present and not a valid `xsd:dateTime`, or in the future beyond `clockSkewMs` — absent is accepted | `notBefore` |
620
+ | 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` |
623
+ | 11 | `Response/@Destination` *(signed-Response only)* | absent, or not the ACS the response arrived at | `destination` |
624
+ | 12 | Replay | the store has already recorded this `{issuer, ID}` | `replay` |
625
+
626
+ What the table compresses:
627
+
628
+ - **Every required field is refused when absent**, not skipped: a rule
629
+ phrased "present and not X" is one an attacker satisfies by deleting the
630
+ field. That covers `Status` and `Destination` (signed-Response only),
631
+ `Assertion/@ID`, `Assertion/Issuer`, `Conditions`, `Conditions/@NotOnOrAfter`,
632
+ the `AudienceRestriction`, and, in the bearer confirmation, `Recipient`,
633
+ `NotOnOrAfter` and — when a request ID is expected — `InResponseTo`. Of the
634
+ fields the validators read, only these may be missing, each for a reason:
635
+ - `Conditions/@NotBefore` and `SubjectConfirmationData/@NotBefore` — a
636
+ missing `NotBefore` only means "valid from issue"; when present it is
637
+ checked;
638
+ - `Response/Issuer` — optional in SAML Core, and the assertion's own `Issuer`
639
+ is checked inside the signature; when present it must agree (5b);
640
+ - `NameID` — surfaced on the result, not trusted for anything.
641
+ - **The signature must cover the element that is read.** A wrapping attack
642
+ supplies a document holding a genuinely signed fragment beside a forged one;
643
+ the validator resolves which element each signature covers and reads the
644
+ assertion's fields from that element only. And since a payload travels on
645
+ 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.
650
+ - **Several signatures are accepted** when every one verifies against
651
+ `idpCertificates`, carries exactly one same-document reference, and sits
652
+ directly inside the element it references. A signature that fails refuses the
653
+ whole document, even when another covers the element read. A document with no
654
+ signature is refused.
655
+ - **Unique IDs (1b)** are the wrapping defence again: XML-DSig resolves its
656
+ reference by `ID`, so a duplicate makes "which element is signed" ambiguous.
657
+ 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.
666
+ - **Check 9 is AND across restrictions, OR within one**, as SAML Core §2.5.1.4
667
+ says: every `AudienceRestriction` must name you; the `Audience` elements
668
+ inside one are alternatives.
669
+ - **Dates are parsed strictly.** An `xsd:dateTime` must have real calendar
670
+ components — `2026-02-30T00:00:00Z`, which `Date.parse` quietly turns into
671
+ 2 March, is refused.
672
+ - **No DTD.** A `<!DOCTYPE` anywhere in the payload is refused at `document`
673
+ before it is parsed: a SAML message has no use for one, and the document is
674
+ parsed twice — by `@xmldom/xmldom` 0.9 here and by the 0.8 inside
675
+ `xml-crypto` — where a DTD is exactly what parsers disagree about.
676
+ - **Any XML fault is a refusal, and nothing reaches the console.** The parser
677
+ is given an error handler that throws on every level, so a payload it would
678
+ have repaired — an undeclared entity, say — is refused at `document` rather
679
+ than validated in its repaired form, and a malformed callback never writes
680
+ to stderr past your `ILogger`. The same holds for the bearer conversion.
681
+ - **SHA-1 is accepted.** RSA-SHA1 signatures and SHA-1 digests verify, as they
682
+ do under `xml-crypto`'s defaults, because identity providers still emit them
683
+ and refusing them would refuse genuine logins. To refuse them, supply an
684
+ `assertionValidator` of your own that rejects a `SignatureMethod` or
685
+ `DigestMethod` naming `…xmldsig#rsa-sha1` or `…xmldsig#sha1` before
686
+ delegating to a shipped validator — and keep `idpEntityId` configured, since
687
+ the shipped validator inside still refuses without an expected issuer.
688
+
689
+ **Expiry comes from the verified document.** A validated assertion's
690
+ `expiresAt` is the earlier of `Conditions/@NotOnOrAfter` and the `NotOnOrAfter`
691
+ of the bearer confirmation accepted — the earliest, if several qualify — so a
692
+ session cannot outlive a window the assertion itself closed.
693
+ `Saml2PureProvider` takes its session's `expiresAt` from it.
694
+ `parseSamlNotOnOrAfter`, the regular expression over unverified XML it replaces,
695
+ is gone.
696
+
697
+ **What remains unproven.** The validators verify signatures with `xml-crypto`.
698
+ They are tested against signatures `xml-crypto` itself produced (through
699
+ `@mcp-abap-adt/auth-mocks`) and against Keycloak, a real identity provider, on
700
+ the provider stand. Whether every other identity provider's canonicalisation
701
+ matches is not proven; a refusal at `signature` from a genuine response is the
702
+ symptom to report.
703
+
704
+ #### Where the expected request ID comes from
705
+
706
+ `InResponseTo` must answer the request that was sent — or, where none was sent
707
+ by explicit choice, be absent. The expected ID is decided before validation,
708
+ from one of three sources, and never inferred from the assertion:
709
+
710
+ | Source | When | `InResponseTo` must be |
711
+ |---|---|---|
712
+ | minted | the strategy called `buildAuthorizationUrl`, and the package built the AuthnRequest — `samlCallbackStrategy`, `manualSamlResponseStrategy`, `externalCodeStrategy`, or the default | equal to the ID the package minted |
713
+ | declared | `authnRequestId` is configured | equal to `authnRequestId` |
714
+ | none, by declaration | `idpInitiated: true`, and no request was sent | **absent** |
715
+
716
+ `authnRequestId` is **required** whenever the package did not build the request
717
+ and the login is not declared IdP-initiated. Two flows trigger it:
718
+
719
+ - a pre-built `authorizationUrl` — the package cannot read the ID out of a
720
+ request it did not build;
721
+ - a strategy that returns a payload without calling `buildAuthorizationUrl` —
722
+ `staticCodeStrategy`, or your own — after a request you sent some other way.
723
+
724
+ Without it, the login fails with a `ValidationError` (`missingFields:
725
+ ['authnRequestId']`) after the strategy returns and before the assertion is
726
+ read — as a configuration fault, not a refusal blamed on the assertion. A
727
+ strategy that merely forgot to call the builder must not silently switch the
728
+ provider into accepting unsolicited responses.
729
+
730
+ **`idpInitiated: true`** declares that the identity provider started the login
731
+ and no AuthnRequest exists, so the assertion must carry no `InResponseTo`.
732
+ `Saml2BearerProvider` against UAA or XSUAA needs it: both refuse an assertion
733
+ carrying `InResponseTo` on the saml2-bearer grant. What it gives up is the
734
+ **login-CSRF defence** of a request ID: with one, a response must answer the
735
+ request just sent; without it, whoever can deliver a validly signed response
736
+ of their own to your receiver can log your user in as themselves. That is
737
+ sometimes the right trade — for UAA and XSUAA it is the only one — but it must
738
+ be a decision visible in your configuration. **It is never inferred**: an
739
+ assertion without `InResponseTo` does not make a login IdP-initiated; only
740
+ `idpInitiated: true` does. The other checks apply unchanged.
741
+
742
+ `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
746
+ `authorizationUrl` configured is refused inside the builder, before a URL — and
747
+ so a request ID — exists: a `ValidationError` with `missingFields:
748
+ ['authorizationUrl']`. Use a strategy that does not call the builder, and leave
749
+ `authnRequestId` unset; or configure the identity provider's IdP-initiated SSO
750
+ URL as `authorizationUrl`, which the builder hands over without minting
751
+ anything.
752
+
753
+ #### Replay
754
+
755
+ The default replay store is **process-wide**: one module-level in-memory store,
756
+ `defaultReplayStore`, shared by every default validator in the process — both
757
+ providers, every instance. An assertion accepted once is refused as a replay
758
+ (`check: 'replay'`) for as long as it could still be accepted, however many
759
+ providers are constructed; a store per provider would let a second provider
760
+ accept what the first had seen. It is keyed by `{issuer, assertionId}`, since an
761
+ ID is unique only within the identity provider that minted it. Only an assertion
762
+ that passed every other check is recorded.
763
+
764
+ What it does **not** protect: anything across processes. A second process, a
765
+ restart, or a horizontally scaled deployment each start with an empty memory.
766
+ For those, supply a shared store — `assertionReplayStore` on the provider, or
767
+ `replayStore` on a shipped validator's options. Its `recordIfUnseen` must be
768
+ atomic — a single conditional write, never a read followed by a write — because
769
+ that race is exactly the one a replay exploits:
770
+
771
+ ```typescript
772
+ import type { IAssertionReplayStore } from '@mcp-abap-adt/interfaces-auth';
773
+
774
+ const sharedReplayStore: IAssertionReplayStore = {
775
+ async recordIfUnseen({ issuer, assertionId }, retainUntil) {
776
+ // e.g. Redis `SET key 1 NX PXAT <ms>`: true only when newly written.
777
+ return setIfAbsent(
778
+ `saml-replay:${issuer.length}:${issuer}:${assertionId}`,
779
+ retainUntil,
780
+ );
781
+ },
782
+ };
783
+ ```
784
+
785
+ The issuer is length-prefixed, as in the in-memory store, because both parts
786
+ may contain `:` — without the length, issuer `a:b` with ID `c` and issuer `a`
787
+ with ID `b:c` would share one key, and one would be refused as the other's
788
+ replay.
789
+
790
+ `createInMemoryReplayStore()` returns a store of your own, for isolation — a
791
+ test, or a component that must not share memory with the rest of the process.
792
+ The in-memory store prunes lazily when consulted, so it holds no timer and
793
+ needs no disposal.
794
+
795
+ #### Clock skew
796
+
797
+ `clockSkewMs` defaults to **`0`**: this package applies no leniency you did not
798
+ choose. It must be a finite, non-negative integer; anything else fails at
799
+ construction. It widens the `NotBefore` and `NotOnOrAfter` checks of both
800
+ `Conditions` and the bearer confirmation. A replay entry is retained until
801
+ the earlier of `Conditions/@NotOnOrAfter` and the **latest** `NotOnOrAfter` of
802
+ a bearer confirmation that answers the request and names the ACS — one not
803
+ open yet included — plus `clockSkewMs`: the last instant the assertion could
804
+ still be accepted. That is not `expiresAt`, which takes the earliest
805
+ confirmation; with confirmations closing at +120 s and +600 s the session ends
806
+ at +120 s, but the second still admits the assertion at +200 s, so the entry
807
+ must outlive it. Neither window nor tolerance cuts a hole in replay detection.
808
+
809
+ #### What a validated assertion carries: `raw` and `signedXml`
810
+
811
+ You meet a `ValidatedAssertion` when you call a validator yourself or wrap one
812
+ in an `IAssertionValidator` of your own. The shipped validators fill
813
+ `expiresAt`, `assertionId`, `issuer`, `nameId` (when the `Subject` has one),
814
+ `raw` and `signedXml`; they leave `sessionIndex` and `attributes` unset.
815
+
816
+ - **`raw`** is the validator's input, unchanged — a `samlp:Response`, or a bare
817
+ `saml:Assertion` where the validator accepts one. It makes no promise about
818
+ what a provider forwards: `Saml2PureProvider` hands the payload to
819
+ `cookieProvider` as it is, while `Saml2BearerProvider` sends the extracted
820
+ Assertion, not `raw`. **Holding a `ValidatedAssertion` does not make all of
821
+ `raw` trustworthy**: a Response validated by `createSignedAssertionValidator`
822
+ carries `Status`, `Response/Issuer` and `Destination`, which nothing read and
823
+ nothing checked.
824
+ - **`signedXml`** is what the signature covered, serialised: the `Assertion`,
825
+ or the `Response` when that is what was signed. Anything this interface does
826
+ not surface — attributes, a session index — must be parsed from `signedXml`,
827
+ never from `raw`. The difference between the two is the difference between
828
+ "signed" and "arrived".
829
+
830
+ #### Using a shipped validator directly
831
+
832
+ ```typescript
833
+ import { readFileSync } from 'node:fs';
834
+ import {
835
+ AssertionValidationError,
836
+ createSignedResponseValidator,
837
+ } from '@mcp-abap-adt/auth-providers';
838
+
839
+ const validator = createSignedResponseValidator({
840
+ idpCertificates: [readFileSync('idp-signing.pem', 'utf8')],
841
+ });
842
+
843
+ try {
844
+ const validated = await validator.validate(samlResponseBase64, {
845
+ expectedInResponseTo: requestId, // omit only for an IdP-initiated login
846
+ audience: 'my-sp-entity',
847
+ acsUrl: 'https://sp.example.com/saml/acs',
848
+ expectedIssuer: 'https://idp.example.com/metadata', // required — see below
849
+ });
850
+ console.error(validated.nameId, validated.expiresAt);
851
+ } catch (error) {
852
+ if (error instanceof AssertionValidationError) {
853
+ console.error(`refused at ${error.check}: ${error.message}`);
854
+ }
855
+ throw error;
856
+ }
857
+ ```
858
+
859
+ **Pass `expectedIssuer`.** It is optional on `AssertionContext`, for custom
860
+ validators that establish trust some other way, but the shipped validators
861
+ **fail closed** without it: every assertion is refused at `issuer`, since
862
+ otherwise any issuer holding a key on your list would pass. The providers
863
+ always pass `idpEntityId` there; a caller of a shipped validator must pass it
864
+ itself. `expectedInResponseTo` follows the request-ID rule: given, the
865
+ assertion must answer it; absent, the assertion must carry no `InResponseTo`.
866
+
867
+ #### Errors
868
+
869
+ | Error | When |
870
+ |---|---|
871
+ | `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 |
873
+ | `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
874
 
460
875
  ### With Stores
461
876
 
@@ -708,12 +1123,17 @@ import {
708
1123
  SessionDataError,
709
1124
  ServiceKeyError,
710
1125
  BrowserAuthError,
1126
+ AssertionValidationError,
711
1127
  } from '@mcp-abap-adt/auth-providers';
712
1128
 
713
1129
  try {
714
1130
  const result = await provider.getTokens();
715
1131
  } catch (error) {
716
- if (error instanceof ValidationError) {
1132
+ if (error instanceof AssertionValidationError) {
1133
+ // A SAML provider refused the assertion; `check` says which check failed
1134
+ console.error('Assertion refused at:', error.check); // e.g. 'audience'
1135
+ console.error('Error code:', error.code); // 'ASSERTION_VALIDATION_ERROR'
1136
+ } else if (error instanceof ValidationError) {
717
1137
  // provider config validation failed
718
1138
  console.error('Missing required fields:', error.missingFields);
719
1139
  console.error('Error code:', error.code); // 'VALIDATION_ERROR'
@@ -736,8 +1156,116 @@ try {
736
1156
  - `SessionDataError` - Session data invalid, includes `missingFields: string[]`
737
1157
  - `ServiceKeyError` - Service key data invalid, includes `missingFields: string[]`
738
1158
  - `BrowserAuthError` - Browser auth failed, includes `cause?: Error`
1159
+ - `AssertionValidationError` - a SAML assertion was refused, includes `check: AssertionCheck` naming the check that failed — see [SAML assertion validation](#errors)
739
1160
 
740
- All error codes are defined in `@mcp-abap-adt/interfaces-auth` package as `TOKEN_PROVIDER_ERROR_CODES`.
1161
+ 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
+
1163
+ ## Migrating from 3.x to 4.0
1164
+
1165
+ 4.0.0 changes nothing outside the two SAML providers. For those, it validates
1166
+ every assertion — see [SAML assertion validation](#saml-assertion-validation) —
1167
+ and a 3.x configuration no longer constructs:
1168
+
1169
+ ```
1170
+ The default assertion validator needs the identity provider it should trust:
1171
+ missing idpCertificates, idpEntityId. Supply these, or supply an
1172
+ assertionValidator of your own.
1173
+ ```
1174
+
1175
+ What to add, on `Saml2BearerProvider` and `Saml2PureProvider` alike:
1176
+
1177
+ - **Whom to trust: `idpCertificates` and `idpEntityId`, or an
1178
+ `assertionValidator`.** The certificates are the identity provider's signing
1179
+ certificates, PEM or the bare base64 of `<X509Certificate>` in its metadata;
1180
+ `idpEntityId` is the `Issuer` its assertions carry — its `entityID`. A
1181
+ shipped validator supplied as `assertionValidator` still needs
1182
+ `idpEntityId`; only a validator of your own does without.
1183
+ - **`spEntityId` must be your real entity ID.** It was already required, but in
1184
+ 3.x it only named the issuer of the AuthnRequest, and a login that never built
1185
+ one never used it. It is now the `Audience` every `AudienceRestriction` must
1186
+ name — for the bearer grant against UAA or XSUAA, the `entityID` in their SAML
1187
+ metadata.
1188
+ - **`idpInitiated: true` for `Saml2BearerProvider` against UAA or XSUAA**, whose
1189
+ saml2-bearer grant refuses an assertion carrying `InResponseTo`. With it, use
1190
+ a strategy that does not call `buildAuthorizationUrl` — `staticCodeStrategy`
1191
+ or your own. The 3.0 advice, `externalCodeStrategy` whose `provide` ignores
1192
+ the URL, now fails: with `idpInitiated: true` and no `authorizationUrl`, the
1193
+ builder refuses before producing a URL, since the only one it could build
1194
+ carries an AuthnRequest.
1195
+ - **`authnRequestId` when the package does not build the request**: with a
1196
+ pre-built `authorizationUrl`, or a strategy that returns a payload without
1197
+ calling `buildAuthorizationUrl` after a request you sent — unless the login is
1198
+ `idpInitiated`. Without either, the login fails before the assertion is read.
1199
+ - **The strategy's `redirectUri` must be the ACS the assertion names** in
1200
+ `SubjectConfirmationData/@Recipient` — for the bearer grant, the token
1201
+ endpoint's bearer ACS. `staticCodeStrategy` defaults it to
1202
+ `http://localhost:61001/callback`, which such an assertion does not name.
1203
+
1204
+ For the bearer grant against UAA or XSUAA, a 3.x configuration becomes:
1205
+
1206
+ ```typescript
1207
+ // 3.x
1208
+ new Saml2BearerProvider({
1209
+ idpSsoUrl, spEntityId, uaaUrl, clientId, clientSecret,
1210
+ authorization: externalCodeStrategy({ provide: async () => fetchAssertion() }),
1211
+ });
1212
+
1213
+ // 4.0
1214
+ new Saml2BearerProvider({
1215
+ idpSsoUrl, uaaUrl, clientId, clientSecret,
1216
+ spEntityId: uaaEntityId, // the entityID in UAA's SAML metadata
1217
+ acsUrl: uaaBearerAcs, // its bearer ACS: the Recipient
1218
+ idpCertificates: [idpSigningCertPem],
1219
+ idpEntityId: 'https://idp.example.com/metadata',
1220
+ idpInitiated: true,
1221
+ authorization: {
1222
+ // Never calls buildAuthorizationUrl; a fresh assertion per login.
1223
+ authorize: async () => ({ payload: await fetchAssertion(), redirectUri: uaaBearerAcs }),
1224
+ },
1225
+ });
1226
+ ```
1227
+
1228
+ **What now fails that used to pass:**
1229
+
1230
+ - an unsigned assertion, one signed with a key not in `idpCertificates`, or one
1231
+ altered after signing (`signature`);
1232
+ - under `Saml2PureProvider`'s default, a response whose `Response` is not
1233
+ signed — an identity provider that signs only assertions. Select
1234
+ `createSignedAssertionValidator` for it (`signedNode`);
1235
+ - under `Saml2PureProvider`'s default, a response whose `Status` is absent or
1236
+ not `Success` (`status`), or whose `Destination` is absent or not the ACS it
1237
+ arrived at (`destination`). `Saml2BearerProvider`'s default,
1238
+ `createSignedAssertionValidator`, does not read `Status`, so a bearer
1239
+ consumer sees no change there — a declining identity provider mints no
1240
+ signed assertion, and the login is refused for want of one;
1241
+ - an assertion from another issuer (`issuer`), for another audience
1242
+ (`audience`), expired or not yet valid (`notOnOrAfter`, `notBefore`,
1243
+ `bearerConfirmation`), or whose bearer confirmation names another ACS as
1244
+ `Recipient`, or none (`bearerConfirmation`);
1245
+ - an `InResponseTo` that does not answer the request sent, one present on a
1246
+ login declared `idpInitiated`, or one missing from a login that sent a
1247
+ request (`bearerConfirmation`);
1248
+ - the same assertion presented twice while it is still valid (`replay`) — for
1249
+ instance a `staticCodeStrategy` payload reused by a second login;
1250
+ - a document carrying a second assertion, or an `EncryptedAssertion`, outside
1251
+ the signed one (`signedNode`), or a duplicated `ID` (`duplicateId`).
1252
+
1253
+ **Also changed:**
1254
+
1255
+ - `Saml2PureProvider`'s `expiresAt` comes from the validated assertion — the
1256
+ earlier of the `Conditions` and bearer-confirmation windows — not from the
1257
+ first `NotOnOrAfter` a regular expression found. It can be earlier than
1258
+ under 3.x.
1259
+ - `parseSamlNotOnOrAfter` is removed; `buildSamlAuthorizationUrl` returns
1260
+ `{ url, requestId? }` instead of a string, and `getSamlAssertion` a
1261
+ `SamlAssertionResult` instead of the payload string. None was exported from
1262
+ the package root; only a deep import of `dist/auth/saml2Auth` or
1263
+ `dist/providers/saml2Utils` is affected.
1264
+ - `@mcp-abap-adt/interfaces-auth` is `^2.0.0`, where
1265
+ `AssertionContext.expectedInResponseTo` is optional. That matters only to an
1266
+ implementer of `IAssertionValidator`, which must refuse an assertion carrying
1267
+ `InResponseTo` when it is absent.
1268
+ - `xml-crypto` is a new runtime dependency, for signature verification.
741
1269
 
742
1270
  ## Migrating from 2.x to 3.0
743
1271
 
@@ -880,6 +1408,13 @@ The package includes both unit tests (with mocks) and integration tests (with re
880
1408
  npm test
881
1409
  ```
882
1410
 
1411
+ `npm test` also runs both shipped assertion validators end to end, through
1412
+ `Saml2PureProvider` and a real callback, against responses produced by a
1413
+ separately published mock identity provider, `@mcp-abap-adt/auth-mocks`. Every
1414
+ corruption variant it ships is refused at the check it targets — except
1415
+ `statusFailure` and `wrongDestination`, which the assertion-only validator,
1416
+ reading neither field, accepts; both halves are asserted.
1417
+
883
1418
  ### Integration Tests
884
1419
 
885
1420
  Integration tests work with real files from `tests/test-config.yaml`:
@@ -949,9 +1484,9 @@ it.
949
1484
 
950
1485
  | provider | server | what the suite proves |
951
1486
  |---|---|---|
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 |
1487
+ | `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 |
1488
+ | `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` |
1489
+ | `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
1490
  | `ClientCredentialsProvider` | UAA | a client token |
956
1491
  | `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
1492
  | `AuthorizationCodeProvider` | UAA | a login through UAA's own form, and a refresh without logging in again |
@@ -1021,13 +1556,20 @@ not-found message counts as absence.
1021
1556
  `XSUAA_KEEP=1` keeps the environment for another run. A full run takes about a minute and
1022
1557
  a half. It is not part of CI.
1023
1558
 
1559
+ Results of the 4.0 suite on a BTP trial subaccount, 2026-09-25 — 4 passed,
1560
+ 1 skipped. Every SAML login is validated first, by `Saml2BearerProvider`'s default
1561
+ assertion-only validator, against the per-run test identity provider's
1562
+ certificate, declared `idpInitiated`:
1563
+
1024
1564
  | check | result on XSUAA |
1025
1565
  |---|---|
1026
- | `Saml2BearerProvider`, assertion without `InResponseTo` (IdP-initiated) | token and refresh token |
1566
+ | `Saml2BearerProvider`, assertion without `InResponseTo` (IdP-initiated) | passes validation; token and refresh token |
1027
1567
  | `Saml2BearerProvider`, a whole `SAMLResponse` | converted by the provider, accepted |
1028
1568
  | `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 |
1569
+ | `Saml2BearerProvider`, assertion with `InResponseTo` | refused locally at `bearerConfirmation`, before any request reaches XSUAA |
1570
+ | `UaaPasscodeProvider` (with `XSUAA_PASSCODE=<code from /passcode>`) | skipped — no `XSUAA_PASSCODE` was set |
1571
+
1572
+ Teardown removed everything setup had created.
1031
1573
 
1032
1574
  `UaaPasscodeProvider` was also checked by hand with an ABAP environment's own
1033
1575
  service key: its client accepts the passcode, and the token opens ADT. That
@@ -1080,10 +1622,11 @@ Example output:
1080
1622
 
1081
1623
  ## Dependencies
1082
1624
 
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`)
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
1626
+ - `@mcp-abap-adt/interfaces-auth-sap` (^1.0.1) - XSUAA authorization configuration (`IAuthorizationConfig`)
1085
1627
  - `@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
1628
+ - `@xmldom/xmldom` - XML parsing: SAML assertion validation, and taking the Assertion out of a SAMLResponse for the saml2-bearer grant
1629
+ - `xml-crypto` - XML-DSig signature verification for SAML assertion validation
1087
1630
  - `axios` - HTTP client
1088
1631
  - `express` - OAuth2 callback server
1089
1632
  - `open` - Browser opening utility