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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +825 -64
  3. package/dist/__tests__/integration/stand/formLogin.d.ts +68 -0
  4. package/dist/__tests__/integration/stand/formLogin.d.ts.map +1 -0
  5. package/dist/__tests__/integration/stand/formLogin.js +194 -0
  6. package/dist/auth/callbackServer.js +2 -2
  7. package/dist/auth/passcodeAuth.d.ts +25 -0
  8. package/dist/auth/passcodeAuth.d.ts.map +1 -0
  9. package/dist/auth/passcodeAuth.js +62 -0
  10. package/dist/auth/saml2Auth.d.ts +6 -2
  11. package/dist/auth/saml2Auth.d.ts.map +1 -1
  12. package/dist/auth/saml2Auth.js +9 -20
  13. package/dist/auth/samlBearerAssertion.d.ts +24 -0
  14. package/dist/auth/samlBearerAssertion.d.ts.map +1 -0
  15. package/dist/auth/samlBearerAssertion.js +102 -0
  16. package/dist/auth/strictXml.d.ts +13 -0
  17. package/dist/auth/strictXml.d.ts.map +1 -0
  18. package/dist/auth/strictXml.js +21 -0
  19. package/dist/errors/AssertionValidationError.d.ts +15 -0
  20. package/dist/errors/AssertionValidationError.d.ts.map +1 -0
  21. package/dist/errors/AssertionValidationError.js +24 -0
  22. package/dist/errors/TokenProviderErrors.d.ts +2 -0
  23. package/dist/errors/TokenProviderErrors.d.ts.map +1 -1
  24. package/dist/errors/TokenProviderErrors.js +3 -1
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +13 -2
  28. package/dist/providers/Saml2BearerProvider.d.ts +1 -0
  29. package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
  30. package/dist/providers/Saml2BearerProvider.js +20 -2
  31. package/dist/providers/Saml2PureProvider.d.ts +1 -0
  32. package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
  33. package/dist/providers/Saml2PureProvider.js +19 -5
  34. package/dist/providers/UaaPasscodeProvider.d.ts +43 -0
  35. package/dist/providers/UaaPasscodeProvider.d.ts.map +1 -0
  36. package/dist/providers/UaaPasscodeProvider.js +86 -0
  37. package/dist/providers/index.d.ts +2 -2
  38. package/dist/providers/index.d.ts.map +1 -1
  39. package/dist/providers/index.js +3 -3
  40. package/dist/providers/saml2Utils.d.ts +49 -2
  41. package/dist/providers/saml2Utils.d.ts.map +1 -1
  42. package/dist/providers/saml2Utils.js +94 -2
  43. package/dist/strategies/index.d.ts +1 -1
  44. package/dist/strategies/index.d.ts.map +1 -1
  45. package/dist/strategies/index.js +2 -1
  46. package/dist/strategies/manualStrategies.d.ts +7 -0
  47. package/dist/strategies/manualStrategies.d.ts.map +1 -1
  48. package/dist/strategies/manualStrategies.js +21 -0
  49. package/dist/validation/assertionValidator.d.ts +28 -0
  50. package/dist/validation/assertionValidator.d.ts.map +1 -0
  51. package/dist/validation/assertionValidator.js +444 -0
  52. package/dist/validation/documentIds.d.ts +15 -0
  53. package/dist/validation/documentIds.d.ts.map +1 -0
  54. package/dist/validation/documentIds.js +32 -0
  55. package/dist/validation/inMemoryReplayStore.d.ts +22 -0
  56. package/dist/validation/inMemoryReplayStore.d.ts.map +1 -0
  57. package/dist/validation/inMemoryReplayStore.js +49 -0
  58. package/dist/validation/signedNode.d.ts +54 -0
  59. package/dist/validation/signedNode.d.ts.map +1 -0
  60. package/dist/validation/signedNode.js +171 -0
  61. package/dist/validation/xsdDateTime.d.ts +17 -0
  62. package/dist/validation/xsdDateTime.d.ts.map +1 -0
  63. package/dist/validation/xsdDateTime.js +67 -0
  64. package/package.json +14 -8
  65. package/bin/auth-device-flow.ts +0 -114
  66. package/dist/auth/deviceFlowAuth.d.ts +0 -43
  67. package/dist/auth/deviceFlowAuth.d.ts.map +0 -1
  68. package/dist/auth/deviceFlowAuth.js +0 -168
  69. package/dist/providers/DeviceFlowProvider.d.ts +0 -32
  70. package/dist/providers/DeviceFlowProvider.d.ts.map +0 -1
  71. package/dist/providers/DeviceFlowProvider.js +0 -86
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Plays the user in an interactive login against a stand server's own login
3
+ * page: follows redirects, keeps cookies, finds the form with a password
4
+ * field, fills it in with every hidden field it carries (UAA's CSRF token,
5
+ * Keycloak's session code), and submits it.
6
+ *
7
+ * Deliberately small: enough for UAA's and Keycloak's stock login pages, which
8
+ * the pinned image versions keep stable. It is a test helper, not a browser.
9
+ */
10
+ export interface Credentials {
11
+ username: string;
12
+ password: string;
13
+ }
14
+ export declare class FormBrowser {
15
+ private readonly cookies;
16
+ /** GET `url` and follow redirects until `stop(url)` or a page is served. */
17
+ open(url: string, stop?: (next: string) => boolean): Promise<{
18
+ url: string;
19
+ html?: string;
20
+ }>;
21
+ /**
22
+ * Submit the login form on `page`, then follow redirects until `stop` says
23
+ * the next location is the one the caller wants — typically the client's
24
+ * redirect URI carrying the code — without requesting it.
25
+ */
26
+ submitLogin(page: {
27
+ url: string;
28
+ html?: string;
29
+ }, credentials: Credentials, stop?: (next: string) => boolean): Promise<{
30
+ url: string;
31
+ html?: string;
32
+ }>;
33
+ /**
34
+ * Accept a consent page ("Do you grant these access privileges?"): submit
35
+ * the form that carries an `accept` button, with its hidden fields.
36
+ */
37
+ acceptConsent(page: {
38
+ url: string;
39
+ html?: string;
40
+ }): Promise<{
41
+ url: string;
42
+ html?: string;
43
+ }>;
44
+ private follow;
45
+ private remember;
46
+ private cookieHeader;
47
+ }
48
+ /**
49
+ * Log in through the authorization URL and return the redirect URI the server
50
+ * sends the browser back to, with its `code` — not requested, since nothing
51
+ * listens there.
52
+ */
53
+ export declare function authorizeByForm(authorizationUrl: string, redirectUri: string, credentials: Credentials): Promise<URL>;
54
+ /**
55
+ * Approve a device authorization the way a user would: open the verification
56
+ * URI (with the user code already in it), log in, and grant access.
57
+ */
58
+ export declare function approveDevice(verificationUriComplete: string, credentials: Credentials): Promise<void>;
59
+ /**
60
+ * A SAML login at an identity provider: open the AuthnRequest URL, log in,
61
+ * and take the SAMLResponse from the auto-posting form the IdP answers with —
62
+ * what a browser would post to the assertion consumer service.
63
+ */
64
+ export declare function samlResponseByForm(authnRequestUrl: string, credentials: Credentials): Promise<{
65
+ samlResponse: string;
66
+ acsUrl: string;
67
+ }>;
68
+ //# sourceMappingURL=formLogin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formLogin.d.ts","sourceRoot":"","sources":["../../../../src/__tests__/integration/stand/formLogin.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAID,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IAErD,4EAA4E;IACtE,IAAI,CACR,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAqB,GAC5C,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAI1C;;;;OAIG;IACG,WAAW,CACf,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EACpC,WAAW,EAAE,WAAW,EACxB,IAAI,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAqB,GAC5C,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAmB1C;;;OAGG;IACG,aAAa,CAAC,IAAI,EAAE;QACxB,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;YAgC7B,MAAM;IA4BpB,OAAO,CAAC,QAAQ;IAUhB,OAAO,CAAC,YAAY;CAGrB;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,GAAG,CAAC,CAcd;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,uBAAuB,EAAE,MAAM,EAC/B,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,IAAI,CAAC,CAKf;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,eAAe,EAAE,MAAM,EACvB,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAgBnD"}
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ /**
3
+ * Plays the user in an interactive login against a stand server's own login
4
+ * page: follows redirects, keeps cookies, finds the form with a password
5
+ * field, fills it in with every hidden field it carries (UAA's CSRF token,
6
+ * Keycloak's session code), and submits it.
7
+ *
8
+ * Deliberately small: enough for UAA's and Keycloak's stock login pages, which
9
+ * the pinned image versions keep stable. It is a test helper, not a browser.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.FormBrowser = void 0;
13
+ exports.authorizeByForm = authorizeByForm;
14
+ exports.approveDevice = approveDevice;
15
+ exports.samlResponseByForm = samlResponseByForm;
16
+ const MAX_HOPS = 20;
17
+ class FormBrowser {
18
+ cookies = new Map();
19
+ /** GET `url` and follow redirects until `stop(url)` or a page is served. */
20
+ async open(url, stop = () => false) {
21
+ return this.follow(url, { method: 'GET' }, stop);
22
+ }
23
+ /**
24
+ * Submit the login form on `page`, then follow redirects until `stop` says
25
+ * the next location is the one the caller wants — typically the client's
26
+ * redirect URI carrying the code — without requesting it.
27
+ */
28
+ async submitLogin(page, credentials, stop = () => false) {
29
+ const form = findPasswordForm(page.html ?? '');
30
+ if (!form) {
31
+ throw new Error(`no login form on ${page.url}`);
32
+ }
33
+ const body = new URLSearchParams(form.hidden);
34
+ body.set(form.userField, credentials.username);
35
+ body.set(form.passwordField, credentials.password);
36
+ return this.follow(new URL(form.action, page.url).toString(), {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
39
+ body: body.toString(),
40
+ }, stop);
41
+ }
42
+ /**
43
+ * Accept a consent page ("Do you grant these access privileges?"): submit
44
+ * the form that carries an `accept` button, with its hidden fields.
45
+ */
46
+ async acceptConsent(page) {
47
+ for (const match of (page.html ?? '').matchAll(/<form\b[^>]*>[\s\S]*?<\/form>/gi)) {
48
+ const formHtml = match[0];
49
+ const inputs = [...formHtml.matchAll(/<(?:input|button)\b[^>]*>/gi)].map((m) => m[0]);
50
+ const accept = inputs.find((i) => attribute(i, 'name') === 'accept');
51
+ if (!accept)
52
+ continue;
53
+ const body = new URLSearchParams();
54
+ for (const input of inputs) {
55
+ const name = attribute(input, 'name');
56
+ if (name && attribute(input, 'type') === 'hidden') {
57
+ body.set(name, attribute(input, 'value') ?? '');
58
+ }
59
+ }
60
+ body.set('accept', attribute(accept, 'value') ?? 'Yes');
61
+ const formTag = /<form\b[^>]*>/i.exec(formHtml)?.[0] ?? '';
62
+ return this.follow(new URL(attribute(formTag, 'action') ?? '', page.url).toString(), {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
65
+ body: body.toString(),
66
+ }, () => false);
67
+ }
68
+ throw new Error(`no consent form on ${page.url}`);
69
+ }
70
+ async follow(url, init, stop) {
71
+ let current = url;
72
+ let request = init;
73
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
74
+ const response = await fetch(current, {
75
+ ...request,
76
+ redirect: 'manual',
77
+ headers: { ...(request.headers ?? {}), Cookie: this.cookieHeader() },
78
+ signal: AbortSignal.timeout(15_000),
79
+ });
80
+ this.remember(response);
81
+ const location = response.headers.get('location');
82
+ if (response.status >= 300 && response.status < 400 && location) {
83
+ const next = new URL(location, current).toString();
84
+ if (stop(next))
85
+ return { url: next };
86
+ current = next;
87
+ request = { method: 'GET' };
88
+ continue;
89
+ }
90
+ return { url: current, html: await response.text() };
91
+ }
92
+ throw new Error(`more than ${MAX_HOPS} redirects from ${url}`);
93
+ }
94
+ remember(response) {
95
+ for (const line of response.headers.getSetCookie()) {
96
+ const [pair] = line.split(';');
97
+ const eq = pair.indexOf('=');
98
+ if (eq > 0) {
99
+ this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
100
+ }
101
+ }
102
+ }
103
+ cookieHeader() {
104
+ return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ');
105
+ }
106
+ }
107
+ exports.FormBrowser = FormBrowser;
108
+ /**
109
+ * Log in through the authorization URL and return the redirect URI the server
110
+ * sends the browser back to, with its `code` — not requested, since nothing
111
+ * listens there.
112
+ */
113
+ async function authorizeByForm(authorizationUrl, redirectUri, credentials) {
114
+ const reached = (next) => next.startsWith(redirectUri);
115
+ const browser = new FormBrowser();
116
+ const page = await browser.open(authorizationUrl, reached);
117
+ const done = page.html === undefined
118
+ ? page
119
+ : await browser.submitLogin(page, credentials, reached);
120
+ if (!reached(done.url)) {
121
+ throw new Error(`login did not return to ${redirectUri}; ended at ${done.url}`);
122
+ }
123
+ return new URL(done.url);
124
+ }
125
+ /**
126
+ * Approve a device authorization the way a user would: open the verification
127
+ * URI (with the user code already in it), log in, and grant access.
128
+ */
129
+ async function approveDevice(verificationUriComplete, credentials) {
130
+ const browser = new FormBrowser();
131
+ const page = await browser.open(verificationUriComplete);
132
+ const consent = await browser.submitLogin(page, credentials);
133
+ await browser.acceptConsent(consent);
134
+ }
135
+ /**
136
+ * A SAML login at an identity provider: open the AuthnRequest URL, log in,
137
+ * and take the SAMLResponse from the auto-posting form the IdP answers with —
138
+ * what a browser would post to the assertion consumer service.
139
+ */
140
+ async function samlResponseByForm(authnRequestUrl, credentials) {
141
+ const browser = new FormBrowser();
142
+ const page = await browser.submitLogin(await browser.open(authnRequestUrl), credentials);
143
+ const html = page.html ?? '';
144
+ const input = [...html.matchAll(/<input\b[^>]*>/gi)]
145
+ .map((m) => m[0])
146
+ .find((i) => attribute(i, 'name') === 'SAMLResponse');
147
+ const samlResponse = input ? attribute(input, 'value') : undefined;
148
+ const formTag = /<form\b[^>]*>/i.exec(html)?.[0] ?? '';
149
+ if (!samlResponse) {
150
+ throw new Error(`no SAMLResponse form on ${page.url}`);
151
+ }
152
+ return { samlResponse, acsUrl: attribute(formTag, 'action') ?? '' };
153
+ }
154
+ const decode = (value) => value
155
+ .replace(/&amp;/g, '&')
156
+ .replace(/&quot;/g, '"')
157
+ .replace(/&#39;/g, "'")
158
+ .replace(/&lt;/g, '<')
159
+ .replace(/&gt;/g, '>');
160
+ const attribute = (tag, name) => {
161
+ const match = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i').exec(tag);
162
+ return match ? decode(match[2] ?? match[3] ?? '') : undefined;
163
+ };
164
+ function findPasswordForm(html) {
165
+ for (const match of html.matchAll(/<form\b[^>]*>[\s\S]*?<\/form>/gi)) {
166
+ const formHtml = match[0];
167
+ const inputs = [...formHtml.matchAll(/<input\b[^>]*>/gi)].map((m) => m[0]);
168
+ const password = inputs.find((i) => attribute(i, 'type') === 'password');
169
+ if (!password)
170
+ continue;
171
+ const hidden = {};
172
+ let userField = 'username';
173
+ for (const input of inputs) {
174
+ const type = (attribute(input, 'type') ?? 'text').toLowerCase();
175
+ const name = attribute(input, 'name');
176
+ if (!name)
177
+ continue;
178
+ if (type === 'hidden')
179
+ hidden[name] = attribute(input, 'value') ?? '';
180
+ if ((type === 'text' || type === 'email') &&
181
+ /user|email|login/i.test(name)) {
182
+ userField = name;
183
+ }
184
+ }
185
+ const formTag = /<form\b[^>]*>/i.exec(formHtml)?.[0] ?? '';
186
+ return {
187
+ action: attribute(formTag, 'action') ?? '',
188
+ userField,
189
+ passwordField: attribute(password, 'name') ?? 'password',
190
+ hidden,
191
+ };
192
+ }
193
+ return undefined;
194
+ }
@@ -163,8 +163,8 @@ async function runCallbackScope(options, routes, use) {
163
163
  setTimeout(() => {
164
164
  if (finished)
165
165
  return;
166
- // Grace expired — on Node 18.x `close()` does not end idle connections,
167
- // and an active one may simply be stuck. Force it, bounded.
166
+ // Grace expired — an active connection may simply be stuck. Force it,
167
+ // bounded.
168
168
  server.closeIdleConnections?.();
169
169
  server.closeAllConnections?.();
170
170
  for (const socket of sockets)
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The UAA one-time passcode exchange — what `cf login --sso` does.
3
+ *
4
+ * The user opens `<uaa>/passcode` in any browser, logs in however the
5
+ * identity zone lets them (SSO, a corporate IdP, MFA), and copies the
6
+ * "Temporary Authentication Code" shown there. That code is exchanged here
7
+ * through the password grant, with `passcode` in place of a username and
8
+ * password. It is a UAA extension, not an RFC; XSUAA inherits it.
9
+ *
10
+ * UAA hands the request to its passcode filter chain only when its Accept
11
+ * header names JSON (`passcodeTokenMatcher` accepts `application/json` or
12
+ * `application/x-www-form-urlencoded`). Otherwise it falls through to the
13
+ * ordinary password grant, which answers `invalid_client: No password
14
+ * supplied` — what a bare `fetch`, which sends `*\/*`, gets. axios's default
15
+ * Accept happens to include `application/json`; the header is set explicitly
16
+ * so the exchange does not depend on an HTTP client's defaults.
17
+ */
18
+ import type { ILogger } from '@mcp-abap-adt/interfaces-utils';
19
+ export interface PasscodeTokens {
20
+ accessToken: string;
21
+ refreshToken?: string;
22
+ expiresIn?: number;
23
+ }
24
+ export declare function exchangePasscode(uaaUrl: string, clientId: string, clientSecret: string | undefined, passcode: string, logger?: ILogger): Promise<PasscodeTokens>;
25
+ //# sourceMappingURL=passcodeAuth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passcodeAuth.d.ts","sourceRoot":"","sources":["../../src/auth/passcodeAuth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAG9D,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,OAAO,GACf,OAAO,CAAC,cAAc,CAAC,CAiDzB"}
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ /**
3
+ * The UAA one-time passcode exchange — what `cf login --sso` does.
4
+ *
5
+ * The user opens `<uaa>/passcode` in any browser, logs in however the
6
+ * identity zone lets them (SSO, a corporate IdP, MFA), and copies the
7
+ * "Temporary Authentication Code" shown there. That code is exchanged here
8
+ * through the password grant, with `passcode` in place of a username and
9
+ * password. It is a UAA extension, not an RFC; XSUAA inherits it.
10
+ *
11
+ * UAA hands the request to its passcode filter chain only when its Accept
12
+ * header names JSON (`passcodeTokenMatcher` accepts `application/json` or
13
+ * `application/x-www-form-urlencoded`). Otherwise it falls through to the
14
+ * ordinary password grant, which answers `invalid_client: No password
15
+ * supplied` — what a bare `fetch`, which sends `*\/*`, gets. axios's default
16
+ * Accept happens to include `application/json`; the header is set explicitly
17
+ * so the exchange does not depend on an HTTP client's defaults.
18
+ */
19
+ var __importDefault = (this && this.__importDefault) || function (mod) {
20
+ return (mod && mod.__esModule) ? mod : { "default": mod };
21
+ };
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.exchangePasscode = exchangePasscode;
24
+ const axios_1 = __importDefault(require("axios"));
25
+ async function exchangePasscode(uaaUrl, clientId, clientSecret, passcode, logger) {
26
+ const tokenUrl = `${uaaUrl.replace(/\/+$/, '')}/oauth/token`;
27
+ const params = new URLSearchParams();
28
+ params.append('grant_type', 'password');
29
+ params.append('passcode', passcode);
30
+ logger?.info('[UAA] Exchanging passcode for token', { tokenUrl });
31
+ // A public client — `cf` is one — authenticates with an empty secret.
32
+ const basic = Buffer.from(`${clientId}:${clientSecret ?? ''}`).toString('base64');
33
+ let response;
34
+ try {
35
+ response = await axios_1.default.post(tokenUrl, params.toString(), {
36
+ headers: {
37
+ 'Content-Type': 'application/x-www-form-urlencoded',
38
+ Accept: 'application/json',
39
+ Authorization: `Basic ${basic}`,
40
+ },
41
+ });
42
+ }
43
+ catch (error) {
44
+ // UAA says why in the body — "Invalid passcode" for a mistyped or
45
+ // already spent code — which is what the user needs to read.
46
+ if (axios_1.default.isAxiosError(error) && error.response) {
47
+ const body = error.response.data;
48
+ const reason = body?.error_description ?? body?.error ?? 'no reason given';
49
+ throw new Error(`Passcode exchange failed (${error.response.status}): ${reason}`);
50
+ }
51
+ throw error;
52
+ }
53
+ const data = response.data;
54
+ if (!data?.access_token) {
55
+ throw new Error('Passcode exchange returned no access_token');
56
+ }
57
+ return {
58
+ accessToken: data.access_token,
59
+ refreshToken: data.refresh_token,
60
+ expiresIn: data.expires_in,
61
+ };
62
+ }
@@ -9,7 +9,11 @@ export interface Saml2AuthConfig {
9
9
  relayState?: string;
10
10
  authorizationUrl?: string;
11
11
  }
12
- export declare function buildSamlAuthorizationUrl(config: Saml2AuthConfig): string;
12
+ export interface BuiltAuthorizationUrl {
13
+ readonly url: string;
14
+ /** Present only when this function minted the request. */
15
+ readonly requestId?: string;
16
+ }
17
+ export declare function buildSamlAuthorizationUrl(config: Saml2AuthConfig): BuiltAuthorizationUrl;
13
18
  export declare const withSamlCallbackServer: CallbackServerFactory<string>;
14
- export declare function parseSamlNotOnOrAfter(samlResponse: string): number | undefined;
15
19
  //# sourceMappingURL=saml2Auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"saml2Auth.d.ts","sourceRoot":"","sources":["../../src/auth/saml2Auth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EACV,qBAAqB,EAGtB,MAAM,+BAA+B,CAAC;AAIvC,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAyBD,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAazE;AAED,eAAO,MAAM,sBAAsB,EAAE,qBAAqB,CAAC,MAAM,CAgC9D,CAAC;AAEJ,wBAAgB,qBAAqB,CACnC,YAAY,EAAE,MAAM,GACnB,MAAM,GAAG,SAAS,CAYpB"}
1
+ {"version":3,"file":"saml2Auth.d.ts","sourceRoot":"","sources":["../../src/auth/saml2Auth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EACV,qBAAqB,EAGtB,MAAM,+BAA+B,CAAC;AAIvC,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AA4BD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,eAAe,GACtB,qBAAqB,CAkBvB;AAED,eAAO,MAAM,sBAAsB,EAAE,qBAAqB,CAAC,MAAM,CAgC9D,CAAC"}
@@ -8,7 +8,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.withSamlCallbackServer = void 0;
10
10
  exports.buildSamlAuthorizationUrl = buildSamlAuthorizationUrl;
11
- exports.parseSamlNotOnOrAfter = parseSamlNotOnOrAfter;
12
11
  const node_crypto_1 = require("node:crypto");
13
12
  const node_zlib_1 = require("node:zlib");
14
13
  const express_1 = __importDefault(require("express"));
@@ -18,9 +17,8 @@ function base64Encode(input) {
18
17
  ? input.toString('base64')
19
18
  : Buffer.from(input, 'utf8').toString('base64');
20
19
  }
21
- function buildAuthnRequestXml(spEntityId, acsUrl) {
20
+ function buildAuthnRequestXml(id, spEntityId, acsUrl) {
22
21
  const issueInstant = new Date().toISOString();
23
- const id = `_${(0, node_crypto_1.randomUUID)()}`;
24
22
  return [
25
23
  '<?xml version="1.0" encoding="UTF-8"?>',
26
24
  '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"',
@@ -36,15 +34,20 @@ function buildAuthnRequestXml(spEntityId, acsUrl) {
36
34
  }
37
35
  function buildSamlAuthorizationUrl(config) {
38
36
  if (config.authorizationUrl) {
39
- return config.authorizationUrl;
37
+ // Somebody else built the request; its ID is not ours to know.
38
+ return { url: config.authorizationUrl };
40
39
  }
41
- const xml = buildAuthnRequestXml(config.spEntityId, config.acsUrl);
40
+ const requestId = `_${(0, node_crypto_1.randomUUID)()}`;
41
+ const xml = buildAuthnRequestXml(requestId, config.spEntityId, config.acsUrl);
42
42
  const deflated = (0, node_zlib_1.deflateRawSync)(Buffer.from(xml, 'utf8'));
43
43
  const samlRequest = encodeURIComponent(base64Encode(deflated));
44
44
  const relayState = config.relayState
45
45
  ? `&RelayState=${encodeURIComponent(config.relayState)}`
46
46
  : '';
47
- return `${config.idpSsoUrl}?SAMLRequest=${samlRequest}${relayState}`;
47
+ return {
48
+ url: `${config.idpSsoUrl}?SAMLRequest=${samlRequest}${relayState}`,
49
+ requestId,
50
+ };
48
51
  }
49
52
  const withSamlCallbackServer = (options, use) => (0, callbackServer_1.runCallbackScope)(options, (app, settle) => {
50
53
  app.use(express_1.default.urlencoded({ extended: false, limit: '5mb' }));
@@ -69,17 +72,3 @@ const withSamlCallbackServer = (options, use) => (0, callbackServer_1.runCallbac
69
72
  });
70
73
  }, use);
71
74
  exports.withSamlCallbackServer = withSamlCallbackServer;
72
- function parseSamlNotOnOrAfter(samlResponse) {
73
- try {
74
- const decoded = Buffer.from(samlResponse, 'base64').toString('utf8');
75
- const match = decoded.match(/NotOnOrAfter="([^"]+)"/);
76
- if (!match) {
77
- return undefined;
78
- }
79
- const date = Date.parse(match[1]);
80
- return Number.isNaN(date) ? undefined : date;
81
- }
82
- catch {
83
- return undefined;
84
- }
85
- }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What the SAML 2.0 bearer grant accepts, from what a SAML login delivers.
3
+ *
4
+ * RFC 7522 §2.1: the `assertion` parameter is a single SAML 2.0 Assertion,
5
+ * base64url-encoded. An interactive login delivers something else — the
6
+ * identity provider's whole `samlp:Response`, in standard base64 — and a
7
+ * token endpoint that follows the RFC refuses it: Cloud Foundry UAA answers
8
+ * 401 to a Response in either encoding. So the Assertion is taken out of the
9
+ * Response here and re-encoded.
10
+ *
11
+ * The Assertion is serialised as an element of its own, and every namespace
12
+ * declaration it inherited from the Response is copied onto it first. A
13
+ * serializer would add back only the prefixes used in element and attribute
14
+ * names; a prefix used only inside a value — `xsi:type="xs:string"` — would be
15
+ * lost, leaving a QName that no longer resolves. Copying all of them keeps the
16
+ * Assertion's in-scope namespaces exactly what they were. Its signature, over
17
+ * exclusive canonical XML, still verifies: canonicalisation renders a
18
+ * namespace where it is used, or where the signature's InclusiveNamespaces
19
+ * names it, not where it was declared. A signature over the Response alone does not
20
+ * survive the cut; the token endpoint then refuses the Assertion, as it
21
+ * would any unsigned one.
22
+ */
23
+ export declare function toBearerAssertion(payload: string): string;
24
+ //# sourceMappingURL=samlBearerAssertion.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"samlBearerAssertion.d.ts","sourceRoot":"","sources":["../../src/auth/samlBearerAssertion.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAQH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAmDzD"}
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ /**
3
+ * What the SAML 2.0 bearer grant accepts, from what a SAML login delivers.
4
+ *
5
+ * RFC 7522 §2.1: the `assertion` parameter is a single SAML 2.0 Assertion,
6
+ * base64url-encoded. An interactive login delivers something else — the
7
+ * identity provider's whole `samlp:Response`, in standard base64 — and a
8
+ * token endpoint that follows the RFC refuses it: Cloud Foundry UAA answers
9
+ * 401 to a Response in either encoding. So the Assertion is taken out of the
10
+ * Response here and re-encoded.
11
+ *
12
+ * The Assertion is serialised as an element of its own, and every namespace
13
+ * declaration it inherited from the Response is copied onto it first. A
14
+ * serializer would add back only the prefixes used in element and attribute
15
+ * names; a prefix used only inside a value — `xsi:type="xs:string"` — would be
16
+ * lost, leaving a QName that no longer resolves. Copying all of them keeps the
17
+ * Assertion's in-scope namespaces exactly what they were. Its signature, over
18
+ * exclusive canonical XML, still verifies: canonicalisation renders a
19
+ * namespace where it is used, or where the signature's InclusiveNamespaces
20
+ * names it, not where it was declared. A signature over the Response alone does not
21
+ * survive the cut; the token endpoint then refuses the Assertion, as it
22
+ * would any unsigned one.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.toBearerAssertion = toBearerAssertion;
26
+ const xmldom_1 = require("@xmldom/xmldom");
27
+ const strictXml_1 = require("./strictXml");
28
+ const SAML_ASSERTION_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';
29
+ const SAML_PROTOCOL_NS = 'urn:oasis:names:tc:SAML:2.0:protocol';
30
+ function toBearerAssertion(payload) {
31
+ // Node's base64 decoder accepts both alphabets, so this reads either.
32
+ const xml = Buffer.from(payload.trim(), 'base64').toString('utf8');
33
+ if (!xml.trimStart().startsWith('<')) {
34
+ throw new Error('SAML bearer payload is not base64-encoded XML');
35
+ }
36
+ let root;
37
+ try {
38
+ root = (0, strictXml_1.parseStrictXml)(xml).documentElement;
39
+ }
40
+ catch (error) {
41
+ throw new Error(`SAML bearer payload is not well-formed XML: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ if (isElement(root, SAML_ASSERTION_NS, 'Assertion')) {
44
+ return Buffer.from(xml, 'utf8').toString('base64url');
45
+ }
46
+ if (!isElement(root, SAML_PROTOCOL_NS, 'Response')) {
47
+ throw new Error('SAML bearer payload is neither a SAML Response nor an Assertion');
48
+ }
49
+ const children = childElements(root);
50
+ const assertions = children.filter((e) => isElement(e, SAML_ASSERTION_NS, 'Assertion'));
51
+ if (assertions.length === 0) {
52
+ if (children.some((e) => isElement(e, SAML_ASSERTION_NS, 'EncryptedAssertion'))) {
53
+ throw new Error('SAML Response carries only an EncryptedAssertion; encrypted Assertions are not supported');
54
+ }
55
+ throw new Error('SAML Response carries no Assertion');
56
+ }
57
+ if (assertions.length > 1) {
58
+ throw new Error(`SAML Response carries ${assertions.length} Assertions; a bearer grant takes one`);
59
+ }
60
+ const assertion = assertions[0];
61
+ declareInheritedNamespaces(assertion);
62
+ const serialized = new xmldom_1.XMLSerializer().serializeToString(assertion);
63
+ return Buffer.from(serialized, 'utf8').toString('base64url');
64
+ }
65
+ function isElement(node, namespace, localName) {
66
+ return (!!node && node.namespaceURI === namespace && node.localName === localName);
67
+ }
68
+ const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
69
+ const isNamespaceDeclaration = (name) => name === 'xmlns' || name.startsWith('xmlns:');
70
+ /**
71
+ * Copies onto `element` every namespace declaration in scope from its
72
+ * ancestors that it does not make itself. Ancestors are walked innermost
73
+ * first, so the nearest declaration of a prefix wins, as it did in place.
74
+ */
75
+ function declareInheritedNamespaces(element) {
76
+ const declared = new Set();
77
+ for (let i = 0; i < element.attributes.length; i++) {
78
+ const name = element.attributes.item(i)?.name;
79
+ if (name && isNamespaceDeclaration(name))
80
+ declared.add(name);
81
+ }
82
+ for (let ancestor = element.parentNode; ancestor && ancestor.nodeType === 1; ancestor = ancestor.parentNode) {
83
+ const attributes = ancestor.attributes;
84
+ for (let i = 0; i < attributes.length; i++) {
85
+ const attribute = attributes.item(i);
86
+ if (attribute &&
87
+ isNamespaceDeclaration(attribute.name) &&
88
+ !declared.has(attribute.name)) {
89
+ element.setAttributeNS(XMLNS_NS, attribute.name, attribute.value);
90
+ declared.add(attribute.name);
91
+ }
92
+ }
93
+ }
94
+ }
95
+ function childElements(parent) {
96
+ const out = [];
97
+ for (let n = parent.firstChild; n; n = n.nextSibling) {
98
+ if (n.nodeType === 1)
99
+ out.push(n);
100
+ }
101
+ return out;
102
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Parsing untrusted XML so that any fault is a refusal.
3
+ *
4
+ * @xmldom/xmldom's defaults are wrong for a security boundary in two ways:
5
+ * without `onError` it reports every fault to the console — so a malformed
6
+ * callback from anyone writes to the process's stderr, past `ILogger` — and it
7
+ * recovers from `error`-level faults (an undeclared entity, say) by handing
8
+ * back a repaired document instead of failing. Throwing from `onError` turns
9
+ * every level into a `ParseError`, silently.
10
+ */
11
+ import { type Document } from '@xmldom/xmldom';
12
+ export declare function parseStrictXml(xml: string): Document;
13
+ //# sourceMappingURL=strictXml.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strictXml.d.ts","sourceRoot":"","sources":["../../src/auth/strictXml.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,CAMpD"}
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ /**
3
+ * Parsing untrusted XML so that any fault is a refusal.
4
+ *
5
+ * @xmldom/xmldom's defaults are wrong for a security boundary in two ways:
6
+ * without `onError` it reports every fault to the console — so a malformed
7
+ * callback from anyone writes to the process's stderr, past `ILogger` — and it
8
+ * recovers from `error`-level faults (an undeclared entity, say) by handing
9
+ * back a repaired document instead of failing. Throwing from `onError` turns
10
+ * every level into a `ParseError`, silently.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.parseStrictXml = parseStrictXml;
14
+ const xmldom_1 = require("@xmldom/xmldom");
15
+ function parseStrictXml(xml) {
16
+ return new xmldom_1.DOMParser({
17
+ onError: (level, message) => {
18
+ throw new Error(`XML ${level}: ${message}`);
19
+ },
20
+ }).parseFromString(xml, 'text/xml');
21
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * An assertion was refused, and by which check.
3
+ *
4
+ * The check is a field rather than something to read out of the message: a
5
+ * consumer telling "your identity provider declined" from "this was not
6
+ * addressed to us" should not be parsing prose to do it.
7
+ */
8
+ import { TokenProviderError } from './TokenProviderErrors';
9
+ /** The checks the shipped validator performs, in the order it performs them. */
10
+ export type AssertionCheck = 'document' | 'duplicateId' | 'signature' | 'signedNode' | 'status' | 'assertionId' | 'issuer' | 'conditions' | 'notBefore' | 'notOnOrAfter' | 'audience' | 'bearerConfirmation' | 'destination' | 'replay';
11
+ export declare class AssertionValidationError extends TokenProviderError {
12
+ readonly check: AssertionCheck;
13
+ constructor(check: AssertionCheck, message: string);
14
+ }
15
+ //# sourceMappingURL=AssertionValidationError.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AssertionValidationError.d.ts","sourceRoot":"","sources":["../../src/errors/AssertionValidationError.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAE3D,gFAAgF;AAChF,MAAM,MAAM,cAAc,GACtB,UAAU,GACV,aAAa,GACb,WAAW,GACX,YAAY,GACZ,QAAQ,GACR,aAAa,GACb,QAAQ,GACR,YAAY,GACZ,WAAW,GACX,cAAc,GACd,UAAU,GACV,oBAAoB,GACpB,aAAa,GACb,QAAQ,CAAC;AAEb,qBAAa,wBAAyB,SAAQ,kBAAkB;IAC9D,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;gBAEnB,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM;CAQnD"}