@mcp-abap-adt/auth-providers 2.2.2 → 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 (71) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +825 -64
  3. package/dist/__tests__/integration/stand/formLogin.d.ts +68 -0
  4. package/dist/__tests__/integration/stand/formLogin.d.ts.map +1 -0
  5. package/dist/__tests__/integration/stand/formLogin.js +194 -0
  6. package/dist/auth/callbackServer.js +2 -2
  7. package/dist/auth/passcodeAuth.d.ts +25 -0
  8. package/dist/auth/passcodeAuth.d.ts.map +1 -0
  9. package/dist/auth/passcodeAuth.js +62 -0
  10. package/dist/auth/saml2Auth.d.ts +6 -2
  11. package/dist/auth/saml2Auth.d.ts.map +1 -1
  12. package/dist/auth/saml2Auth.js +9 -20
  13. package/dist/auth/samlBearerAssertion.d.ts +24 -0
  14. package/dist/auth/samlBearerAssertion.d.ts.map +1 -0
  15. package/dist/auth/samlBearerAssertion.js +102 -0
  16. package/dist/auth/strictXml.d.ts +13 -0
  17. package/dist/auth/strictXml.d.ts.map +1 -0
  18. package/dist/auth/strictXml.js +21 -0
  19. package/dist/errors/AssertionValidationError.d.ts +15 -0
  20. package/dist/errors/AssertionValidationError.d.ts.map +1 -0
  21. package/dist/errors/AssertionValidationError.js +24 -0
  22. package/dist/errors/TokenProviderErrors.d.ts +2 -0
  23. package/dist/errors/TokenProviderErrors.d.ts.map +1 -1
  24. package/dist/errors/TokenProviderErrors.js +3 -1
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +13 -2
  28. package/dist/providers/Saml2BearerProvider.d.ts +1 -0
  29. package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
  30. package/dist/providers/Saml2BearerProvider.js +20 -2
  31. package/dist/providers/Saml2PureProvider.d.ts +1 -0
  32. package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
  33. package/dist/providers/Saml2PureProvider.js +19 -5
  34. package/dist/providers/UaaPasscodeProvider.d.ts +43 -0
  35. package/dist/providers/UaaPasscodeProvider.d.ts.map +1 -0
  36. package/dist/providers/UaaPasscodeProvider.js +86 -0
  37. package/dist/providers/index.d.ts +2 -2
  38. package/dist/providers/index.d.ts.map +1 -1
  39. package/dist/providers/index.js +3 -3
  40. package/dist/providers/saml2Utils.d.ts +49 -2
  41. package/dist/providers/saml2Utils.d.ts.map +1 -1
  42. package/dist/providers/saml2Utils.js +94 -2
  43. package/dist/strategies/index.d.ts +1 -1
  44. package/dist/strategies/index.d.ts.map +1 -1
  45. package/dist/strategies/index.js +2 -1
  46. package/dist/strategies/manualStrategies.d.ts +7 -0
  47. package/dist/strategies/manualStrategies.d.ts.map +1 -1
  48. package/dist/strategies/manualStrategies.js +21 -0
  49. package/dist/validation/assertionValidator.d.ts +28 -0
  50. package/dist/validation/assertionValidator.d.ts.map +1 -0
  51. package/dist/validation/assertionValidator.js +444 -0
  52. package/dist/validation/documentIds.d.ts +15 -0
  53. package/dist/validation/documentIds.d.ts.map +1 -0
  54. package/dist/validation/documentIds.js +32 -0
  55. package/dist/validation/inMemoryReplayStore.d.ts +22 -0
  56. package/dist/validation/inMemoryReplayStore.d.ts.map +1 -0
  57. package/dist/validation/inMemoryReplayStore.js +49 -0
  58. package/dist/validation/signedNode.d.ts +54 -0
  59. package/dist/validation/signedNode.d.ts.map +1 -0
  60. package/dist/validation/signedNode.js +171 -0
  61. package/dist/validation/xsdDateTime.d.ts +17 -0
  62. package/dist/validation/xsdDateTime.d.ts.map +1 -0
  63. package/dist/validation/xsdDateTime.js +67 -0
  64. package/package.json +14 -8
  65. package/bin/auth-device-flow.ts +0 -114
  66. package/dist/auth/deviceFlowAuth.d.ts +0 -43
  67. package/dist/auth/deviceFlowAuth.d.ts.map +0 -1
  68. package/dist/auth/deviceFlowAuth.js +0 -168
  69. package/dist/providers/DeviceFlowProvider.d.ts +0 -32
  70. package/dist/providers/DeviceFlowProvider.d.ts.map +0 -1
  71. package/dist/providers/DeviceFlowProvider.js +0 -86
package/README.md CHANGED
@@ -15,8 +15,17 @@ npm install @mcp-abap-adt/auth-providers
15
15
 
16
16
  This package implements the `ITokenProvider` interface from `@mcp-abap-adt/interfaces-auth`:
17
17
 
18
- - **AuthorizationCodeProvider** - Uses browser-based OAuth2 authorization code flow (user token)
19
- - **ClientCredentialsProvider** - Uses `client_credentials` grant type (no browser required)
18
+ - **ClientCredentialsProvider** — `client_credentials`, no user interaction
19
+ - **AuthorizationCodeProvider** — UAA/XSUAA authorization code, through a browser
20
+ - **UaaPasscodeProvider** — UAA/XSUAA one-time passcode from `/passcode`, the
21
+ login `cf login --sso` uses: SSO without a browser on this machine
22
+ - **OidcBrowserProvider** — OIDC authorization code with PKCE
23
+ - **OidcDeviceFlowProvider** — OAuth 2.0 device authorization grant (RFC 8628)
24
+ - **OidcPasswordProvider**, **OidcTokenExchangeProvider** — password grant and
25
+ token exchange (RFC 8693)
26
+ - **Saml2BearerProvider** — a SAML assertion exchanged for an OAuth2 token
27
+ (RFC 7522)
28
+ - **Saml2PureProvider** — a SAML assertion exchanged for session cookies
20
29
 
21
30
  Providers are configured via constructor; `getTokens()` takes no parameters and handles refresh/login internally.
22
31
 
@@ -26,8 +35,18 @@ Since 2.0.0 an interactive login is conducted by an **authorization strategy**
26
35
  and the token exchange; everything between them (reaching the URL, receiving
27
36
  what comes back, the port, the timeout) belongs to the strategy, which a
28
37
  consumer may replace wholesale. See
29
- [Choosing an authorization strategy](#choosing-an-authorization-strategy) and,
30
- if you are on 1.x, [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).
31
50
 
32
51
  ## Responsibilities and Design Principles
33
52
 
@@ -55,6 +74,7 @@ This package is responsible for:
55
74
  2. **Token acquisition**: Handles OAuth2 flows (browser-based, refresh token, client credentials) to obtain JWT tokens
56
75
  3. **Token validation**: Validates JWT locally by checking exp claim (no HTTP requests)
57
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
58
78
 
59
79
  #### What This Package Does
60
80
 
@@ -63,6 +83,7 @@ This package is responsible for:
63
83
  - **Obtains tokens**: Makes HTTP requests to UAA endpoints to obtain JWT tokens
64
84
  - **Validates tokens**: Validates JWT locally by checking exp claim (no HTTP requests)
65
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`
66
87
 
67
88
  #### What This Package Does NOT Do
68
89
 
@@ -71,6 +92,7 @@ This package is responsible for:
71
92
  - **Does NOT know about service keys**: Service key loading is handled by stores
72
93
  - **Does NOT manage sessions**: Session management is handled by stores
73
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
74
96
 
75
97
  ### External Dependencies
76
98
 
@@ -318,66 +340,92 @@ The redirect URI is no longer a provider field: it belongs to the strategy,
318
340
  because with an ephemeral port nothing knows it until the socket is bound. The
319
341
  one the strategy reports is the one sent to the token endpoint.
320
342
 
321
- SAML bearer example (manual paste):
322
-
323
- ```typescript
324
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
325
- import {
326
- Saml2BearerProvider,
327
- manualSamlResponseStrategy,
328
- } from '@mcp-abap-adt/auth-providers';
329
-
330
- const acsUrl = 'https://sp.example.com/saml/acs';
331
-
332
- const provider = new Saml2BearerProvider({
333
- idpSsoUrl: 'https://idp.example.com/sso',
334
- spEntityId: 'my-sp-entity',
335
- acsUrl,
336
- uaaUrl: 'https://uaa.example.com',
337
- clientId: '...',
338
- clientSecret: '...',
339
- // `redirectUri` must equal `acsUrl`, or the provider refuses the mismatch.
340
- authorization: manualSamlResponseStrategy({ redirectUri: acsUrl, read: promptUser }),
341
- });
342
-
343
- const broker = new AuthBroker({ tokenProvider: provider }, 'none');
344
- ```
345
-
346
- **Read that `redirectUri` twice.** A SAML strategy defaults its redirect URI to
347
- `http://localhost:61001/callback`, and the provider requires the assertion
348
- consumer service the IdP posts to be exactly the one the strategy names. If you
349
- declare a real `acsUrl` and leave `redirectUri` off, the login fails with
350
- *"SAML acsUrl is … but the authorization strategy is listening on …"* before
351
- anything is opened. Declare neither and the default is used for both, which is
352
- consistent — and only reachable when the IdP will post to your localhost.
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.
353
347
 
354
- SAML bearer example (headless, assertion fetched elsewhere):
348
+ SAML bearer example (UAA or XSUAA — an IdP-initiated assertion):
355
349
 
356
350
  ```typescript
351
+ import { readFileSync } from 'node:fs';
357
352
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
358
- import {
359
- Saml2BearerProvider,
360
- externalCodeStrategy,
361
- } from '@mcp-abap-adt/auth-providers';
362
-
363
- 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
+ };
364
370
 
365
371
  const provider = new Saml2BearerProvider({
366
372
  idpSsoUrl: 'https://idp.example.com/sso',
367
- spEntityId: 'my-sp-entity',
373
+ spEntityId: 'uaa.example', // the entityID in that metadata: the Audience
368
374
  acsUrl,
369
375
  uaaUrl: 'https://uaa.example.com',
370
376
  clientId: '...',
371
377
  clientSecret: '...',
372
- authorization: externalCodeStrategy({
373
- redirectUri: acsUrl,
374
- provide: async (_authorizationUrl) => getSamlResponseFromSsoProxy(),
375
- }),
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,
376
384
  });
377
385
 
378
386
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
379
387
  ```
380
388
 
389
+ **Who starts the login matters.** An identity provider answering an
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.
416
+
417
+ **What is sent.** The saml2-bearer grant takes one SAML Assertion,
418
+ base64url-encoded (RFC 7522 §2.1). A strategy may deliver either that or the
419
+ whole `SAMLResponse` an identity provider posts, in standard base64 —
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.
428
+
381
429
  **Refresh.** When the token endpoint returns a `refresh_token` with the SAML
382
430
  bearer exchange, `Saml2BearerProvider` spends it once the access token expires:
383
431
  a `refresh_token` grant to the same endpoint (`tokenUrl`, or `uaaUrl` +
@@ -386,9 +434,10 @@ browser involved. Pass a stored one back as `refreshToken` in the config and the
386
434
  next `getTokens()` uses it. If the grant is refused, or no refresh token was
387
435
  ever issued, the provider falls back to a full login through `authorization`.
388
436
 
389
- Pure SAML example (cookie-based):
437
+ Pure SAML example (cookie-based, SP-initiated):
390
438
 
391
439
  ```typescript
440
+ import { readFileSync } from 'node:fs';
392
441
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
393
442
  import {
394
443
  Saml2PureProvider,
@@ -401,6 +450,10 @@ const provider = new Saml2PureProvider({
401
450
  idpSsoUrl: 'https://idp.example.com/sso',
402
451
  spEntityId: 'my-sp-entity',
403
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.
404
457
  authorization: manualSamlResponseStrategy({ redirectUri: acsUrl, read: promptUser }),
405
458
  // Convert SAMLResponse to session cookies for SAP (implementation-specific)
406
459
  cookieProvider: async (samlResponse) => {
@@ -411,6 +464,17 @@ const provider = new Saml2PureProvider({
411
464
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
412
465
  ```
413
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
+
414
478
  Both SAML providers now reject at construction when `authorizationUrl` is set
415
479
  without `acsUrl`:
416
480
 
@@ -422,7 +486,391 @@ SAML request cannot be read, so it must be declared.
422
486
  The ACS is buried in a deflated `SAMLRequest` this package did not build and
423
487
  cannot read, so it cannot be verified against whatever the strategy binds. 1.x
424
488
  accepted the combination and defaulted the ACS to
425
- `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 |
426
874
 
427
875
  ### With Stores
428
876
 
@@ -538,14 +986,39 @@ const result = await provider.getTokens();
538
986
  // result.refreshToken is undefined (client_credentials doesn't provide refresh tokens)
539
987
  ```
540
988
 
541
- #### DeviceFlowProvider
989
+ #### UaaPasscodeProvider
990
+
991
+ The login `cf login --sso` uses, for UAA and XSUAA: a one-time **Temporary
992
+ Authentication Code**. Nothing opens and nothing listens on this machine — the
993
+ user opens `<uaaUrl>/passcode` in any browser, on any device, logs in however
994
+ the identity zone asks (SSO through a corporate IdP, MFA), and copies the code
995
+ shown there. The provider exchanges it for tokens and refreshes them, so the
996
+ code is asked for again only when the refresh token is gone. It suits an MCP
997
+ server on a remote machine, in a container, or behind SSH.
998
+
999
+ ```typescript
1000
+ import { UaaPasscodeProvider, manualPasscodeStrategy } from '@mcp-abap-adt/auth-providers';
1001
+
1002
+ const provider = new UaaPasscodeProvider({
1003
+ uaaUrl: 'https://<subdomain>.authentication.<region>.hana.ondemand.com',
1004
+ clientId: '...', // a client allowed the `password` grant (and `refresh_token`)
1005
+ clientSecret: '...', // omit for a public client
1006
+ // The default: announce <uaaUrl>/passcode, read the code from the terminal.
1007
+ // Supply `read` to take it from anywhere else — never from stdin under MCP.
1008
+ authorization: manualPasscodeStrategy({ read: askTheUser }),
1009
+ });
1010
+ ```
1011
+
1012
+ The exchange is the password grant with `passcode` instead of a username and
1013
+ password — a UAA extension, not an RFC. A code is single-use; a mistyped or
1014
+ spent one fails with `Passcode exchange failed (401): Invalid passcode`.
1015
+
1016
+ #### Device flow prompts
542
1017
 
543
- `DeviceFlowProviderConfig` now accepts `logger?: ILogger`. The verification URI
544
- and the user code are a prompt the user must see, not a log line: they go to the
545
- logger when one is supplied and to **stderr** otherwise. They no longer go to
546
- stdout — capturing stdout to read the device code will read nothing, and the
547
- change exists because stdout carries protocol traffic under an MCP or LSP stdio
548
- transport. `OidcDeviceFlowProvider` behaves the same way.
1018
+ `OidcDeviceFlowProvider` accepts `logger?: ILogger`. The verification URI and
1019
+ the user code are a prompt the user must see, not a log line: they go to the
1020
+ logger when one is supplied and to **stderr** otherwise — never to stdout,
1021
+ which carries protocol traffic under an MCP or LSP stdio transport.
549
1022
 
550
1023
  #### Callback port and lifetime
551
1024
 
@@ -650,12 +1123,17 @@ import {
650
1123
  SessionDataError,
651
1124
  ServiceKeyError,
652
1125
  BrowserAuthError,
1126
+ AssertionValidationError,
653
1127
  } from '@mcp-abap-adt/auth-providers';
654
1128
 
655
1129
  try {
656
1130
  const result = await provider.getTokens();
657
1131
  } catch (error) {
658
- 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) {
659
1137
  // provider config validation failed
660
1138
  console.error('Missing required fields:', error.missingFields);
661
1139
  console.error('Error code:', error.code); // 'VALIDATION_ERROR'
@@ -678,8 +1156,149 @@ try {
678
1156
  - `SessionDataError` - Session data invalid, includes `missingFields: string[]`
679
1157
  - `ServiceKeyError` - Service key data invalid, includes `missingFields: string[]`
680
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)
1160
+
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
+ });
681
1212
 
682
- All error codes are defined in `@mcp-abap-adt/interfaces-auth` package as `TOKEN_PROVIDER_ERROR_CODES`.
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.
1269
+
1270
+ ## Migrating from 2.x to 3.0
1271
+
1272
+ 3.0.0 changes no provider's configuration, but it drops a provider, a command
1273
+ and the Node versions nothing supports any more.
1274
+
1275
+ - **Node.js 22 or 24.** `engines` is `"^22 || ^24"`, following SAP BTP, Cloud
1276
+ Foundry. Node 18 and 20 are past their end of life and SAP has removed 20;
1277
+ 23 and 25, odd releases, are too. Move the process to 22 or 24.
1278
+ - **`DeviceFlowProvider` is gone**, with `DeviceFlowProviderConfig` and the
1279
+ `auth-device-flow` command. It sent the device grant to
1280
+ `<uaaUrl>/oauth/device_authorization`, which no server we know of serves —
1281
+ neither UAA nor XSUAA offers the device grant at all. Replace it with:
1282
+
1283
+ ```typescript
1284
+ // A server that implements RFC 8628 (Keycloak, Spring Authorization Server, …):
1285
+ new OidcDeviceFlowProvider({ issuerUrl, clientId, logger });
1286
+
1287
+ // UAA or XSUAA — a headless SSO login, the way `cf login --sso` does it:
1288
+ new UaaPasscodeProvider({
1289
+ uaaUrl, clientId, clientSecret,
1290
+ authorization: manualPasscodeStrategy({ read: askTheUser }),
1291
+ });
1292
+ ```
1293
+
1294
+ - **`Saml2BearerProvider` now sends one base64url Assertion** (RFC 7522),
1295
+ taken out of the `SAMLResponse` a login delivers. Before, it forwarded the
1296
+ whole response, which UAA and XSUAA refuse — so nothing that worked stops
1297
+ working. Note what the live checks showed, though: both refuse an assertion
1298
+ carrying `InResponseTo`, which an identity provider sets whenever it answers
1299
+ an AuthnRequest. Against them, supply an IdP-initiated assertion — see
1300
+ *Who starts the login matters* under `Saml2BearerProvider`.
1301
+ - `@xmldom/xmldom` is a new runtime dependency, for that conversion.
683
1302
 
684
1303
  ## Migrating from 1.x to 2.0
685
1304
 
@@ -770,9 +1389,11 @@ Three more changes that are not fields:
770
1389
  an explicit choice; otherwise the paste form on `/` is the remaining fallback
771
1390
  for a browser on another machine.
772
1391
  - **Device flow prompts no longer go to stdout.** `DeviceFlowProviderConfig`
773
- accepts `logger?: ILogger`; the verification URI and user code go to that
1392
+ accepted `logger?: ILogger`; the verification URI and user code go to that
774
1393
  logger, or to stderr when there is none. Anything that captured stdout to read
775
- the device code must read stderr or supply a logger.
1394
+ the device code must read stderr or supply a logger. (`DeviceFlowProvider`
1395
+ itself was removed in 3.0.0 — see the changelog; `OidcDeviceFlowProvider`
1396
+ behaves the same way.)
776
1397
  - **A `/callback` carrying neither a code nor an error no longer ends the
777
1398
  login.** It is answered and counted, and the tally appears in the timeout
778
1399
  message if the login later expires.
@@ -787,6 +1408,13 @@ The package includes both unit tests (with mocks) and integration tests (with re
787
1408
  npm test
788
1409
  ```
789
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
+
790
1418
  ### Integration Tests
791
1419
 
792
1420
  Integration tests work with real files from `tests/test-config.yaml`:
@@ -820,6 +1448,134 @@ Integration tests will skip if `test-config.yaml` is not configured or contains
820
1448
  - The interactive test asks the OS for a free port rather than pinning one, so it cannot collide with a running server
821
1449
  - Tests use `browserCallbackStrategy({ browser: 'system' })` for interactive authentication (not `'none'`)
822
1450
 
1451
+ ### Providers against real authorization servers (UAA and Keycloak)
1452
+
1453
+ The providers are also tested against two real, widely used authorization
1454
+ servers running locally in Docker from their official images — [Cloud Foundry
1455
+ UAA](https://github.com/cloudfoundry/uaa) (`cfidentity/uaa`), the open-source
1456
+ server XSUAA is built from, and [Keycloak](https://www.keycloak.org/)
1457
+ (`quay.io/keycloak/keycloak`). It needs Docker and nothing else — no SAP
1458
+ system, no setup step:
1459
+
1460
+ ```bash
1461
+ npm run test:stand # start UAA and Keycloak, run the suites, stop both
1462
+ ```
1463
+
1464
+ `test:stand` starts both containers with `docker compose`, waits until both
1465
+ answer, runs the suites, and stops the containers again — also when a test
1466
+ fails, with the suites' exit code, after printing the last 200 lines of each
1467
+ server's log. A full run takes well under a minute. CI runs exactly this as its
1468
+ own job, on Node 22 and 24. To keep the stand up between runs, start it
1469
+ yourself; `test:stand` then leaves it running. Ownership is per server: if only
1470
+ one of the two was running, the run starts the other and removes only that one
1471
+ afterwards:
1472
+
1473
+ ```bash
1474
+ npm run stand:up # start and keep running
1475
+ npm run test:stand # as often as needed
1476
+ npm run stand:down # stop
1477
+ ```
1478
+
1479
+ `STAND_KEEP=1 npm run test:stand` keeps a stand the run started. `UAA_PORT`
1480
+ (8080) and `KEYCLOAK_PORT` (8081) move the servers. A server that is already
1481
+ running is never changed: if it is published on another port than the one
1482
+ asked for, `test:stand` refuses and says so, rather than let Compose recreate
1483
+ it.
1484
+
1485
+ | provider | server | what the suite proves |
1486
+ |---|---|---|
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 |
1490
+ | `ClientCredentialsProvider` | UAA | a client token |
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 |
1492
+ | `AuthorizationCodeProvider` | UAA | a login through UAA's own form, and a refresh without logging in again |
1493
+ | `OidcPasswordProvider` | Keycloak | the password grant through discovery, and a refresh that works with a wrong password — so it is a refresh, not a second login |
1494
+ | `OidcBrowserProvider` | Keycloak | authorization code with S256 PKCE, which the client requires, through Keycloak's login page |
1495
+ | `OidcDeviceFlowProvider` | Keycloak | a token once the user logs in and grants access on Keycloak's device pages, read from the verification URI the provider announces |
1496
+ | `OidcTokenExchangeProvider` | Keycloak | RFC 8693: another client's access token exchanged for the requester's own |
1497
+
1498
+ The servers' configuration is committed as test fixtures —
1499
+ `tests/stand/uaa/config/uaa.yml`, `tests/stand/keycloak/realm-test.json` and the
1500
+ test identity provider's key in `tests/stand/uaa/idp/` — so every machine and CI
1501
+ run the same stand. The keys and passwords in them are trusted by nothing but
1502
+ that local stand; they are not secrets, and must not be reused.
1503
+
1504
+ For the Keycloak → UAA case the suite makes UAA trust Keycloak at run time —
1505
+ it registers Keycloak as a SAML identity provider through UAA's API, from the
1506
+ metadata Keycloak publishes — and configures Keycloak's IdP-initiated SSO to
1507
+ post to UAA's bearer ACS, since both depend on the ports and on keys Keycloak
1508
+ generates when it starts.
1509
+
1510
+ Interactive logins are played by `src/__tests__/integration/stand/formLogin.ts`,
1511
+ which submits each server's own login and consent forms over HTTP.
1512
+
1513
+ Not covered: the cookie half of `Saml2PureProvider`, which belongs to the
1514
+ consumer's `cookieProvider` and needs a real SAP system.
1515
+
1516
+ A plain `npm test` skips these suites: they run only with `UAA_URL` or
1517
+ `KEYCLOAK_URL` set, which `test:stand` does.
1518
+
1519
+ ### Live checks against XSUAA (BTP subaccount)
1520
+
1521
+ The stand proves the wire contracts against open-source servers. What only a
1522
+ real XSUAA can answer is checked by `npm run test:xsuaa`, against a BTP
1523
+ subaccount you are logged in to — a trial one is enough:
1524
+
1525
+ ```bash
1526
+ cf login -a https://api.cf.<region>.hana.ondemand.com --sso -o <org> -s <space>
1527
+ XSUAA_CF_API=https://api.cf.<region>.hana.ondemand.com XSUAA_CF_ORG=<org> \
1528
+ XSUAA_CF_SPACE=<space> npm run test:xsuaa
1529
+ ```
1530
+
1531
+ It creates, in the targeted space, an `xsuaa`/`application` instance whose
1532
+ client may use saml2-bearer, refresh_token and password; an `xsuaa`/`apiaccess`
1533
+ instance, used only to manage trust; and a SAML trust to a test identity
1534
+ provider whose key is generated locally and never leaves the gitignored
1535
+ `tests/xsuaa/.local/`. Then it runs the suite and removes all of it — also when
1536
+ a test fails. Two rules keep it from touching anything else:
1537
+
1538
+ - **Target.** The scripts refuse to run unless `cf` targets exactly
1539
+ `XSUAA_CF_API`, `XSUAA_CF_ORG` and `XSUAA_CF_SPACE`. There are no defaults, so
1540
+ a `cf` left pointing at another org or space cannot receive anything.
1541
+ - **Ownership.** Everything setup creates is recorded in
1542
+ `tests/xsuaa/.local/owned` with its immutable ID — the service instance's
1543
+ GUID, the trust's id — and the record names the API, org and space it
1544
+ belongs to. A resource is treated as ours only when its name and its current
1545
+ ID both match a record, checked right before it is reused, refreshed or
1546
+ deleted. A name held by anything else — never created here, or recreated
1547
+ after ours was deleted — is refused by setup and left alone by teardown, and
1548
+ a record from another target is refused outright.
1549
+
1550
+ The run fails — non-zero — when the tests fail or the teardown does. A
1551
+ teardown that fails stops at once and keeps `tests/xsuaa/.local/`, keys and
1552
+ record included, so `tests/xsuaa/teardown.sh` can be run again to finish. A
1553
+ lookup that fails — no session, no network, an API error — is a failure, never
1554
+ read as "already gone": `cf service` exits 1 for both, so only its exact
1555
+ not-found message counts as absence.
1556
+ `XSUAA_KEEP=1` keeps the environment for another run. A full run takes about a minute and
1557
+ a half. It is not part of CI.
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
+
1564
+ | check | result on XSUAA |
1565
+ |---|---|
1566
+ | `Saml2BearerProvider`, assertion without `InResponseTo` (IdP-initiated) | passes validation; token and refresh token |
1567
+ | `Saml2BearerProvider`, a whole `SAMLResponse` | converted by the provider, accepted |
1568
+ | `Saml2BearerProvider`, refresh | never reaches the strategy |
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.
1573
+
1574
+ `UaaPasscodeProvider` was also checked by hand with an ABAP environment's own
1575
+ service key: its client accepts the passcode, and the token opens ADT. That
1576
+ depends on the user being known to the ABAP system — a user from an identity
1577
+ provider not propagated to it gets a token and a 401 from ADT.
1578
+
823
1579
  ### Debug Logging
824
1580
 
825
1581
  To enable detailed logging during tests or runtime, set environment variables:
@@ -866,14 +1622,19 @@ Example output:
866
1622
 
867
1623
  ## Dependencies
868
1624
 
869
- - `@mcp-abap-adt/interfaces-auth` (^1.2.0) - Token provider and authorization contracts (`ITokenProvider`, `IAuthorizationStrategy`, `CallbackServerFactory`) and error code constants
870
- - `@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`)
871
1627
  - `@mcp-abap-adt/interfaces-utils` (^1.1.0) - `ILogger`
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
872
1630
  - `axios` - HTTP client
873
1631
  - `express` - OAuth2 callback server
874
1632
  - `open` - Browser opening utility
875
1633
 
876
- Requires Node.js `>=18.2.0`.
1634
+ Requires Node.js 22 or 24 (`engines: "^22 || ^24"`). The supported versions
1635
+ follow SAP BTP, Cloud Foundry, whose Node.js buildpack offers exactly these two;
1636
+ CI tests both. Odd-numbered releases are never supported — they reach end of
1637
+ life within months — and a new major joins only once SAP offers it.
877
1638
 
878
1639
  ## License
879
1640