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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +231 -13
  3. package/dist/__tests__/integration/stand/formLogin.d.ts +68 -0
  4. package/dist/__tests__/integration/stand/formLogin.d.ts.map +1 -0
  5. package/dist/__tests__/integration/stand/formLogin.js +194 -0
  6. package/dist/auth/callbackServer.js +2 -2
  7. package/dist/auth/passcodeAuth.d.ts +25 -0
  8. package/dist/auth/passcodeAuth.d.ts.map +1 -0
  9. package/dist/auth/passcodeAuth.js +62 -0
  10. package/dist/auth/samlBearerAssertion.d.ts +24 -0
  11. package/dist/auth/samlBearerAssertion.d.ts.map +1 -0
  12. package/dist/auth/samlBearerAssertion.js +101 -0
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -2
  16. package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
  17. package/dist/providers/Saml2BearerProvider.js +4 -1
  18. package/dist/providers/UaaPasscodeProvider.d.ts +43 -0
  19. package/dist/providers/UaaPasscodeProvider.d.ts.map +1 -0
  20. package/dist/providers/UaaPasscodeProvider.js +86 -0
  21. package/dist/providers/index.d.ts +2 -2
  22. package/dist/providers/index.d.ts.map +1 -1
  23. package/dist/providers/index.js +3 -3
  24. package/dist/strategies/index.d.ts +1 -1
  25. package/dist/strategies/index.d.ts.map +1 -1
  26. package/dist/strategies/index.js +2 -1
  27. package/dist/strategies/manualStrategies.d.ts +7 -0
  28. package/dist/strategies/manualStrategies.d.ts.map +1 -1
  29. package/dist/strategies/manualStrategies.js +21 -0
  30. package/package.json +10 -5
  31. package/bin/auth-device-flow.ts +0 -114
  32. package/dist/auth/deviceFlowAuth.d.ts +0 -43
  33. package/dist/auth/deviceFlowAuth.d.ts.map +0 -1
  34. package/dist/auth/deviceFlowAuth.js +0 -168
  35. package/dist/providers/DeviceFlowProvider.d.ts +0 -32
  36. package/dist/providers/DeviceFlowProvider.d.ts.map +0 -1
  37. package/dist/providers/DeviceFlowProvider.js +0 -86
package/CHANGELOG.md CHANGED
@@ -7,6 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.0.0] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - **`UaaPasscodeProvider`** and **`manualPasscodeStrategy`** — the one-time
15
+ passcode `cf login --sso` uses, for UAA and XSUAA. The user fetches a
16
+ Temporary Authentication Code from `<uaaUrl>/passcode` in any browser,
17
+ logging in however the identity zone asks, and the provider exchanges it
18
+ through the password grant and refreshes afterwards — a headless SSO login
19
+ that XSUAA supports, unlike the device authorization grant. A rejected code
20
+ reports UAA's reason (`Invalid passcode`). Tested on the UAA stand.
21
+
22
+ ### Breaking
23
+
24
+ - **Node.js 22 or 24** — `engines: "^22 || ^24"` (was `>=18.2.0`). The
25
+ supported versions now follow SAP BTP, Cloud Foundry, whose Node.js buildpack
26
+ offers exactly 22 and 24: Node 18 reached its end of life on 30 April 2025
27
+ and Node 20 on 30 April 2026, and SAP has removed 20 from Cloud Foundry. The
28
+ odd releases between them are excluded too — 23 reached end of life on 1 June
29
+ 2025 and 25 on 1 June 2026 — and so is 26 until SAP offers it. CI tests on 22
30
+ and 24 instead of 18.
31
+
32
+ **Migrating:** run on Node 22 or 24. Nothing in the API changed.
33
+ - **`DeviceFlowProvider` is removed**, with `DeviceFlowProviderConfig` and the
34
+ `auth-device-flow` command. It sent the device authorization grant to
35
+ `${uaaUrl}/oauth/device_authorization`, a path no server we could find
36
+ serves: Cloud Foundry UAA has no device grant, XSUAA answers that path with a
37
+ redirect to its login page and advertises no `device_authorization_endpoint`,
38
+ and servers that do implement RFC 8628 — Keycloak, Spring Authorization
39
+ Server, Ory Hydra, Zitadel, Dex — publish it elsewhere. Its live tests had
40
+ long been skipped.
41
+
42
+ **Migrating:** for a server that implements RFC 8628, use
43
+ `OidcDeviceFlowProvider` — it finds the endpoints through discovery
44
+ (`issuerUrl`) or takes them explicitly, and is tested against Keycloak. For a
45
+ headless login to UAA or XSUAA, use `UaaPasscodeProvider`: the user fetches
46
+ a one-time code from `<uaaUrl>/passcode` in any browser, as with
47
+ `cf login --sso`.
48
+
49
+ ### Fixed
50
+
51
+ - **`Saml2BearerProvider` sends what RFC 7522 accepts** (#37). It forwarded
52
+ the strategy's payload as is — after an interactive login, the whole
53
+ `SAMLResponse` in standard base64 — where §2.1 takes one Assertion,
54
+ base64url-encoded. Cloud Foundry UAA answers 401 to a Response in either
55
+ encoding, so the provider could not get a token through any interactive
56
+ strategy. It now takes the Assertion out of a Response (copying onto it
57
+ every namespace declaration it inherited, including one used only inside an
58
+ `xsi:type` value, and keeping its signature) and sends it
59
+ base64url-encoded; a bare Assertion in either encoding is re-encoded. A
60
+ Response with no Assertion, several, or only an `EncryptedAssertion` is
61
+ refused before anything is sent. `@xmldom/xmldom` becomes a dependency.
62
+
63
+ ### Development
64
+
65
+ - **Every provider but two is tested against a real authorization server** in
66
+ Docker: Cloud Foundry UAA — the server XSUAA is built from — and Keycloak.
67
+ `npm run test:stand` starts both, runs the suites and stops them, and CI runs
68
+ the same as its own job on Node 22 and 24.
69
+ - UAA: `Saml2BearerProvider` (confirming end to end what #23 was about: a
70
+ refresh token is issued with the saml2-bearer token when the client may
71
+ hold one, and spent without running the authorization strategy),
72
+ `ClientCredentialsProvider`, `AuthorizationCodeProvider` with refresh.
73
+ - Keycloak: `OidcPasswordProvider` with refresh, `OidcBrowserProvider` with
74
+ S256 PKCE, `OidcDeviceFlowProvider`, `OidcTokenExchangeProvider`
75
+ (RFC 8693).
76
+ - Keycloak as the SAML identity provider: `Saml2BearerProvider` end to end
77
+ into UAA, with no assertion built by the tests, and the identity-provider
78
+ half of `Saml2PureProvider`. This showed that UAA's bearer grant refuses
79
+ the answer to an SP-initiated login for its `InResponseTo`, and accepts an
80
+ IdP-initiated one; the README says so under `Saml2BearerProvider`.
81
+ - Interactive logins go through each server's own login and consent pages,
82
+ submitted over HTTP by a test helper.
83
+ - Not covered: `DeviceFlowProvider`, whose `/oauth/device_authorization`
84
+ neither server serves, and `Saml2PureProvider`'s cookie exchange, which is
85
+ the consumer's `cookieProvider`.
86
+
87
+ - **Live checks against a real XSUAA** — `npm run test:xsuaa`, not in CI —
88
+ create an XSUAA instance and a SAML trust in a BTP subaccount, run, and
89
+ remove them. On a trial subaccount: `Saml2BearerProvider` gets a token and a
90
+ refresh token from an IdP-initiated assertion, converts a whole
91
+ `SAMLResponse`, refreshes without its strategy, and is refused an assertion
92
+ carrying `InResponseTo`, exactly as by UAA; `UaaPasscodeProvider` works,
93
+ including — checked by hand — with an ABAP environment's own service key,
94
+ whose token opens ADT.
95
+ `@mcp-abap-adt/auth-mocks` becomes a devDependency, for signing assertions.
96
+ The stand itself is not published.
97
+
10
98
  ## [2.2.2] - 2026-09-24
11
99
 
12
100
  ### Dependencies
package/README.md CHANGED
@@ -15,8 +15,17 @@ npm install @mcp-abap-adt/auth-providers
15
15
 
16
16
  This package implements the `ITokenProvider` interface from `@mcp-abap-adt/interfaces-auth`:
17
17
 
18
- - **AuthorizationCodeProvider** - Uses browser-based OAuth2 authorization code flow (user token)
19
- - **ClientCredentialsProvider** - Uses `client_credentials` grant type (no browser required)
18
+ - **ClientCredentialsProvider** — `client_credentials`, no user interaction
19
+ - **AuthorizationCodeProvider** — UAA/XSUAA authorization code, through a browser
20
+ - **UaaPasscodeProvider** — UAA/XSUAA one-time passcode from `/passcode`, the
21
+ login `cf login --sso` uses: SSO without a browser on this machine
22
+ - **OidcBrowserProvider** — OIDC authorization code with PKCE
23
+ - **OidcDeviceFlowProvider** — OAuth 2.0 device authorization grant (RFC 8628)
24
+ - **OidcPasswordProvider**, **OidcTokenExchangeProvider** — password grant and
25
+ token exchange (RFC 8693)
26
+ - **Saml2BearerProvider** — a SAML assertion exchanged for an OAuth2 token
27
+ (RFC 7522)
28
+ - **Saml2PureProvider** — a SAML assertion exchanged for session cookies
20
29
 
21
30
  Providers are configured via constructor; `getTokens()` takes no parameters and handles refresh/login internally.
22
31
 
@@ -27,7 +36,8 @@ 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
38
  [Choosing an authorization strategy](#choosing-an-authorization-strategy) and,
30
- if you are on 1.x, [Migrating from 1.x to 2.0](#migrating-from-1x-to-20).
39
+ if you are on an earlier major, [Migrating from 2.x to 3.0](#migrating-from-2x-to-30)
40
+ and [Migrating from 1.x to 2.0](#migrating-from-1x-to-20).
31
41
 
32
42
  ## Responsibilities and Design Principles
33
43
 
@@ -378,6 +388,29 @@ const provider = new Saml2BearerProvider({
378
388
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
379
389
  ```
380
390
 
391
+ **Who starts the login matters.** An identity provider answering an
392
+ `AuthnRequest` — which is what the provider's own URL, and every shipped SAML
393
+ strategy, sends — puts `InResponseTo` on the assertion's subject
394
+ confirmation. UAA's saml2-bearer grant refuses any assertion that carries it:
395
+ there is no request on its side to match it against, and UAA's
396
+ `disableInResponseToCheck` applies to web SSO only. Measured against Cloud
397
+ Foundry UAA with Keycloak as the identity provider, an SP-initiated login is
398
+ refused with *"SubjectConfirmationData/@InResponseTo … did not match the valid
399
+ value: null"*, and an IdP-initiated one — started at the IdP, answering no
400
+ request — is accepted. Against UAA, supply the assertion from an IdP-initiated
401
+ login, for instance through `externalCodeStrategy` whose `provide` ignores the
402
+ URL it is handed. Whether XSUAA behaves the same has not been verified.
403
+
404
+ **What is sent.** The saml2-bearer grant takes one SAML Assertion,
405
+ base64url-encoded (RFC 7522 §2.1). A strategy may deliver either that or the
406
+ whole `SAMLResponse` an identity provider posts, in standard base64 —
407
+ `Saml2BearerProvider` takes the Assertion out of a Response and re-encodes it,
408
+ copying onto it every namespace declaration it inherited — including one used
409
+ only inside a value such as `xsi:type="xs:string"`. The Assertion must carry
410
+ its own signature: one over the Response alone does not survive the cut, and
411
+ the token endpoint refuses the Assertion. An `EncryptedAssertion` is refused
412
+ before anything is sent.
413
+
381
414
  **Refresh.** When the token endpoint returns a `refresh_token` with the SAML
382
415
  bearer exchange, `Saml2BearerProvider` spends it once the access token expires:
383
416
  a `refresh_token` grant to the same endpoint (`tokenUrl`, or `uaaUrl` +
@@ -538,14 +571,39 @@ const result = await provider.getTokens();
538
571
  // result.refreshToken is undefined (client_credentials doesn't provide refresh tokens)
539
572
  ```
540
573
 
541
- #### DeviceFlowProvider
574
+ #### UaaPasscodeProvider
575
+
576
+ The login `cf login --sso` uses, for UAA and XSUAA: a one-time **Temporary
577
+ Authentication Code**. Nothing opens and nothing listens on this machine — the
578
+ user opens `<uaaUrl>/passcode` in any browser, on any device, logs in however
579
+ the identity zone asks (SSO through a corporate IdP, MFA), and copies the code
580
+ shown there. The provider exchanges it for tokens and refreshes them, so the
581
+ code is asked for again only when the refresh token is gone. It suits an MCP
582
+ server on a remote machine, in a container, or behind SSH.
583
+
584
+ ```typescript
585
+ import { UaaPasscodeProvider, manualPasscodeStrategy } from '@mcp-abap-adt/auth-providers';
586
+
587
+ const provider = new UaaPasscodeProvider({
588
+ uaaUrl: 'https://<subdomain>.authentication.<region>.hana.ondemand.com',
589
+ clientId: '...', // a client allowed the `password` grant (and `refresh_token`)
590
+ clientSecret: '...', // omit for a public client
591
+ // The default: announce <uaaUrl>/passcode, read the code from the terminal.
592
+ // Supply `read` to take it from anywhere else — never from stdin under MCP.
593
+ authorization: manualPasscodeStrategy({ read: askTheUser }),
594
+ });
595
+ ```
596
+
597
+ The exchange is the password grant with `passcode` instead of a username and
598
+ password — a UAA extension, not an RFC. A code is single-use; a mistyped or
599
+ spent one fails with `Passcode exchange failed (401): Invalid passcode`.
542
600
 
543
- `DeviceFlowProviderConfig` now accepts `logger?: ILogger`. The verification URI
544
- and the user code are a prompt the user must see, not a log line: they go to the
545
- logger when one is supplied and to **stderr** otherwise. They no longer go to
546
- stdout — capturing stdout to read the device code will read nothing, and the
547
- change exists because stdout carries protocol traffic under an MCP or LSP stdio
548
- transport. `OidcDeviceFlowProvider` behaves the same way.
601
+ #### Device flow prompts
602
+
603
+ `OidcDeviceFlowProvider` accepts `logger?: ILogger`. The verification URI and
604
+ the user code are a prompt the user must see, not a log line: they go to the
605
+ logger when one is supplied and to **stderr** otherwise — never to stdout,
606
+ which carries protocol traffic under an MCP or LSP stdio transport.
549
607
 
550
608
  #### Callback port and lifetime
551
609
 
@@ -681,6 +739,39 @@ try {
681
739
 
682
740
  All error codes are defined in `@mcp-abap-adt/interfaces-auth` package as `TOKEN_PROVIDER_ERROR_CODES`.
683
741
 
742
+ ## Migrating from 2.x to 3.0
743
+
744
+ 3.0.0 changes no provider's configuration, but it drops a provider, a command
745
+ and the Node versions nothing supports any more.
746
+
747
+ - **Node.js 22 or 24.** `engines` is `"^22 || ^24"`, following SAP BTP, Cloud
748
+ Foundry. Node 18 and 20 are past their end of life and SAP has removed 20;
749
+ 23 and 25, odd releases, are too. Move the process to 22 or 24.
750
+ - **`DeviceFlowProvider` is gone**, with `DeviceFlowProviderConfig` and the
751
+ `auth-device-flow` command. It sent the device grant to
752
+ `<uaaUrl>/oauth/device_authorization`, which no server we know of serves —
753
+ neither UAA nor XSUAA offers the device grant at all. Replace it with:
754
+
755
+ ```typescript
756
+ // A server that implements RFC 8628 (Keycloak, Spring Authorization Server, …):
757
+ new OidcDeviceFlowProvider({ issuerUrl, clientId, logger });
758
+
759
+ // UAA or XSUAA — a headless SSO login, the way `cf login --sso` does it:
760
+ new UaaPasscodeProvider({
761
+ uaaUrl, clientId, clientSecret,
762
+ authorization: manualPasscodeStrategy({ read: askTheUser }),
763
+ });
764
+ ```
765
+
766
+ - **`Saml2BearerProvider` now sends one base64url Assertion** (RFC 7522),
767
+ taken out of the `SAMLResponse` a login delivers. Before, it forwarded the
768
+ whole response, which UAA and XSUAA refuse — so nothing that worked stops
769
+ working. Note what the live checks showed, though: both refuse an assertion
770
+ carrying `InResponseTo`, which an identity provider sets whenever it answers
771
+ an AuthnRequest. Against them, supply an IdP-initiated assertion — see
772
+ *Who starts the login matters* under `Saml2BearerProvider`.
773
+ - `@xmldom/xmldom` is a new runtime dependency, for that conversion.
774
+
684
775
  ## Migrating from 1.x to 2.0
685
776
 
686
777
  Every field that described *how* an interactive login is conducted is gone from
@@ -770,9 +861,11 @@ Three more changes that are not fields:
770
861
  an explicit choice; otherwise the paste form on `/` is the remaining fallback
771
862
  for a browser on another machine.
772
863
  - **Device flow prompts no longer go to stdout.** `DeviceFlowProviderConfig`
773
- accepts `logger?: ILogger`; the verification URI and user code go to that
864
+ accepted `logger?: ILogger`; the verification URI and user code go to that
774
865
  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.
866
+ the device code must read stderr or supply a logger. (`DeviceFlowProvider`
867
+ itself was removed in 3.0.0 — see the changelog; `OidcDeviceFlowProvider`
868
+ behaves the same way.)
776
869
  - **A `/callback` carrying neither a code nor an error no longer ends the
777
870
  login.** It is answered and counted, and the tally appears in the timeout
778
871
  message if the login later expires.
@@ -820,6 +913,127 @@ Integration tests will skip if `test-config.yaml` is not configured or contains
820
913
  - The interactive test asks the OS for a free port rather than pinning one, so it cannot collide with a running server
821
914
  - Tests use `browserCallbackStrategy({ browser: 'system' })` for interactive authentication (not `'none'`)
822
915
 
916
+ ### Providers against real authorization servers (UAA and Keycloak)
917
+
918
+ The providers are also tested against two real, widely used authorization
919
+ servers running locally in Docker from their official images — [Cloud Foundry
920
+ UAA](https://github.com/cloudfoundry/uaa) (`cfidentity/uaa`), the open-source
921
+ server XSUAA is built from, and [Keycloak](https://www.keycloak.org/)
922
+ (`quay.io/keycloak/keycloak`). It needs Docker and nothing else — no SAP
923
+ system, no setup step:
924
+
925
+ ```bash
926
+ npm run test:stand # start UAA and Keycloak, run the suites, stop both
927
+ ```
928
+
929
+ `test:stand` starts both containers with `docker compose`, waits until both
930
+ answer, runs the suites, and stops the containers again — also when a test
931
+ fails, with the suites' exit code, after printing the last 200 lines of each
932
+ server's log. A full run takes well under a minute. CI runs exactly this as its
933
+ own job, on Node 22 and 24. To keep the stand up between runs, start it
934
+ yourself; `test:stand` then leaves it running. Ownership is per server: if only
935
+ one of the two was running, the run starts the other and removes only that one
936
+ afterwards:
937
+
938
+ ```bash
939
+ npm run stand:up # start and keep running
940
+ npm run test:stand # as often as needed
941
+ npm run stand:down # stop
942
+ ```
943
+
944
+ `STAND_KEEP=1 npm run test:stand` keeps a stand the run started. `UAA_PORT`
945
+ (8080) and `KEYCLOAK_PORT` (8081) move the servers. A server that is already
946
+ running is never changed: if it is published on another port than the one
947
+ asked for, `test:stand` refuses and says so, rather than let Compose recreate
948
+ it.
949
+
950
+ | provider | server | what the suite proves |
951
+ |---|---|---|
952
+ | `Saml2BearerProvider` | UAA | a bearer assertion — and a whole `SAMLResponse` — is exchanged for a token; UAA issues a refresh token exactly when the client may hold one, and the provider refreshes without its authorization strategy |
953
+ | `Saml2BearerProvider` | Keycloak → UAA | end to end with no assertion built by the tests: an IdP-initiated Keycloak login becomes a UAA token; the answer to the provider's own AuthnRequest is refused for its `InResponseTo` |
954
+ | `Saml2PureProvider` | Keycloak | the identity-provider half: Keycloak accepts the provider's AuthnRequest and posts a signed response for that service provider to the ACS it named, which reaches `cookieProvider` unchanged |
955
+ | `ClientCredentialsProvider` | UAA | a client token |
956
+ | `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
+ | `AuthorizationCodeProvider` | UAA | a login through UAA's own form, and a refresh without logging in again |
958
+ | `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 |
959
+ | `OidcBrowserProvider` | Keycloak | authorization code with S256 PKCE, which the client requires, through Keycloak's login page |
960
+ | `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 |
961
+ | `OidcTokenExchangeProvider` | Keycloak | RFC 8693: another client's access token exchanged for the requester's own |
962
+
963
+ The servers' configuration is committed as test fixtures —
964
+ `tests/stand/uaa/config/uaa.yml`, `tests/stand/keycloak/realm-test.json` and the
965
+ test identity provider's key in `tests/stand/uaa/idp/` — so every machine and CI
966
+ run the same stand. The keys and passwords in them are trusted by nothing but
967
+ that local stand; they are not secrets, and must not be reused.
968
+
969
+ For the Keycloak → UAA case the suite makes UAA trust Keycloak at run time —
970
+ it registers Keycloak as a SAML identity provider through UAA's API, from the
971
+ metadata Keycloak publishes — and configures Keycloak's IdP-initiated SSO to
972
+ post to UAA's bearer ACS, since both depend on the ports and on keys Keycloak
973
+ generates when it starts.
974
+
975
+ Interactive logins are played by `src/__tests__/integration/stand/formLogin.ts`,
976
+ which submits each server's own login and consent forms over HTTP.
977
+
978
+ Not covered: the cookie half of `Saml2PureProvider`, which belongs to the
979
+ consumer's `cookieProvider` and needs a real SAP system.
980
+
981
+ A plain `npm test` skips these suites: they run only with `UAA_URL` or
982
+ `KEYCLOAK_URL` set, which `test:stand` does.
983
+
984
+ ### Live checks against XSUAA (BTP subaccount)
985
+
986
+ The stand proves the wire contracts against open-source servers. What only a
987
+ real XSUAA can answer is checked by `npm run test:xsuaa`, against a BTP
988
+ subaccount you are logged in to — a trial one is enough:
989
+
990
+ ```bash
991
+ cf login -a https://api.cf.<region>.hana.ondemand.com --sso -o <org> -s <space>
992
+ XSUAA_CF_API=https://api.cf.<region>.hana.ondemand.com XSUAA_CF_ORG=<org> \
993
+ XSUAA_CF_SPACE=<space> npm run test:xsuaa
994
+ ```
995
+
996
+ It creates, in the targeted space, an `xsuaa`/`application` instance whose
997
+ client may use saml2-bearer, refresh_token and password; an `xsuaa`/`apiaccess`
998
+ instance, used only to manage trust; and a SAML trust to a test identity
999
+ provider whose key is generated locally and never leaves the gitignored
1000
+ `tests/xsuaa/.local/`. Then it runs the suite and removes all of it — also when
1001
+ a test fails. Two rules keep it from touching anything else:
1002
+
1003
+ - **Target.** The scripts refuse to run unless `cf` targets exactly
1004
+ `XSUAA_CF_API`, `XSUAA_CF_ORG` and `XSUAA_CF_SPACE`. There are no defaults, so
1005
+ a `cf` left pointing at another org or space cannot receive anything.
1006
+ - **Ownership.** Everything setup creates is recorded in
1007
+ `tests/xsuaa/.local/owned` with its immutable ID — the service instance's
1008
+ GUID, the trust's id — and the record names the API, org and space it
1009
+ belongs to. A resource is treated as ours only when its name and its current
1010
+ ID both match a record, checked right before it is reused, refreshed or
1011
+ deleted. A name held by anything else — never created here, or recreated
1012
+ after ours was deleted — is refused by setup and left alone by teardown, and
1013
+ a record from another target is refused outright.
1014
+
1015
+ The run fails — non-zero — when the tests fail or the teardown does. A
1016
+ teardown that fails stops at once and keeps `tests/xsuaa/.local/`, keys and
1017
+ record included, so `tests/xsuaa/teardown.sh` can be run again to finish. A
1018
+ lookup that fails — no session, no network, an API error — is a failure, never
1019
+ read as "already gone": `cf service` exits 1 for both, so only its exact
1020
+ not-found message counts as absence.
1021
+ `XSUAA_KEEP=1` keeps the environment for another run. A full run takes about a minute and
1022
+ a half. It is not part of CI.
1023
+
1024
+ | check | result on XSUAA |
1025
+ |---|---|
1026
+ | `Saml2BearerProvider`, assertion without `InResponseTo` (IdP-initiated) | token and refresh token |
1027
+ | `Saml2BearerProvider`, a whole `SAMLResponse` | converted by the provider, accepted |
1028
+ | `Saml2BearerProvider`, refresh | never reaches the strategy |
1029
+ | `Saml2BearerProvider`, assertion with `InResponseTo` | refused — as UAA does |
1030
+ | `UaaPasscodeProvider` (with `XSUAA_PASSCODE=<code from /passcode>`) | token, refresh |
1031
+
1032
+ `UaaPasscodeProvider` was also checked by hand with an ABAP environment's own
1033
+ service key: its client accepts the passcode, and the token opens ADT. That
1034
+ depends on the user being known to the ABAP system — a user from an identity
1035
+ provider not propagated to it gets a token and a 401 from ADT.
1036
+
823
1037
  ### Debug Logging
824
1038
 
825
1039
  To enable detailed logging during tests or runtime, set environment variables:
@@ -869,11 +1083,15 @@ Example output:
869
1083
  - `@mcp-abap-adt/interfaces-auth` (^1.2.0) - Token provider and authorization contracts (`ITokenProvider`, `IAuthorizationStrategy`, `CallbackServerFactory`) and error code constants
870
1084
  - `@mcp-abap-adt/interfaces-auth-sap` (^1.0.0) - XSUAA authorization configuration (`IAuthorizationConfig`)
871
1085
  - `@mcp-abap-adt/interfaces-utils` (^1.1.0) - `ILogger`
1086
+ - `@xmldom/xmldom` - XML parsing, to take the Assertion out of a SAMLResponse for the saml2-bearer grant
872
1087
  - `axios` - HTTP client
873
1088
  - `express` - OAuth2 callback server
874
1089
  - `open` - Browser opening utility
875
1090
 
876
- Requires Node.js `>=18.2.0`.
1091
+ Requires Node.js 22 or 24 (`engines: "^22 || ^24"`). The supported versions
1092
+ follow SAP BTP, Cloud Foundry, whose Node.js buildpack offers exactly these two;
1093
+ CI tests both. Odd-numbered releases are never supported — they reach end of
1094
+ life within months — and a new major joins only once SAP offers it.
877
1095
 
878
1096
  ## License
879
1097
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Plays the user in an interactive login against a stand server's own login
3
+ * page: follows redirects, keeps cookies, finds the form with a password
4
+ * field, fills it in with every hidden field it carries (UAA's CSRF token,
5
+ * Keycloak's session code), and submits it.
6
+ *
7
+ * Deliberately small: enough for UAA's and Keycloak's stock login pages, which
8
+ * the pinned image versions keep stable. It is a test helper, not a browser.
9
+ */
10
+ export interface Credentials {
11
+ username: string;
12
+ password: string;
13
+ }
14
+ export declare class FormBrowser {
15
+ private readonly cookies;
16
+ /** GET `url` and follow redirects until `stop(url)` or a page is served. */
17
+ open(url: string, stop?: (next: string) => boolean): Promise<{
18
+ url: string;
19
+ html?: string;
20
+ }>;
21
+ /**
22
+ * Submit the login form on `page`, then follow redirects until `stop` says
23
+ * the next location is the one the caller wants — typically the client's
24
+ * redirect URI carrying the code — without requesting it.
25
+ */
26
+ submitLogin(page: {
27
+ url: string;
28
+ html?: string;
29
+ }, credentials: Credentials, stop?: (next: string) => boolean): Promise<{
30
+ url: string;
31
+ html?: string;
32
+ }>;
33
+ /**
34
+ * Accept a consent page ("Do you grant these access privileges?"): submit
35
+ * the form that carries an `accept` button, with its hidden fields.
36
+ */
37
+ acceptConsent(page: {
38
+ url: string;
39
+ html?: string;
40
+ }): Promise<{
41
+ url: string;
42
+ html?: string;
43
+ }>;
44
+ private follow;
45
+ private remember;
46
+ private cookieHeader;
47
+ }
48
+ /**
49
+ * Log in through the authorization URL and return the redirect URI the server
50
+ * sends the browser back to, with its `code` — not requested, since nothing
51
+ * listens there.
52
+ */
53
+ export declare function authorizeByForm(authorizationUrl: string, redirectUri: string, credentials: Credentials): Promise<URL>;
54
+ /**
55
+ * Approve a device authorization the way a user would: open the verification
56
+ * URI (with the user code already in it), log in, and grant access.
57
+ */
58
+ export declare function approveDevice(verificationUriComplete: string, credentials: Credentials): Promise<void>;
59
+ /**
60
+ * A SAML login at an identity provider: open the AuthnRequest URL, log in,
61
+ * and take the SAMLResponse from the auto-posting form the IdP answers with —
62
+ * what a browser would post to the assertion consumer service.
63
+ */
64
+ export declare function samlResponseByForm(authnRequestUrl: string, credentials: Credentials): Promise<{
65
+ samlResponse: string;
66
+ acsUrl: string;
67
+ }>;
68
+ //# sourceMappingURL=formLogin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formLogin.d.ts","sourceRoot":"","sources":["../../../../src/__tests__/integration/stand/formLogin.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAID,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IAErD,4EAA4E;IACtE,IAAI,CACR,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAqB,GAC5C,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAI1C;;;;OAIG;IACG,WAAW,CACf,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EACpC,WAAW,EAAE,WAAW,EACxB,IAAI,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAqB,GAC5C,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAmB1C;;;OAGG;IACG,aAAa,CAAC,IAAI,EAAE;QACxB,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;YAgC7B,MAAM;IA4BpB,OAAO,CAAC,QAAQ;IAUhB,OAAO,CAAC,YAAY;CAGrB;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,GAAG,CAAC,CAcd;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,uBAAuB,EAAE,MAAM,EAC/B,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,IAAI,CAAC,CAKf;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,eAAe,EAAE,MAAM,EACvB,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAgBnD"}
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ /**
3
+ * Plays the user in an interactive login against a stand server's own login
4
+ * page: follows redirects, keeps cookies, finds the form with a password
5
+ * field, fills it in with every hidden field it carries (UAA's CSRF token,
6
+ * Keycloak's session code), and submits it.
7
+ *
8
+ * Deliberately small: enough for UAA's and Keycloak's stock login pages, which
9
+ * the pinned image versions keep stable. It is a test helper, not a browser.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.FormBrowser = void 0;
13
+ exports.authorizeByForm = authorizeByForm;
14
+ exports.approveDevice = approveDevice;
15
+ exports.samlResponseByForm = samlResponseByForm;
16
+ const MAX_HOPS = 20;
17
+ class FormBrowser {
18
+ cookies = new Map();
19
+ /** GET `url` and follow redirects until `stop(url)` or a page is served. */
20
+ async open(url, stop = () => false) {
21
+ return this.follow(url, { method: 'GET' }, stop);
22
+ }
23
+ /**
24
+ * Submit the login form on `page`, then follow redirects until `stop` says
25
+ * the next location is the one the caller wants — typically the client's
26
+ * redirect URI carrying the code — without requesting it.
27
+ */
28
+ async submitLogin(page, credentials, stop = () => false) {
29
+ const form = findPasswordForm(page.html ?? '');
30
+ if (!form) {
31
+ throw new Error(`no login form on ${page.url}`);
32
+ }
33
+ const body = new URLSearchParams(form.hidden);
34
+ body.set(form.userField, credentials.username);
35
+ body.set(form.passwordField, credentials.password);
36
+ return this.follow(new URL(form.action, page.url).toString(), {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
39
+ body: body.toString(),
40
+ }, stop);
41
+ }
42
+ /**
43
+ * Accept a consent page ("Do you grant these access privileges?"): submit
44
+ * the form that carries an `accept` button, with its hidden fields.
45
+ */
46
+ async acceptConsent(page) {
47
+ for (const match of (page.html ?? '').matchAll(/<form\b[^>]*>[\s\S]*?<\/form>/gi)) {
48
+ const formHtml = match[0];
49
+ const inputs = [...formHtml.matchAll(/<(?:input|button)\b[^>]*>/gi)].map((m) => m[0]);
50
+ const accept = inputs.find((i) => attribute(i, 'name') === 'accept');
51
+ if (!accept)
52
+ continue;
53
+ const body = new URLSearchParams();
54
+ for (const input of inputs) {
55
+ const name = attribute(input, 'name');
56
+ if (name && attribute(input, 'type') === 'hidden') {
57
+ body.set(name, attribute(input, 'value') ?? '');
58
+ }
59
+ }
60
+ body.set('accept', attribute(accept, 'value') ?? 'Yes');
61
+ const formTag = /<form\b[^>]*>/i.exec(formHtml)?.[0] ?? '';
62
+ return this.follow(new URL(attribute(formTag, 'action') ?? '', page.url).toString(), {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
65
+ body: body.toString(),
66
+ }, () => false);
67
+ }
68
+ throw new Error(`no consent form on ${page.url}`);
69
+ }
70
+ async follow(url, init, stop) {
71
+ let current = url;
72
+ let request = init;
73
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
74
+ const response = await fetch(current, {
75
+ ...request,
76
+ redirect: 'manual',
77
+ headers: { ...(request.headers ?? {}), Cookie: this.cookieHeader() },
78
+ signal: AbortSignal.timeout(15_000),
79
+ });
80
+ this.remember(response);
81
+ const location = response.headers.get('location');
82
+ if (response.status >= 300 && response.status < 400 && location) {
83
+ const next = new URL(location, current).toString();
84
+ if (stop(next))
85
+ return { url: next };
86
+ current = next;
87
+ request = { method: 'GET' };
88
+ continue;
89
+ }
90
+ return { url: current, html: await response.text() };
91
+ }
92
+ throw new Error(`more than ${MAX_HOPS} redirects from ${url}`);
93
+ }
94
+ remember(response) {
95
+ for (const line of response.headers.getSetCookie()) {
96
+ const [pair] = line.split(';');
97
+ const eq = pair.indexOf('=');
98
+ if (eq > 0) {
99
+ this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
100
+ }
101
+ }
102
+ }
103
+ cookieHeader() {
104
+ return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ');
105
+ }
106
+ }
107
+ exports.FormBrowser = FormBrowser;
108
+ /**
109
+ * Log in through the authorization URL and return the redirect URI the server
110
+ * sends the browser back to, with its `code` — not requested, since nothing
111
+ * listens there.
112
+ */
113
+ async function authorizeByForm(authorizationUrl, redirectUri, credentials) {
114
+ const reached = (next) => next.startsWith(redirectUri);
115
+ const browser = new FormBrowser();
116
+ const page = await browser.open(authorizationUrl, reached);
117
+ const done = page.html === undefined
118
+ ? page
119
+ : await browser.submitLogin(page, credentials, reached);
120
+ if (!reached(done.url)) {
121
+ throw new Error(`login did not return to ${redirectUri}; ended at ${done.url}`);
122
+ }
123
+ return new URL(done.url);
124
+ }
125
+ /**
126
+ * Approve a device authorization the way a user would: open the verification
127
+ * URI (with the user code already in it), log in, and grant access.
128
+ */
129
+ async function approveDevice(verificationUriComplete, credentials) {
130
+ const browser = new FormBrowser();
131
+ const page = await browser.open(verificationUriComplete);
132
+ const consent = await browser.submitLogin(page, credentials);
133
+ await browser.acceptConsent(consent);
134
+ }
135
+ /**
136
+ * A SAML login at an identity provider: open the AuthnRequest URL, log in,
137
+ * and take the SAMLResponse from the auto-posting form the IdP answers with —
138
+ * what a browser would post to the assertion consumer service.
139
+ */
140
+ async function samlResponseByForm(authnRequestUrl, credentials) {
141
+ const browser = new FormBrowser();
142
+ const page = await browser.submitLogin(await browser.open(authnRequestUrl), credentials);
143
+ const html = page.html ?? '';
144
+ const input = [...html.matchAll(/<input\b[^>]*>/gi)]
145
+ .map((m) => m[0])
146
+ .find((i) => attribute(i, 'name') === 'SAMLResponse');
147
+ const samlResponse = input ? attribute(input, 'value') : undefined;
148
+ const formTag = /<form\b[^>]*>/i.exec(html)?.[0] ?? '';
149
+ if (!samlResponse) {
150
+ throw new Error(`no SAMLResponse form on ${page.url}`);
151
+ }
152
+ return { samlResponse, acsUrl: attribute(formTag, 'action') ?? '' };
153
+ }
154
+ const decode = (value) => value
155
+ .replace(/&amp;/g, '&')
156
+ .replace(/&quot;/g, '"')
157
+ .replace(/&#39;/g, "'")
158
+ .replace(/&lt;/g, '<')
159
+ .replace(/&gt;/g, '>');
160
+ const attribute = (tag, name) => {
161
+ const match = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i').exec(tag);
162
+ return match ? decode(match[2] ?? match[3] ?? '') : undefined;
163
+ };
164
+ function findPasswordForm(html) {
165
+ for (const match of html.matchAll(/<form\b[^>]*>[\s\S]*?<\/form>/gi)) {
166
+ const formHtml = match[0];
167
+ const inputs = [...formHtml.matchAll(/<input\b[^>]*>/gi)].map((m) => m[0]);
168
+ const password = inputs.find((i) => attribute(i, 'type') === 'password');
169
+ if (!password)
170
+ continue;
171
+ const hidden = {};
172
+ let userField = 'username';
173
+ for (const input of inputs) {
174
+ const type = (attribute(input, 'type') ?? 'text').toLowerCase();
175
+ const name = attribute(input, 'name');
176
+ if (!name)
177
+ continue;
178
+ if (type === 'hidden')
179
+ hidden[name] = attribute(input, 'value') ?? '';
180
+ if ((type === 'text' || type === 'email') &&
181
+ /user|email|login/i.test(name)) {
182
+ userField = name;
183
+ }
184
+ }
185
+ const formTag = /<form\b[^>]*>/i.exec(formHtml)?.[0] ?? '';
186
+ return {
187
+ action: attribute(formTag, 'action') ?? '',
188
+ userField,
189
+ passwordField: attribute(password, 'name') ?? 'password',
190
+ hidden,
191
+ };
192
+ }
193
+ return undefined;
194
+ }