@mcp-abap-adt/auth-providers 4.1.0 → 4.1.2
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 +63 -1
- package/README.md +2 -2
- package/dist/auth/clientCredentialsAuth.d.ts.map +1 -1
- package/dist/auth/clientCredentialsAuth.js +2 -1
- package/dist/auth/oauthErrorBody.d.ts +15 -0
- package/dist/auth/oauthErrorBody.d.ts.map +1 -0
- package/dist/auth/oauthErrorBody.js +58 -0
- package/dist/auth/saml2TokenExchange.d.ts.map +1 -1
- package/dist/auth/saml2TokenExchange.js +9 -2
- package/dist/auth/tokenRefresher.d.ts.map +1 -1
- package/dist/auth/tokenRefresher.js +2 -1
- package/dist/providers/BaseTokenProvider.d.ts +3 -1
- package/dist/providers/BaseTokenProvider.d.ts.map +1 -1
- package/dist/providers/BaseTokenProvider.js +4 -4
- package/dist/validation/assertionValidator.js +1 -1
- package/package.json +5 -5
- package/dist/__tests__/helpers/configHelpers.d.ts +0 -46
- package/dist/__tests__/helpers/configHelpers.d.ts.map +0 -1
- package/dist/__tests__/helpers/configHelpers.js +0 -232
- package/dist/__tests__/helpers/netHelpers.d.ts +0 -19
- package/dist/__tests__/helpers/netHelpers.d.ts.map +0 -1
- package/dist/__tests__/helpers/netHelpers.js +0 -92
- package/dist/__tests__/helpers/testLogger.d.ts +0 -7
- package/dist/__tests__/helpers/testLogger.d.ts.map +0 -1
- package/dist/__tests__/helpers/testLogger.js +0 -45
- package/dist/__tests__/integration/stand/formLogin.d.ts +0 -68
- package/dist/__tests__/integration/stand/formLogin.d.ts.map +0 -1
- package/dist/__tests__/integration/stand/formLogin.js +0 -194
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.1.2] - 2026-09-26
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **No token the provider holds reaches a log line.** Every provider logged tokens through
|
|
15
|
+
`BaseTokenProvider.formatToken`. That function returned a token of 50
|
|
16
|
+
characters or fewer whole, and a longer one's first and last 25 characters.
|
|
17
|
+
UAA and XSUAA refresh tokens are opaque and about 34 characters, so they were
|
|
18
|
+
logged in full, at `info`, by `AuthorizationCodeProvider` on creation, on
|
|
19
|
+
refresh (old and new) and by `BaseTokenProvider` on every token update. Access
|
|
20
|
+
tokens leaked 50 characters. A log line now carries only
|
|
21
|
+
`<redacted, N chars>`. Anyone who shipped logs from an earlier version at
|
|
22
|
+
`info` or `debug` should treat the refresh tokens in them as exposed, and
|
|
23
|
+
revoke them.
|
|
24
|
+
- **No token endpoint's error body reaches a log line or an error message
|
|
25
|
+
whole.** The SAML exchange and SAML refresh logged the whole error body, and
|
|
26
|
+
`refreshJwtToken` and `getTokenWithClientCredentials` serialised it into the
|
|
27
|
+
thrown `Error`'s message, which `BaseTokenProvider` then logged. Only the
|
|
28
|
+
body's `error` and `error_description` are kept now, each quoted and capped:
|
|
29
|
+
`error` at 64 characters, and `error_description` at 512, so a server's
|
|
30
|
+
diagnosis survives. Both are redacted first. Every secret the request itself
|
|
31
|
+
sent (refresh token, assertion, client secret), however short, whether it
|
|
32
|
+
comes back as sent or form- or percent-encoded, and anything shaped like a JWT
|
|
33
|
+
becomes `<redacted>`. A server that echoes one of those, in the body or in
|
|
34
|
+
its description, no longer leaks it. **Limit:** an opaque token the request
|
|
35
|
+
did not send cannot be told from an ordinary identifier, so if a server writes
|
|
36
|
+
a new one into `error_description` it passes through, capped at 512
|
|
37
|
+
characters. Scrubbing every long string would also erase the IDs that make a
|
|
38
|
+
refusal diagnosable.
|
|
39
|
+
|
|
40
|
+
## [4.1.1] - 2026-09-26
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- The `signedNode` refusal for an assertion inside a `ds:Signature` now reads
|
|
45
|
+
"…inside a ds:Signature, which is never accepted". The 4.1.0 wording, "where
|
|
46
|
+
no signature covers it", was untrue when a signed Response also covers that
|
|
47
|
+
signature. `check` and the fragment `inside a ds:Signature` are unchanged.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- The tarball no longer ships `dist/__tests__/…`. Test helpers and stand
|
|
52
|
+
fixtures were built into `dist` and published with 4.0.0 and 4.1.0. The build
|
|
53
|
+
now uses `tsconfig.build.json`, which leaves `src/__tests__` out, and the
|
|
54
|
+
tarball drops from 140 to 128 files. Nothing a consumer imports changes.
|
|
55
|
+
|
|
56
|
+
### Development
|
|
57
|
+
|
|
58
|
+
- `@mcp-abap-adt/auth-stores` devDependency `^1.2.1`, which depends on
|
|
59
|
+
`@mcp-abap-adt/interfaces-auth` `^2.0.1`. The development tree now holds a
|
|
60
|
+
single `interfaces-auth` 2.0.1 instead of a second, nested 1.2.0. Nothing
|
|
61
|
+
published changes.
|
|
62
|
+
- The live authorization tests take tokens from any session file:
|
|
63
|
+
`MCP_ABAP_ADT_SESSION_FILE`, or `session_path` in `tests/test-config.yaml`,
|
|
64
|
+
or the stores' folder `<destination_dir>/sessions/<destination>.env`. The
|
|
65
|
+
folder defaults to `~/.config/mcp-abap-adt` on Unix and
|
|
66
|
+
`Documents/mcp-abap-adt` on Windows. Browser logins run only with
|
|
67
|
+
`interactive_login: true` or `MCP_ABAP_ADT_INTERACTIVE=1`, so a default
|
|
68
|
+
`npm test` never waits for one.
|
|
69
|
+
|
|
10
70
|
## [4.1.0] - 2026-09-26
|
|
11
71
|
|
|
12
72
|
Three refusals get stricter; no export, configuration field or error class
|
|
@@ -54,7 +114,9 @@ changes. The README's *Upgrading from 4.0 to 4.1* lists what now fails.
|
|
|
54
114
|
- `idpInitiated: true` with `authnRequestId` is refused when the provider is
|
|
55
115
|
constructed — a `ValidationError` with `missingFields: ['idpInitiated']` —
|
|
56
116
|
not after `authorize()` returns, so before the user has been through the
|
|
57
|
-
browser.
|
|
117
|
+
browser. A `Saml2BearerProvider` seeded with a `refreshToken` did work in 4.0
|
|
118
|
+
until that token lapsed, since a refresh never reaches the strategy; it now
|
|
119
|
+
fails at construction.
|
|
58
120
|
- A malformed `Signature` whose `loadSignature` throws something other than
|
|
59
121
|
an `Error` is refused at `signature` quoting what was thrown. Before, the
|
|
60
122
|
refusal carried an empty message, and a thrown `null` or `undefined`
|
package/README.md
CHANGED
|
@@ -727,7 +727,7 @@ value.
|
|
|
727
727
|
| `signedNode` | `the signature does not cover the samlp:Response this validator requires` |
|
|
728
728
|
| `signedNode` | `the signature does not cover the saml:Assertion this validator requires` |
|
|
729
729
|
| `signedNode` | `the document carries an Assertion or EncryptedAssertion, SAML 2.0 or 1.x, outside the one the signature covers` |
|
|
730
|
-
| `signedNode` | `the document carries an Assertion or EncryptedAssertion inside a ds:Signature,
|
|
730
|
+
| `signedNode` | `the document carries an Assertion or EncryptedAssertion inside a ds:Signature, which is never accepted` |
|
|
731
731
|
| `status` | `the response carries no samlp:Status` |
|
|
732
732
|
| `status` | `the response carries <n> samlp:Status; exactly one is allowed` |
|
|
733
733
|
| `status` | `the samlp:Status carries no samlp:StatusCode` |
|
|
@@ -1768,7 +1768,7 @@ Example output:
|
|
|
1768
1768
|
```
|
|
1769
1769
|
|
|
1770
1770
|
**Logging Features**:
|
|
1771
|
-
- **
|
|
1771
|
+
- **No tokens in logs**: a token the provider holds or sent is never logged, not even in part. A log line carries only `<redacted, N chars>` (since 4.1.2; earlier versions logged a short refresh token whole). A token endpoint's error body contributes only `error` and `error_description`, with the request's secrets and anything shaped like a JWT redacted. A new opaque token that a server writes into `error_description` cannot be recognised and passes through, capped at 512 characters.
|
|
1772
1772
|
- **Date Formatting**: Expiration dates are displayed in readable format (YYYY-MM-DD HH:MM:SS UTC) instead of ISO format
|
|
1773
1773
|
- **Browser Information**: Logs browser type and authorization URL for debugging
|
|
1774
1774
|
- **Token Lifecycle**: Detailed logging of token acquisition, validation, and refresh operations
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clientCredentialsAuth.d.ts","sourceRoot":"","sources":["../../src/auth/clientCredentialsAuth.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"clientCredentialsAuth.d.ts","sourceRoot":"","sources":["../../src/auth/clientCredentialsAuth.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAsB,6BAA6B,CACjD,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,uBAAuB,CAAC,CAmDlC"}
|
|
@@ -11,6 +11,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.getTokenWithClientCredentials = getTokenWithClientCredentials;
|
|
13
13
|
const axios_1 = __importDefault(require("axios"));
|
|
14
|
+
const oauthErrorBody_1 = require("./oauthErrorBody");
|
|
14
15
|
/**
|
|
15
16
|
* Get access token using client_credentials grant type
|
|
16
17
|
* @param uaaUrl UAA URL (e.g., https://your-account.authentication.eu10.hana.ondemand.com)
|
|
@@ -54,7 +55,7 @@ async function getTokenWithClientCredentials(uaaUrl, clientId, clientSecret) {
|
|
|
54
55
|
'status' in error.response &&
|
|
55
56
|
'data' in error.response) {
|
|
56
57
|
const axiosError = error;
|
|
57
|
-
throw new Error(`Client credentials authentication failed (${axiosError.response.status}): ${
|
|
58
|
+
throw new Error(`Client credentials authentication failed (${axiosError.response.status}): ${(0, oauthErrorBody_1.describeOAuthErrorBody)(axiosError.response.data, [clientSecret])}`);
|
|
58
59
|
}
|
|
59
60
|
else {
|
|
60
61
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What of an OAuth error response may reach a log line or an error message.
|
|
3
|
+
*
|
|
4
|
+
* Only RFC 6749 §5.2's `error` and `error_description`, each quoted and capped.
|
|
5
|
+
* A token endpoint's body is otherwise untrusted: a misbehaving server can echo
|
|
6
|
+
* the request or return tokens in it, so it is never serialised whole.
|
|
7
|
+
* `error_description` is the server's human-readable diagnosis, so it keeps a
|
|
8
|
+
* cap long enough to stay useful (UAA explains assertion refusals in it).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @param knownSecrets what the request sent that must never come back out:
|
|
12
|
+
* the refresh token, the assertion, the client secret.
|
|
13
|
+
*/
|
|
14
|
+
export declare function describeOAuthErrorBody(data: unknown, knownSecrets?: readonly (string | undefined)[]): string;
|
|
15
|
+
//# sourceMappingURL=oauthErrorBody.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauthErrorBody.d.ts","sourceRoot":"","sources":["../../src/auth/oauthErrorBody.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAsCH;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,OAAO,EACb,YAAY,GAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAO,GACjD,MAAM,CAaR"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What of an OAuth error response may reach a log line or an error message.
|
|
4
|
+
*
|
|
5
|
+
* Only RFC 6749 §5.2's `error` and `error_description`, each quoted and capped.
|
|
6
|
+
* A token endpoint's body is otherwise untrusted: a misbehaving server can echo
|
|
7
|
+
* the request or return tokens in it, so it is never serialised whole.
|
|
8
|
+
* `error_description` is the server's human-readable diagnosis, so it keeps a
|
|
9
|
+
* cap long enough to stay useful (UAA explains assertion refusals in it).
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.describeOAuthErrorBody = describeOAuthErrorBody;
|
|
13
|
+
const ERROR_CAP = 64;
|
|
14
|
+
const DESCRIPTION_CAP = 512;
|
|
15
|
+
const quote = (value, cap) => JSON.stringify(value.length > cap ? `${value.slice(0, cap)}…` : value);
|
|
16
|
+
/** Anything shaped like a JWT: three base64url segments, the first a header. */
|
|
17
|
+
const JWT_SHAPE = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g;
|
|
18
|
+
/**
|
|
19
|
+
* Removes what a server might echo back: every secret the request itself
|
|
20
|
+
* sent (a refresh token, an assertion, a client secret), in each form it may
|
|
21
|
+
* come back in, and any JWT.
|
|
22
|
+
* Every known secret is redacted, however short: nothing guarantees a client
|
|
23
|
+
* secret is long, and dropping a matching word from a diagnosis is the lesser
|
|
24
|
+
* harm.
|
|
25
|
+
*/
|
|
26
|
+
function redact(text, secrets) {
|
|
27
|
+
let out = text;
|
|
28
|
+
for (const secret of secrets) {
|
|
29
|
+
if (!secret)
|
|
30
|
+
continue;
|
|
31
|
+
// As sent, as the request body encoded it (URLSearchParams: + / = become
|
|
32
|
+
// %2B %2F %3D), and percent-encoded: a server may echo any of these.
|
|
33
|
+
const forms = new Set([
|
|
34
|
+
secret,
|
|
35
|
+
new URLSearchParams({ s: secret }).toString().slice(2),
|
|
36
|
+
encodeURIComponent(secret),
|
|
37
|
+
]);
|
|
38
|
+
for (const form of forms)
|
|
39
|
+
out = out.split(form).join('<redacted>');
|
|
40
|
+
}
|
|
41
|
+
return out.replace(JWT_SHAPE, '<redacted jwt>');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* @param knownSecrets what the request sent that must never come back out:
|
|
45
|
+
* the refresh token, the assertion, the client secret.
|
|
46
|
+
*/
|
|
47
|
+
function describeOAuthErrorBody(data, knownSecrets = []) {
|
|
48
|
+
if (!data || typeof data !== 'object')
|
|
49
|
+
return 'no error given';
|
|
50
|
+
const { error, error_description } = data;
|
|
51
|
+
const parts = [];
|
|
52
|
+
if (typeof error === 'string')
|
|
53
|
+
parts.push(quote(redact(error, knownSecrets), ERROR_CAP));
|
|
54
|
+
if (typeof error_description === 'string') {
|
|
55
|
+
parts.push(quote(redact(error_description, knownSecrets), DESCRIPTION_CAP));
|
|
56
|
+
}
|
|
57
|
+
return parts.length > 0 ? parts.join(': ') : 'no error given';
|
|
58
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"saml2TokenExchange.d.ts","sourceRoot":"","sources":["../../src/auth/saml2TokenExchange.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"saml2TokenExchange.d.ts","sourceRoot":"","sources":["../../src/auth/saml2TokenExchange.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAI9D,MAAM,WAAW,0BAA0B;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,wBAAsB,qBAAqB,CACzC,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,MAAM,CAAC,EAAE,OAAO,GACf,OAAO,CAAC,0BAA0B,CAAC,CA2CrC;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,CAAC,EAAE,MAAM,EACrB,MAAM,CAAC,EAAE,OAAO,GACf,OAAO,CAAC,0BAA0B,CAAC,CA2CrC"}
|
|
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
9
9
|
exports.exchangeSamlAssertion = exchangeSamlAssertion;
|
|
10
10
|
exports.refreshSamlBearerToken = refreshSamlBearerToken;
|
|
11
11
|
const axios_1 = __importDefault(require("axios"));
|
|
12
|
+
const oauthErrorBody_1 = require("./oauthErrorBody");
|
|
12
13
|
function toBasicAuth(clientId, clientSecret) {
|
|
13
14
|
return Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
14
15
|
}
|
|
@@ -34,7 +35,10 @@ async function exchangeSamlAssertion(samlResponse, tokenUrl, clientId, clientSec
|
|
|
34
35
|
if (axios_1.default.isAxiosError(error)) {
|
|
35
36
|
logger?.error('[SAML] Token exchange failed', {
|
|
36
37
|
status: error.response?.status,
|
|
37
|
-
|
|
38
|
+
error: (0, oauthErrorBody_1.describeOAuthErrorBody)(error.response?.data, [
|
|
39
|
+
samlResponse,
|
|
40
|
+
clientSecret,
|
|
41
|
+
]),
|
|
38
42
|
});
|
|
39
43
|
}
|
|
40
44
|
throw error;
|
|
@@ -79,7 +83,10 @@ async function refreshSamlBearerToken(refreshToken, tokenUrl, clientId, clientSe
|
|
|
79
83
|
if (axios_1.default.isAxiosError(error)) {
|
|
80
84
|
logger?.error('[SAML] Token refresh failed', {
|
|
81
85
|
status: error.response?.status,
|
|
82
|
-
|
|
86
|
+
error: (0, oauthErrorBody_1.describeOAuthErrorBody)(error.response?.data, [
|
|
87
|
+
refreshToken,
|
|
88
|
+
clientSecret,
|
|
89
|
+
]),
|
|
83
90
|
});
|
|
84
91
|
}
|
|
85
92
|
throw error;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokenRefresher.d.ts","sourceRoot":"","sources":["../../src/auth/tokenRefresher.ts"],"names":[],"mappings":"AAAA;;GAEG;
|
|
1
|
+
{"version":3,"file":"tokenRefresher.d.ts","sourceRoot":"","sources":["../../src/auth/tokenRefresher.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,kBAAkB,CAAC,CAqD7B"}
|
|
@@ -8,6 +8,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.refreshJwtToken = refreshJwtToken;
|
|
10
10
|
const axios_1 = __importDefault(require("axios"));
|
|
11
|
+
const oauthErrorBody_1 = require("./oauthErrorBody");
|
|
11
12
|
/**
|
|
12
13
|
* Refreshes the access token using refresh token
|
|
13
14
|
* @param refreshToken Refresh token
|
|
@@ -53,7 +54,7 @@ async function refreshJwtToken(refreshToken, uaaUrl, clientId, clientSecret) {
|
|
|
53
54
|
'status' in error.response &&
|
|
54
55
|
'data' in error.response) {
|
|
55
56
|
const axiosError = error;
|
|
56
|
-
throw new Error(`Token refresh failed (${axiosError.response.status}): ${
|
|
57
|
+
throw new Error(`Token refresh failed (${axiosError.response.status}): ${(0, oauthErrorBody_1.describeOAuthErrorBody)(axiosError.response.data, [refreshToken, clientSecret])}`);
|
|
57
58
|
}
|
|
58
59
|
else {
|
|
59
60
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -31,7 +31,9 @@ export declare abstract class BaseTokenProvider implements ITokenProvider {
|
|
|
31
31
|
*/
|
|
32
32
|
protected formatExpirationDate(timestamp: number): string;
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* What a log line may say about a token: that it is there, and its length.
|
|
35
|
+
* Never any of its characters — a UAA refresh token is about 34 characters,
|
|
36
|
+
* so even "the first and last 25" is the whole secret.
|
|
35
37
|
*/
|
|
36
38
|
protected formatToken(token?: string): string | undefined;
|
|
37
39
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BaseTokenProvider.d.ts","sourceRoot":"","sources":["../../src/providers/BaseTokenProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,YAAY,EACZ,eAAe,EAChB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAE9D;;;;;;;;GAQG;AACH,8BAAsB,iBAAkB,YAAW,cAAc;IAC/D,SAAS,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACtC,SAAS,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAChC,SAAS,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC7B,SAAS,CAAC,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;IAChD,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAE3B;;;;OAIG;IACH,SAAS,CAAC,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;IAWzD
|
|
1
|
+
{"version":3,"file":"BaseTokenProvider.d.ts","sourceRoot":"","sources":["../../src/providers/BaseTokenProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,YAAY,EACZ,eAAe,EAChB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAE9D;;;;;;;;GAQG;AACH,8BAAsB,iBAAkB,YAAW,cAAc;IAC/D,SAAS,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACtC,SAAS,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAChC,SAAS,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC7B,SAAS,CAAC,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;IAChD,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAE3B;;;;OAIG;IACH,SAAS,CAAC,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;IAWzD;;;;OAIG;IACH,SAAS,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAKzD;;;OAGG;IACH,SAAS,CAAC,YAAY,IAAI,OAAO;IAyBjC;;;OAGG;IACH,SAAS,CAAC,QAAQ,CAAC,YAAY,IAAI,OAAO,CAAC,YAAY,CAAC;IAExD;;;OAGG;IACH,SAAS,CAAC,QAAQ,CAAC,cAAc,IAAI,OAAO,CAAC,YAAY,CAAC;IAE1D;;;OAGG;IACH,SAAS,CAAC,QAAQ,CAAC,WAAW,IAAI,eAAe;IAEjD;;;;;;;;OAQG;IACG,SAAS,IAAI,OAAO,CAAC,YAAY,CAAC;IAyElC,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAoC3E;;;OAGG;IACH,SAAS,CAAC,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI;IA0BlD;;;;OAIG;IACH,SAAS,CAAC,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IA0BnE;;;;OAIG;IACH,SAAS,CAAC,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;CAShE"}
|
|
@@ -41,14 +41,14 @@ class BaseTokenProvider {
|
|
|
41
41
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} UTC`;
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
44
|
+
* What a log line may say about a token: that it is there, and its length.
|
|
45
|
+
* Never any of its characters — a UAA refresh token is about 34 characters,
|
|
46
|
+
* so even "the first and last 25" is the whole secret.
|
|
45
47
|
*/
|
|
46
48
|
formatToken(token) {
|
|
47
49
|
if (!token)
|
|
48
50
|
return undefined;
|
|
49
|
-
|
|
50
|
-
return token;
|
|
51
|
-
return `${token.substring(0, 25)}...${token.substring(token.length - 25)}`;
|
|
51
|
+
return `<redacted, ${token.length} chars>`;
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
54
|
* Check if current token is valid (not expired)
|
|
@@ -173,7 +173,7 @@ function createValidator(require, options) {
|
|
|
173
173
|
// whose subtree an enveloped signature leaves unsigned.
|
|
174
174
|
const place = placeOfAssertions(doc, assertion);
|
|
175
175
|
if (place === 'inSignature') {
|
|
176
|
-
return fail('signedNode', 'the document carries an Assertion or EncryptedAssertion inside a ds:Signature,
|
|
176
|
+
return fail('signedNode', 'the document carries an Assertion or EncryptedAssertion inside a ds:Signature, which is never accepted');
|
|
177
177
|
}
|
|
178
178
|
if (place === 'outside') {
|
|
179
179
|
return fail('signedNode', 'the document carries an Assertion or EncryptedAssertion, SAML 2.0 or 1.x, outside the one the signature covers');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcp-abap-adt/auth-providers",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.2",
|
|
4
4
|
"description": "Token providers for MCP ABAP ADT auth-broker",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"chrono": "./tools/version-stats.sh",
|
|
42
|
-
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
42
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo",
|
|
43
43
|
"lint": "npx biome check --write src",
|
|
44
44
|
"lint:check": "npx biome check src",
|
|
45
45
|
"format": "npx biome format --write src",
|
|
46
|
-
"build": "npm run --silent clean && npx biome check src --diagnostic-level=error && npx tsc -p tsconfig.json",
|
|
47
|
-
"build:fast": "npx tsc -p tsconfig.json",
|
|
46
|
+
"build": "npm run --silent clean && npx biome check src --diagnostic-level=error && npx tsc -p tsconfig.build.json",
|
|
47
|
+
"build:fast": "npx tsc -p tsconfig.build.json",
|
|
48
48
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
49
49
|
"test:check": "npx tsc --noEmit",
|
|
50
50
|
"prepublishOnly": "npm run build",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@biomejs/biome": "^2.3.14",
|
|
71
71
|
"@jest/globals": "^30.2.0",
|
|
72
72
|
"@mcp-abap-adt/auth-mocks": "^0.3.0",
|
|
73
|
-
"@mcp-abap-adt/auth-stores": "^1.
|
|
73
|
+
"@mcp-abap-adt/auth-stores": "^1.2.1",
|
|
74
74
|
"@mcp-abap-adt/logger": "^0.4.0",
|
|
75
75
|
"@types/express": "^5.0.5",
|
|
76
76
|
"@types/jest": "^30.0.0",
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Configuration helpers for auth-providers tests
|
|
3
|
-
* Loads test configuration from test-config.yaml
|
|
4
|
-
*/
|
|
5
|
-
export interface TestConfig {
|
|
6
|
-
destination?: string;
|
|
7
|
-
destination_dir?: string;
|
|
8
|
-
service_key_path?: string;
|
|
9
|
-
session_path?: string;
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* Load test configuration from YAML
|
|
13
|
-
* Uses test-config.yaml from tests/ directory
|
|
14
|
-
*/
|
|
15
|
-
export declare function loadTestConfig(): TestConfig;
|
|
16
|
-
/**
|
|
17
|
-
* Check if test config has real values (not placeholders)
|
|
18
|
-
*/
|
|
19
|
-
export declare function hasRealConfigValue(config?: TestConfig): boolean;
|
|
20
|
-
/**
|
|
21
|
-
* Get destination from config
|
|
22
|
-
*/
|
|
23
|
-
export declare function getDestination(config?: TestConfig): string | null;
|
|
24
|
-
/**
|
|
25
|
-
* Get service keys directory from config
|
|
26
|
-
* Uses base_dir/service-keys or default platform path
|
|
27
|
-
*/
|
|
28
|
-
export declare function getServiceKeysDir(config?: TestConfig): string;
|
|
29
|
-
/**
|
|
30
|
-
* Get sessions directory from config
|
|
31
|
-
* Uses base_dir/sessions or default platform path
|
|
32
|
-
*/
|
|
33
|
-
export declare function getSessionsDir(config?: TestConfig): string;
|
|
34
|
-
/**
|
|
35
|
-
* Get service key file path
|
|
36
|
-
* Returns full path to service key file
|
|
37
|
-
*/
|
|
38
|
-
export declare function getServiceKeyPath(config?: TestConfig): string | null;
|
|
39
|
-
/**
|
|
40
|
-
* Get session file path
|
|
41
|
-
* Returns full path to session file
|
|
42
|
-
*/
|
|
43
|
-
export declare function getSessionPath(config?: TestConfig): string | null;
|
|
44
|
-
export declare function getAbapDestination(config?: TestConfig): string | null;
|
|
45
|
-
export declare function hasRealConfig(config?: TestConfig, _section?: 'abap' | 'xsuaa'): boolean;
|
|
46
|
-
//# sourceMappingURL=configHelpers.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"configHelpers.d.ts","sourceRoot":"","sources":["../../../src/__tests__/helpers/configHelpers.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAQH,MAAM,WAAW,UAAU;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAkBD;;;GAGG;AACH,wBAAgB,cAAc,IAAI,UAAU,CAyD3C;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAO/D;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAGjE;AA+BD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,CAa7D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,CAa1D;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAcpE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAcjE;AAGD,wBAAgB,kBAAkB,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAErE;AAED,wBAAgB,aAAa,CAC3B,MAAM,CAAC,EAAE,UAAU,EACnB,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,GAC1B,OAAO,CAET"}
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Configuration helpers for auth-providers tests
|
|
4
|
-
* Loads test configuration from test-config.yaml
|
|
5
|
-
*/
|
|
6
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
-
if (k2 === undefined) k2 = k;
|
|
8
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
-
}
|
|
12
|
-
Object.defineProperty(o, k2, desc);
|
|
13
|
-
}) : (function(o, m, k, k2) {
|
|
14
|
-
if (k2 === undefined) k2 = k;
|
|
15
|
-
o[k2] = m[k];
|
|
16
|
-
}));
|
|
17
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
-
}) : function(o, v) {
|
|
20
|
-
o["default"] = v;
|
|
21
|
-
});
|
|
22
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
-
var ownKeys = function(o) {
|
|
24
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
-
var ar = [];
|
|
26
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
-
return ar;
|
|
28
|
-
};
|
|
29
|
-
return ownKeys(o);
|
|
30
|
-
};
|
|
31
|
-
return function (mod) {
|
|
32
|
-
if (mod && mod.__esModule) return mod;
|
|
33
|
-
var result = {};
|
|
34
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
-
__setModuleDefault(result, mod);
|
|
36
|
-
return result;
|
|
37
|
-
};
|
|
38
|
-
})();
|
|
39
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
-
exports.loadTestConfig = loadTestConfig;
|
|
41
|
-
exports.hasRealConfigValue = hasRealConfigValue;
|
|
42
|
-
exports.getDestination = getDestination;
|
|
43
|
-
exports.getServiceKeysDir = getServiceKeysDir;
|
|
44
|
-
exports.getSessionsDir = getSessionsDir;
|
|
45
|
-
exports.getServiceKeyPath = getServiceKeyPath;
|
|
46
|
-
exports.getSessionPath = getSessionPath;
|
|
47
|
-
exports.getAbapDestination = getAbapDestination;
|
|
48
|
-
exports.hasRealConfig = hasRealConfig;
|
|
49
|
-
const fs = __importStar(require("node:fs"));
|
|
50
|
-
const path = __importStar(require("node:path"));
|
|
51
|
-
const yaml = __importStar(require("js-yaml"));
|
|
52
|
-
let cachedConfig = null;
|
|
53
|
-
/**
|
|
54
|
-
* Find project root directory by looking for package.json
|
|
55
|
-
*/
|
|
56
|
-
function findProjectRoot() {
|
|
57
|
-
let currentDir = __dirname;
|
|
58
|
-
while (currentDir !== path.dirname(currentDir)) {
|
|
59
|
-
const packageJsonPath = path.join(currentDir, 'package.json');
|
|
60
|
-
if (fs.existsSync(packageJsonPath)) {
|
|
61
|
-
return currentDir;
|
|
62
|
-
}
|
|
63
|
-
currentDir = path.dirname(currentDir);
|
|
64
|
-
}
|
|
65
|
-
// Fallback to process.cwd() if package.json not found
|
|
66
|
-
return process.cwd();
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Load test configuration from YAML
|
|
70
|
-
* Uses test-config.yaml from tests/ directory
|
|
71
|
-
*/
|
|
72
|
-
function loadTestConfig() {
|
|
73
|
-
if (cachedConfig) {
|
|
74
|
-
return cachedConfig;
|
|
75
|
-
}
|
|
76
|
-
// Find project root and load from tests/test-config.yaml
|
|
77
|
-
const projectRoot = findProjectRoot();
|
|
78
|
-
const configPath = path.resolve(projectRoot, 'tests', 'test-config.yaml');
|
|
79
|
-
const templatePath = path.resolve(projectRoot, 'tests', 'test-config.yaml.template');
|
|
80
|
-
if (process.env.TEST_VERBOSE) {
|
|
81
|
-
console.log(`[configHelpers] Project root: ${projectRoot}`);
|
|
82
|
-
console.log(`[configHelpers] Config path: ${configPath}`);
|
|
83
|
-
console.log(`[configHelpers] Config exists: ${fs.existsSync(configPath)}`);
|
|
84
|
-
}
|
|
85
|
-
if (fs.existsSync(configPath)) {
|
|
86
|
-
try {
|
|
87
|
-
const configContent = fs.readFileSync(configPath, 'utf8');
|
|
88
|
-
cachedConfig = yaml.load(configContent) || {};
|
|
89
|
-
if (process.env.TEST_VERBOSE) {
|
|
90
|
-
console.log(`[configHelpers] Loaded config:`, JSON.stringify(cachedConfig, null, 2));
|
|
91
|
-
}
|
|
92
|
-
return cachedConfig;
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
console.warn(`Failed to load test config from ${configPath}:`, error);
|
|
96
|
-
return {};
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (fs.existsSync(templatePath)) {
|
|
100
|
-
console.warn('⚠️ tests/test-config.yaml not found. Using template (all integration tests will be disabled).');
|
|
101
|
-
try {
|
|
102
|
-
const templateContent = fs.readFileSync(templatePath, 'utf8');
|
|
103
|
-
cachedConfig = yaml.load(templateContent) || {};
|
|
104
|
-
return cachedConfig;
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
console.warn(`Failed to load test config template from ${templatePath}:`, error);
|
|
108
|
-
return {};
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
console.warn('⚠️ Test configuration files not found.');
|
|
112
|
-
console.warn('Please create tests/test-config.yaml with test parameters.');
|
|
113
|
-
return {};
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Check if test config has real values (not placeholders)
|
|
117
|
-
*/
|
|
118
|
-
function hasRealConfigValue(config) {
|
|
119
|
-
const cfg = config || loadTestConfig();
|
|
120
|
-
if (!cfg.destination) {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
// Check if destination is not a placeholder
|
|
124
|
-
return !cfg.destination.includes('<') && !cfg.destination.includes('>');
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* Get destination from config
|
|
128
|
-
*/
|
|
129
|
-
function getDestination(config) {
|
|
130
|
-
const cfg = config || loadTestConfig();
|
|
131
|
-
return cfg.destination || null;
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Get default destination directory based on platform
|
|
135
|
-
*/
|
|
136
|
-
function getDefaultDestinationDir() {
|
|
137
|
-
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
138
|
-
if (process.platform === 'win32') {
|
|
139
|
-
return path.join(homeDir, 'Documents', 'mcp-abap-adt');
|
|
140
|
-
}
|
|
141
|
-
return path.join(homeDir, '.config', 'mcp-abap-adt');
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Get destination directory from config or use default
|
|
145
|
-
*/
|
|
146
|
-
function getDestinationDir(config) {
|
|
147
|
-
const cfg = config || loadTestConfig();
|
|
148
|
-
if (cfg.destination_dir) {
|
|
149
|
-
// Expand ~ to home directory
|
|
150
|
-
if (cfg.destination_dir.startsWith('~')) {
|
|
151
|
-
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
152
|
-
return cfg.destination_dir.replace('~', homeDir);
|
|
153
|
-
}
|
|
154
|
-
return cfg.destination_dir;
|
|
155
|
-
}
|
|
156
|
-
return getDefaultDestinationDir();
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Get service keys directory from config
|
|
160
|
-
* Uses base_dir/service-keys or default platform path
|
|
161
|
-
*/
|
|
162
|
-
function getServiceKeysDir(config) {
|
|
163
|
-
const cfg = config || loadTestConfig();
|
|
164
|
-
// If service_key_path is specified, return its directory
|
|
165
|
-
if (cfg.service_key_path) {
|
|
166
|
-
const projectRoot = findProjectRoot();
|
|
167
|
-
const fullPath = path.resolve(projectRoot, cfg.service_key_path);
|
|
168
|
-
return path.dirname(fullPath);
|
|
169
|
-
}
|
|
170
|
-
// Use destination_dir/service-keys
|
|
171
|
-
const destinationDir = getDestinationDir(cfg);
|
|
172
|
-
return path.join(destinationDir, 'service-keys');
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Get sessions directory from config
|
|
176
|
-
* Uses base_dir/sessions or default platform path
|
|
177
|
-
*/
|
|
178
|
-
function getSessionsDir(config) {
|
|
179
|
-
const cfg = config || loadTestConfig();
|
|
180
|
-
// If session_path is specified, return its directory
|
|
181
|
-
if (cfg.session_path) {
|
|
182
|
-
const projectRoot = findProjectRoot();
|
|
183
|
-
const fullPath = path.resolve(projectRoot, cfg.session_path);
|
|
184
|
-
return path.dirname(fullPath);
|
|
185
|
-
}
|
|
186
|
-
// Use destination_dir/sessions
|
|
187
|
-
const destinationDir = getDestinationDir(cfg);
|
|
188
|
-
return path.join(destinationDir, 'sessions');
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Get service key file path
|
|
192
|
-
* Returns full path to service key file
|
|
193
|
-
*/
|
|
194
|
-
function getServiceKeyPath(config) {
|
|
195
|
-
const cfg = config || loadTestConfig();
|
|
196
|
-
const destination = cfg.destination;
|
|
197
|
-
if (!destination)
|
|
198
|
-
return null;
|
|
199
|
-
// If service_key_path is specified, use it
|
|
200
|
-
if (cfg.service_key_path) {
|
|
201
|
-
const projectRoot = findProjectRoot();
|
|
202
|
-
return path.resolve(projectRoot, cfg.service_key_path);
|
|
203
|
-
}
|
|
204
|
-
// Construct from directory + destination
|
|
205
|
-
const serviceKeysDir = getServiceKeysDir(cfg);
|
|
206
|
-
return path.join(serviceKeysDir, `${destination}.json`);
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* Get session file path
|
|
210
|
-
* Returns full path to session file
|
|
211
|
-
*/
|
|
212
|
-
function getSessionPath(config) {
|
|
213
|
-
const cfg = config || loadTestConfig();
|
|
214
|
-
const destination = cfg.destination;
|
|
215
|
-
if (!destination)
|
|
216
|
-
return null;
|
|
217
|
-
// If session_path is specified, use it
|
|
218
|
-
if (cfg.session_path) {
|
|
219
|
-
const projectRoot = findProjectRoot();
|
|
220
|
-
return path.resolve(projectRoot, cfg.session_path);
|
|
221
|
-
}
|
|
222
|
-
// Construct from directory + destination
|
|
223
|
-
const sessionsDir = getSessionsDir(cfg);
|
|
224
|
-
return path.join(sessionsDir, `${destination}.env`);
|
|
225
|
-
}
|
|
226
|
-
// Legacy functions for backward compatibility
|
|
227
|
-
function getAbapDestination(config) {
|
|
228
|
-
return getDestination(config);
|
|
229
|
-
}
|
|
230
|
-
function hasRealConfig(config, _section) {
|
|
231
|
-
return hasRealConfigValue(config);
|
|
232
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export declare function getAvailablePort(): Promise<number>;
|
|
2
|
-
export declare function canListenOnLocalhost(): Promise<boolean>;
|
|
3
|
-
/**
|
|
4
|
-
* Whether a login in this test could own `port` — and therefore whether
|
|
5
|
-
* asserting that it released the port means anything.
|
|
6
|
-
*
|
|
7
|
-
* A release assertion is a claim about our own cleanup. When an unrelated
|
|
8
|
-
* process already holds the port, the login fails at the probe, never binds
|
|
9
|
-
* anything, and cannot release anything: the port is still held afterwards,
|
|
10
|
-
* the code is behaving exactly as it should, and the assertion fails anyway.
|
|
11
|
-
* That is a test reporting on what else is running on the machine.
|
|
12
|
-
*
|
|
13
|
-
* So the gate is placed *before* the login rather than the assertion being
|
|
14
|
-
* widened until it always passes — a `toBe(true)` relaxed into "free or held"
|
|
15
|
-
* would also pass when our own cleanup leaked, which is the one thing these
|
|
16
|
-
* cases exist to catch.
|
|
17
|
-
*/
|
|
18
|
-
export declare function canOwnPort(port: number, context: string): Promise<boolean>;
|
|
19
|
-
//# sourceMappingURL=netHelpers.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"netHelpers.d.ts","sourceRoot":"","sources":["../../../src/__tests__/helpers/netHelpers.ts"],"names":[],"mappings":"AAEA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC,CAcxD;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAQ7D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,OAAO,CAAC,CAclB"}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.getAvailablePort = getAvailablePort;
|
|
37
|
-
exports.canListenOnLocalhost = canListenOnLocalhost;
|
|
38
|
-
exports.canOwnPort = canOwnPort;
|
|
39
|
-
const net = __importStar(require("node:net"));
|
|
40
|
-
async function getAvailablePort() {
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
const server = net.createServer();
|
|
43
|
-
server.once('error', reject);
|
|
44
|
-
server.listen(0, '127.0.0.1', () => {
|
|
45
|
-
const address = server.address();
|
|
46
|
-
if (typeof address === 'object' && address?.port) {
|
|
47
|
-
const port = address.port;
|
|
48
|
-
server.close(() => resolve(port));
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
server.close(() => reject(new Error('Failed to acquire a port')));
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
async function canListenOnLocalhost() {
|
|
57
|
-
return new Promise((resolve) => {
|
|
58
|
-
const server = net.createServer();
|
|
59
|
-
server.once('error', () => resolve(false));
|
|
60
|
-
server.listen(0, '127.0.0.1', () => {
|
|
61
|
-
server.close(() => resolve(true));
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Whether a login in this test could own `port` — and therefore whether
|
|
67
|
-
* asserting that it released the port means anything.
|
|
68
|
-
*
|
|
69
|
-
* A release assertion is a claim about our own cleanup. When an unrelated
|
|
70
|
-
* process already holds the port, the login fails at the probe, never binds
|
|
71
|
-
* anything, and cannot release anything: the port is still held afterwards,
|
|
72
|
-
* the code is behaving exactly as it should, and the assertion fails anyway.
|
|
73
|
-
* That is a test reporting on what else is running on the machine.
|
|
74
|
-
*
|
|
75
|
-
* So the gate is placed *before* the login rather than the assertion being
|
|
76
|
-
* widened until it always passes — a `toBe(true)` relaxed into "free or held"
|
|
77
|
-
* would also pass when our own cleanup leaked, which is the one thing these
|
|
78
|
-
* cases exist to catch.
|
|
79
|
-
*/
|
|
80
|
-
async function canOwnPort(port, context) {
|
|
81
|
-
const free = await new Promise((resolve) => {
|
|
82
|
-
const probe = net.createServer();
|
|
83
|
-
probe.once('error', () => resolve(false));
|
|
84
|
-
probe.listen(port, () => probe.close(() => resolve(true)));
|
|
85
|
-
});
|
|
86
|
-
if (!free) {
|
|
87
|
-
console.warn(`⚠️ Port ${port} is held by another process — skipping the port-release ` +
|
|
88
|
-
`assertions in "${context}". A login that never bound the socket cannot ` +
|
|
89
|
-
'release it; the remaining assertions in this case still run.');
|
|
90
|
-
}
|
|
91
|
-
return free;
|
|
92
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test logger with environment variable control
|
|
3
|
-
* Uses DefaultLogger from @mcp-abap-adt/logger for proper formatting
|
|
4
|
-
*/
|
|
5
|
-
import type { ILogger } from '@mcp-abap-adt/interfaces-utils';
|
|
6
|
-
export declare function createTestLogger(prefix?: string): ILogger;
|
|
7
|
-
//# sourceMappingURL=testLogger.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"testLogger.d.ts","sourceRoot":"","sources":["../../../src/__tests__/helpers/testLogger.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAG9D,wBAAgB,gBAAgB,CAAC,MAAM,GAAE,MAAe,GAAG,OAAO,CAwCjE"}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Test logger with environment variable control
|
|
4
|
-
* Uses DefaultLogger from @mcp-abap-adt/logger for proper formatting
|
|
5
|
-
*/
|
|
6
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.createTestLogger = createTestLogger;
|
|
8
|
-
const logger_1 = require("@mcp-abap-adt/logger");
|
|
9
|
-
function createTestLogger(prefix = 'TEST') {
|
|
10
|
-
// Check if logging is enabled
|
|
11
|
-
const isEnabled = () => {
|
|
12
|
-
return (process.env.DEBUG_PROVIDER === 'true' ||
|
|
13
|
-
process.env.DEBUG_AUTH_PROVIDERS === 'true' ||
|
|
14
|
-
process.env.DEBUG_BROWSER_AUTH === 'true' ||
|
|
15
|
-
process.env.DEBUG === 'true' ||
|
|
16
|
-
process.env.DEBUG?.includes('provider') === true ||
|
|
17
|
-
process.env.DEBUG?.includes('auth-providers') === true);
|
|
18
|
-
};
|
|
19
|
-
// Create DefaultLogger with appropriate log level
|
|
20
|
-
// getLogLevel respects AUTH_LOG_LEVEL env var and defaults to INFO
|
|
21
|
-
const baseLogger = new logger_1.DefaultLogger((0, logger_1.getLogLevel)());
|
|
22
|
-
// Return wrapper that checks if logging is enabled
|
|
23
|
-
return {
|
|
24
|
-
debug: (message, meta) => {
|
|
25
|
-
if (isEnabled()) {
|
|
26
|
-
baseLogger.debug(`[${prefix}] ${message}`, meta);
|
|
27
|
-
}
|
|
28
|
-
},
|
|
29
|
-
info: (message, meta) => {
|
|
30
|
-
if (isEnabled()) {
|
|
31
|
-
baseLogger.info(`[${prefix}] ${message}`, meta);
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
warn: (message, meta) => {
|
|
35
|
-
if (isEnabled()) {
|
|
36
|
-
baseLogger.warn(`[${prefix}] ${message}`, meta);
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
|
-
error: (message, meta) => {
|
|
40
|
-
if (isEnabled()) {
|
|
41
|
-
baseLogger.error(`[${prefix}] ${message}`, meta);
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
|
@@ -1 +0,0 @@
|
|
|
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"}
|
|
@@ -1,194 +0,0 @@
|
|
|
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
|
-
}
|