@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.
- package/CHANGELOG.md +193 -0
- package/README.md +825 -64
- package/dist/__tests__/integration/stand/formLogin.d.ts +68 -0
- package/dist/__tests__/integration/stand/formLogin.d.ts.map +1 -0
- package/dist/__tests__/integration/stand/formLogin.js +194 -0
- package/dist/auth/callbackServer.js +2 -2
- package/dist/auth/passcodeAuth.d.ts +25 -0
- package/dist/auth/passcodeAuth.d.ts.map +1 -0
- package/dist/auth/passcodeAuth.js +62 -0
- 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 +24 -0
- package/dist/auth/samlBearerAssertion.d.ts.map +1 -0
- package/dist/auth/samlBearerAssertion.js +102 -0
- 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 +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/providers/Saml2BearerProvider.d.ts +1 -0
- package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
- package/dist/providers/Saml2BearerProvider.js +20 -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/UaaPasscodeProvider.d.ts +43 -0
- package/dist/providers/UaaPasscodeProvider.d.ts.map +1 -0
- package/dist/providers/UaaPasscodeProvider.js +86 -0
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +3 -3
- package/dist/providers/saml2Utils.d.ts +49 -2
- package/dist/providers/saml2Utils.d.ts.map +1 -1
- package/dist/providers/saml2Utils.js +94 -2
- package/dist/strategies/index.d.ts +1 -1
- package/dist/strategies/index.d.ts.map +1 -1
- package/dist/strategies/index.js +2 -1
- package/dist/strategies/manualStrategies.d.ts +7 -0
- package/dist/strategies/manualStrategies.d.ts.map +1 -1
- package/dist/strategies/manualStrategies.js +21 -0
- package/dist/validation/assertionValidator.d.ts +28 -0
- package/dist/validation/assertionValidator.d.ts.map +1 -0
- package/dist/validation/assertionValidator.js +444 -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 +171 -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 +14 -8
- package/bin/auth-device-flow.ts +0 -114
- package/dist/auth/deviceFlowAuth.d.ts +0 -43
- package/dist/auth/deviceFlowAuth.d.ts.map +0 -1
- package/dist/auth/deviceFlowAuth.js +0 -168
- package/dist/providers/DeviceFlowProvider.d.ts +0 -32
- package/dist/providers/DeviceFlowProvider.d.ts.map +0 -1
- 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
|
-
- **
|
|
19
|
-
- **
|
|
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)
|
|
30
|
-
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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 (
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const acsUrl = 'https://
|
|
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: '
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
####
|
|
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
|
-
`
|
|
544
|
-
|
|
545
|
-
logger when one is supplied and to **stderr** otherwise
|
|
546
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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` (^
|
|
870
|
-
- `@mcp-abap-adt/interfaces-auth-sap` (^1.0.
|
|
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
|
|
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
|
|