@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.
- package/CHANGELOG.md +88 -0
- package/README.md +231 -13
- 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/samlBearerAssertion.d.ts +24 -0
- package/dist/auth/samlBearerAssertion.d.ts.map +1 -0
- package/dist/auth/samlBearerAssertion.js +101 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
- package/dist/providers/Saml2BearerProvider.js +4 -1
- 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/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/package.json +10 -5
- 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/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
|
-
- **
|
|
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
|
|
|
@@ -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
|
|
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
|
-
####
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
logger
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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
|
|
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(/&/g, '&')
|
|
156
|
+
.replace(/"/g, '"')
|
|
157
|
+
.replace(/'/g, "'")
|
|
158
|
+
.replace(/</g, '<')
|
|
159
|
+
.replace(/>/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
|
+
}
|