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