@idp.global/sdk 18.1.0 → 18.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/changelog.md +20 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_relyingparty/classes.errors.d.ts +4 -0
  4. package/dist_ts_relyingparty/classes.errors.js +8 -1
  5. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +17 -1
  6. package/dist_ts_relyingparty/classes.idpoidcprovider.js +20 -4
  7. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +26 -8
  8. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +19 -3
  9. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +22 -9
  10. package/dist_ts_relyingparty/classes.relyingpartysessions.js +50 -11
  11. package/dist_ts_relyingparty/classes.rpmodels.d.ts +10 -2
  12. package/dist_ts_relyingparty/classes.rpmodels.js +26 -7
  13. package/dist_ts_relyingparty/classes.socketbinding.d.ts +18 -9
  14. package/dist_ts_relyingparty/classes.socketbinding.js +17 -11
  15. package/dist_ts_relyingparty/interfaces.d.ts +32 -3
  16. package/dist_ts_server/classes.oidc-introspection-client.d.ts +7 -0
  17. package/dist_ts_server/classes.oidc-introspection-client.js +14 -6
  18. package/dist_ts_server/helpers.trusted-ca-fetch.d.ts +25 -0
  19. package/dist_ts_server/helpers.trusted-ca-fetch.js +179 -0
  20. package/dist_ts_server/index.d.ts +1 -0
  21. package/dist_ts_server/index.js +2 -1
  22. package/dist_ts_server/plugins.d.ts +3 -1
  23. package/dist_ts_server/plugins.js +4 -2
  24. package/package.json +1 -1
  25. package/readme.md +108 -7
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts_relyingparty/classes.errors.ts +8 -0
  28. package/ts_relyingparty/classes.idpoidcprovider.ts +35 -4
  29. package/ts_relyingparty/classes.relyingpartyroutes.ts +46 -13
  30. package/ts_relyingparty/classes.relyingpartysessions.ts +65 -18
  31. package/ts_relyingparty/classes.rpmodels.ts +31 -7
  32. package/ts_relyingparty/classes.socketbinding.ts +30 -16
  33. package/ts_relyingparty/interfaces.ts +35 -3
  34. package/ts_server/classes.oidc-introspection-client.ts +17 -3
  35. package/ts_server/helpers.trusted-ca-fetch.ts +204 -0
  36. package/ts_server/index.ts +1 -0
  37. package/ts_server/plugins.ts +3 -1
@@ -1,4 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
+ import type { TOidcFetch } from './helpers.trusted-ca-fetch.js';
2
3
 
3
4
  export interface IOidcIntrospectionClientOptions {
4
5
  issuer: string;
@@ -6,6 +7,12 @@ export interface IOidcIntrospectionClientOptions {
6
7
  clientSecret: string;
7
8
  timeoutMs?: number;
8
9
  maxResponseBytes?: number;
10
+ /**
11
+ * The fetch every introspection request goes through, e.g.
12
+ * `createTrustedCaFetch(caPem)` for an issuer under a private CA. The global
13
+ * `fetch` when absent.
14
+ */
15
+ fetch?: TOidcFetch;
9
16
  }
10
17
 
11
18
  export interface IOidcIntrospectionClientRequest {
@@ -40,6 +47,7 @@ export class OidcIntrospectionClient {
40
47
  readonly #authorization: string;
41
48
  readonly #timeoutMs: number;
42
49
  readonly #maxResponseBytes: number;
50
+ readonly #fetch?: TOidcFetch;
43
51
 
44
52
  constructor(optionsArg: IOidcIntrospectionClientOptions) {
45
53
  if (!optionsArg || !exactString(optionsArg.issuer, 2048) || !exactString(optionsArg.clientId)
@@ -56,6 +64,8 @@ export class OidcIntrospectionClient {
56
64
  || !Number.isSafeInteger(this.#maxResponseBytes) || this.#maxResponseBytes < 1 || this.#maxResponseBytes > 65536) {
57
65
  throw new OidcIntrospectionError('invalid_request');
58
66
  }
67
+ if (optionsArg.fetch !== undefined && typeof optionsArg.fetch !== 'function') throw new OidcIntrospectionError('invalid_request');
68
+ this.#fetch = optionsArg.fetch;
59
69
  this.#issuer = canonicalIssuer;
60
70
  this.#clientId = optionsArg.clientId;
61
71
  // OAuth Basic components are form-encoded separately before joining with ':'.
@@ -76,11 +86,15 @@ export class OidcIntrospectionClient {
76
86
  });
77
87
  let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
78
88
  try {
79
- const response = await Promise.race([fetch(`${this.#issuer}/oauth/introspect`, {
80
- method: 'POST', redirect: 'error', credentials: 'omit', cache: 'no-store', signal: controller.signal,
89
+ const url = `${this.#issuer}/oauth/introspect`;
90
+ const init = {
91
+ method: 'POST', redirect: 'error' as const, signal: controller.signal,
81
92
  headers: { authorization: this.#authorization, 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
82
93
  body: new URLSearchParams({ token, subject, organization_id: organizationId, nonce, token_type_hint: 'access_token' }),
83
- }), deadline]);
94
+ };
95
+ const response = await Promise.race([this.#fetch
96
+ ? this.#fetch(url, init)
97
+ : fetch(url, { ...init, credentials: 'omit', cache: 'no-store' }), deadline]);
84
98
  if (response.status !== 200) throw new OidcIntrospectionError('unavailable');
85
99
  if (response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json'
86
100
  || !response.headers.get('cache-control')?.toLowerCase().split(',').map((partArg) => partArg.trim()).includes('no-store')
@@ -0,0 +1,204 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /**
4
+ * What the sdk's OIDC clients pass to their fetch: the subset of `RequestInit`
5
+ * that `openid-client` and `OidcIntrospectionClient` use. Node's global `fetch`
6
+ * satisfies it.
7
+ */
8
+ export interface IOidcFetchInit {
9
+ method: string;
10
+ headers: Record<string, string>;
11
+ body?: ArrayBuffer | ReadableStream | string | Uint8Array | URLSearchParams | null;
12
+ duplex?: 'half';
13
+ /** `manual` answers a redirect as it is; `error` rejects it. Neither follows it. */
14
+ redirect: 'manual' | 'error';
15
+ signal?: AbortSignal;
16
+ }
17
+
18
+ /** A fetch for one OIDC client's requests, e.g. one that trusts the issuer's private CA. */
19
+ export type TOidcFetch = (urlArg: string, initArg: IOidcFetchInit) => Promise<Response>;
20
+
21
+ const pemCertificatePattern = /-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----/g;
22
+
23
+ /** The statuses a fetch answers with a null body. */
24
+ const nullBodyStatuses: ReadonlySet<number> = new Set([101, 103, 204, 205, 304]);
25
+
26
+ /** The statuses fetch treats as redirects. */
27
+ const redirectStatuses: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
28
+
29
+ /**
30
+ * Idle keep-alive sockets close after this delay, or earlier when the server
31
+ * announces a shorter `Keep-Alive: timeout`, so a pooled socket is not reused
32
+ * as the server closes it.
33
+ */
34
+ const idleSocketTimeoutMs = 4_000;
35
+
36
+ /** A request body as bytes, with the content type fetch would give it. */
37
+ const encodeBody = async (
38
+ bodyArg: NonNullable<IOidcFetchInit['body']>,
39
+ ): Promise<{ bytes: Buffer; contentType?: string }> => {
40
+ if (typeof bodyArg === 'string') {
41
+ return { bytes: Buffer.from(bodyArg, 'utf8'), contentType: 'text/plain;charset=UTF-8' };
42
+ }
43
+ if (bodyArg instanceof URLSearchParams) {
44
+ return { bytes: Buffer.from(bodyArg.toString(), 'utf8'), contentType: 'application/x-www-form-urlencoded;charset=UTF-8' };
45
+ }
46
+ if (bodyArg instanceof ArrayBuffer) {
47
+ return { bytes: Buffer.from(new Uint8Array(bodyArg)) };
48
+ }
49
+ if (bodyArg instanceof Uint8Array) {
50
+ return { bytes: Buffer.from(bodyArg) };
51
+ }
52
+ const chunks: Uint8Array[] = [];
53
+ const reader = bodyArg.getReader();
54
+ while (true) {
55
+ const part = await reader.read();
56
+ if (part.done) {
57
+ break;
58
+ }
59
+ if (!(part.value instanceof Uint8Array)) {
60
+ await reader.cancel();
61
+ throw new TypeError('A request body stream must yield bytes');
62
+ }
63
+ chunks.push(part.value);
64
+ }
65
+ return { bytes: Buffer.concat(chunks) };
66
+ };
67
+
68
+ /**
69
+ * The response body as a fetch body stream, read as it is pulled. Cancelling
70
+ * it destroys the response and so its connection.
71
+ */
72
+ const webStreamOf = (incomingArg: plugins.http.IncomingMessage): ReadableStream<Uint8Array> => {
73
+ const chunks = incomingArg[Symbol.asyncIterator]();
74
+ return new ReadableStream<Uint8Array>({
75
+ async pull(controllerArg) {
76
+ const part = await chunks.next();
77
+ if (part.done) {
78
+ controllerArg.close();
79
+ } else {
80
+ controllerArg.enqueue(part.value);
81
+ }
82
+ },
83
+ async cancel() {
84
+ await chunks.return?.();
85
+ },
86
+ });
87
+ };
88
+
89
+ /**
90
+ * Every certificate of the given PEM text, each parsed. Anything else in the
91
+ * text, or no certificate at all, is refused: an unreadable trust anchor must
92
+ * fail here, not as a TLS error at the first request.
93
+ */
94
+ const readTrustedCertificates = (trustedCaPemArg: string | readonly string[]): string[] => {
95
+ const pems = typeof trustedCaPemArg === 'string' ? [trustedCaPemArg] : trustedCaPemArg;
96
+ if (!Array.isArray(pems) || pems.length === 0) {
97
+ throw new TypeError('trustedCaPem must name at least one PEM certificate');
98
+ }
99
+ const certificates: string[] = [];
100
+ for (const pem of pems) {
101
+ if (typeof pem !== 'string') {
102
+ throw new TypeError('trustedCaPem must be PEM certificate text');
103
+ }
104
+ const blocks = pem.match(pemCertificatePattern) ?? [];
105
+ if (blocks.length === 0 || pem.replace(pemCertificatePattern, '').trim() !== '') {
106
+ throw new TypeError('trustedCaPem must contain PEM certificates only');
107
+ }
108
+ for (const block of blocks) {
109
+ try {
110
+ new plugins.crypto.X509Certificate(block);
111
+ } catch {
112
+ throw new TypeError('trustedCaPem contains a certificate that cannot be parsed');
113
+ }
114
+ certificates.push(block);
115
+ }
116
+ }
117
+ return certificates;
118
+ };
119
+
120
+ /**
121
+ * A fetch over `node:https` whose connections trust the given CA certificates
122
+ * and nothing else. The agent belongs to this fetch alone: the process-wide
123
+ * trust store, the global `fetch` and its dispatcher stay untouched. Only
124
+ * `https:` URLs are fetched, redirects are never followed, and bodies are
125
+ * requested without a content coding. Idle pooled sockets are unreferenced, so
126
+ * they never keep the process alive.
127
+ */
128
+ export const createTrustedCaFetch = (trustedCaPemArg: string | readonly string[]): TOidcFetch => {
129
+ const agent = new plugins.https.Agent({
130
+ ca: readTrustedCertificates(trustedCaPemArg),
131
+ keepAlive: true,
132
+ timeout: idleSocketTimeoutMs,
133
+ });
134
+ return async (urlArg, initArg) => {
135
+ const url = new URL(urlArg);
136
+ if (url.protocol !== 'https:') {
137
+ throw new TypeError('A trusted-CA fetch only requests https URLs');
138
+ }
139
+ const signal = initArg.signal;
140
+ signal?.throwIfAborted();
141
+ const headers = new Headers(initArg.headers);
142
+ let body: Buffer | undefined;
143
+ if (initArg.body !== undefined && initArg.body !== null) {
144
+ const encoded = await encodeBody(initArg.body);
145
+ body = encoded.bytes;
146
+ if (encoded.contentType && !headers.has('content-type')) {
147
+ headers.set('content-type', encoded.contentType);
148
+ }
149
+ headers.set('content-length', String(body.byteLength));
150
+ }
151
+ headers.set('accept-encoding', 'identity');
152
+ signal?.throwIfAborted();
153
+
154
+ return new Promise<Response>((resolve, reject) => {
155
+ const request = plugins.https.request(url, {
156
+ method: initArg.method,
157
+ headers: Object.fromEntries(headers.entries()),
158
+ agent,
159
+ });
160
+ // Stays attached for the request's lifetime, so a connection failure
161
+ // while the body is read never becomes an unhandled 'error' event.
162
+ request.on('error', reject);
163
+ // Destroyed without an error on purpose: an error would be re-emitted on
164
+ // the socket, which no longer has a listener once a keep-alive response
165
+ // completed. The response body errors as its connection goes.
166
+ const abort = () => {
167
+ reject(signal?.reason);
168
+ request.destroy();
169
+ };
170
+ signal?.addEventListener('abort', abort, { once: true });
171
+ request.once('close', () => signal?.removeEventListener('abort', abort));
172
+ request.on('response', (incoming) => {
173
+ try {
174
+ const status = incoming.statusCode ?? 0;
175
+ if (initArg.redirect === 'error' && redirectStatuses.has(status)) {
176
+ throw new TypeError('The response is a redirect');
177
+ }
178
+ const contentEncoding = incoming.headers['content-encoding'];
179
+ if (contentEncoding !== undefined && contentEncoding.trim().toLowerCase() !== 'identity') {
180
+ throw new TypeError('The response has a content coding');
181
+ }
182
+ // same combination rules as fetch: repeated fields join with ", "
183
+ const responseHeaders = new Headers();
184
+ for (let index = 0; index < incoming.rawHeaders.length; index += 2) {
185
+ responseHeaders.append(incoming.rawHeaders[index], incoming.rawHeaders[index + 1]);
186
+ }
187
+ const withoutBody = nullBodyStatuses.has(status) || initArg.method.toUpperCase() === 'HEAD';
188
+ if (withoutBody) {
189
+ incoming.resume();
190
+ }
191
+ resolve(new Response(withoutBody ? null : webStreamOf(incoming), {
192
+ status,
193
+ statusText: incoming.statusMessage,
194
+ headers: responseHeaders,
195
+ }));
196
+ } catch (error) {
197
+ reject(error);
198
+ request.destroy();
199
+ }
200
+ });
201
+ request.end(body);
202
+ });
203
+ };
204
+ };
@@ -3,6 +3,7 @@ export * from './classes.account-auth-service.js';
3
3
  export * from './classes.account-doc.js';
4
4
  export * from './classes.account-store.js';
5
5
  export * from './classes.idp-global-server-client.js';
6
+ export * from './helpers.trusted-ca-fetch.js';
6
7
  export * from './classes.oidc-introspection-client.js';
7
8
  export * from './classes.oidc-userinfo-client.js';
8
9
  export * from './classes.password-hasher.js';
@@ -1,6 +1,8 @@
1
1
  import * as crypto from 'crypto';
2
+ import * as http from 'node:http';
3
+ import * as https from 'node:https';
2
4
 
3
- export { crypto };
5
+ export { crypto, http, https };
4
6
 
5
7
  import * as idpInterfaces from '@idp.global/interfaces';
6
8