@thuzjq/meteorcloud-device-sdk-node 0.5.1

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/lib/connect.js ADDED
@@ -0,0 +1,718 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const http = require('node:http');
5
+ const path = require('node:path');
6
+ const { spawn } = require('node:child_process');
7
+
8
+ const { MeteorCloudError, fail, isObject } = require('./errors');
9
+ const { defaultTransport, headerValue } = require('./http');
10
+ const { base64UrlEncodeJson, createPkcePair } = require('./jose');
11
+ const {
12
+ PRODUCT_CLIENT_IDS,
13
+ INSTALLATION_UID_RE,
14
+ authorizeEndpoint,
15
+ normalizeApiBase,
16
+ normalizeIssuer,
17
+ tokenEndpoint,
18
+ saveInstallationConfig
19
+ } = require('./config');
20
+ const { defaultKeyStoreScheme, FileKeyStore, DpapiKeyStore, CngKeyStore } = require('./keystore');
21
+
22
+ const BIND_SCOPE = 'mscloud.bind';
23
+ const CALLBACK_PATH = '/callback';
24
+ const DEFAULT_TIMEOUT_MS = 300000;
25
+ const BROWSER_MODES = Object.freeze(['system', 'manual']);
26
+
27
+ /**
28
+ * The complete public authorize surface (contract §5.3), in the published
29
+ * order. It is asserted against the built URL rather than merely documented:
30
+ * every parameter this list does not name was deleted for a reason — `bridge`
31
+ * was a session credential in a process argv, `proposal` / `channels` /
32
+ * `devices` were a device inventory the consent page no longer shows, and
33
+ * `cameraUid` / `stationUid` pinned an account-wide grant to one resource. A
34
+ * future edit that re-adds any of them fails here instead of on a server that
35
+ * would quietly accept it.
36
+ */
37
+ const AUTHORIZE_PARAMS = Object.freeze([
38
+ 'response_type',
39
+ 'client_id',
40
+ 'redirect_uri',
41
+ 'scope',
42
+ 'code_challenge',
43
+ 'code_challenge_method',
44
+ 'state',
45
+ 'installation_jwk'
46
+ ]);
47
+
48
+ /**
49
+ * Options the 0.4.x surface accepted and 0.5.0 must not. They are *rejected*,
50
+ * not ignored: a caller that keeps passing `bridgeGrant` after upgrading would
51
+ * otherwise get a silently different URL, and the only symptom would be the
52
+ * server's `401 账号未登录` with nothing pointing at the removed parameter.
53
+ * Each value says what replaced it, because "unknown option" is not a migration
54
+ * note.
55
+ */
56
+ const REMOVED_CONNECT_OPTIONS = Object.freeze({
57
+ bridgeGrant:
58
+ 'the browser signs in on its own and the SDK never relays a session grant',
59
+ proposal:
60
+ 'the consent page shows no cameras; declare the camera on each upload instead',
61
+ installationDecisions: 'the consent page submits no per-camera decisions',
62
+ channels: 'the SDK neither reads nor forwards host capture channels',
63
+ devices: 'the SDK neither reads nor forwards host devices',
64
+ cameras: 'the SDK holds no camera inventory',
65
+ cameraUid: 'the grant is account-wide; name the camera on each upload instead',
66
+ stationUid: 'the grant is account-wide; the station follows from the upload'
67
+ });
68
+
69
+ const CALLBACK_HTML_OK =
70
+ '<!doctype html><meta charset="utf-8"><title>MeteorCloud</title>' +
71
+ '<body style="font:16px system-ui;margin:4rem auto;max-width:30rem;text-align:center">' +
72
+ '<h1>Binding complete</h1><p>You can close this tab and return to the application.</p>';
73
+ const CALLBACK_HTML_FAIL =
74
+ '<!doctype html><meta charset="utf-8"><title>MeteorCloud</title>' +
75
+ '<body style="font:16px system-ui;margin:4rem auto;max-width:30rem;text-align:center">' +
76
+ '<h1>Binding failed</h1><p>Return to the application for details.</p>';
77
+
78
+ function cancelled(message, action) {
79
+ return new MeteorCloudError(message, { kind: 'cancelled', action });
80
+ }
81
+
82
+ /**
83
+ * Hands the authorization URL to the desktop's default browser.
84
+ *
85
+ * SECURITY, and the balance here changed in 0.5.0: the URL no longer carries a
86
+ * credential. A child process's argv is world-readable in the process table on
87
+ * macOS and on default-configured Linux (`hidepid=0`), and until 0.4.x this
88
+ * spawn published the one-time bridge grant there — a value that, presented to
89
+ * `/oauth2/authorize`, *created an authenticated session as the user*. That
90
+ * parameter is gone (contract §5.3), so what crosses the process boundary now
91
+ * is the PKCE *challenge* (a hash), the installation's public JWK, the loopback
92
+ * port and `state`.
93
+ *
94
+ * That is not nothing. A local process that reads `state` and the port can beat
95
+ * the browser to the loopback listener and burn the single accepted callback —
96
+ * a denial of service on this bind, and with a fabricated `code` it wastes one
97
+ * exchange that PKCE then fails. It cannot complete the bind: it holds no
98
+ * authorization code the server will honour, and the code it cannot forge is
99
+ * what the exchange is checked against. The standard fix for the residue is to
100
+ * stop putting authorization parameters in the URL at all — push them once over
101
+ * TLS (RFC 9126 PAR) and hand the browser `client_id` + `request_uri`. That is
102
+ * a server change and is tracked separately.
103
+ *
104
+ * `internals` is a test seam (platform, env, spawnImpl); production callers pass
105
+ * nothing.
106
+ */
107
+ function openSystemBrowser(url, internals = {}) {
108
+ const platform = internals.platform || process.platform;
109
+ const spawnImpl = internals.spawnImpl || spawn;
110
+ let command;
111
+ const args = [];
112
+
113
+ if (platform === 'win32') {
114
+ // Never put an OAuth URL through cmd.exe. `cmd /c start ...` parses `&` as
115
+ // a command separator and expands `%NAME%`, so the server receives only the
116
+ // first query parameter (and reports the request as unauthenticated). Using
117
+ // FileProtocolHandler gives CreateProcess one opaque URL argv element and
118
+ // asks Windows to dispatch it through the registered default handler.
119
+ const windowsRoot = (internals.env || process.env).SystemRoot;
120
+ if (!windowsRoot) {
121
+ return Promise.reject(
122
+ new MeteorCloudError('cannot open the authorization URL: SystemRoot is unavailable', {
123
+ kind: 'io',
124
+ action: 'open_browser'
125
+ })
126
+ );
127
+ }
128
+ const system32 = path.win32.join(windowsRoot, 'System32');
129
+ command = path.win32.join(system32, 'rundll32.exe');
130
+ args.push(`${path.win32.join(system32, 'url.dll')},FileProtocolHandler`);
131
+ } else {
132
+ command = platform === 'darwin' ? 'open' : 'xdg-open';
133
+ }
134
+
135
+ args.push(url);
136
+ const spawnOptions = {
137
+ detached: true,
138
+ stdio: 'ignore',
139
+ // The same rule as the Windows branch, for the same reason: with `shell`
140
+ // set, `&` in the query would split the command on every platform.
141
+ shell: false,
142
+ ...(platform === 'win32' ? { windowsHide: true } : {})
143
+ };
144
+
145
+ return new Promise((resolve, reject) => {
146
+ let child;
147
+ try {
148
+ child = spawnImpl(command, args, spawnOptions);
149
+ } catch (cause) {
150
+ reject(
151
+ new MeteorCloudError('failed to open the authorization URL in the system browser', {
152
+ kind: 'io',
153
+ action: 'open_browser',
154
+ cause
155
+ })
156
+ );
157
+ return;
158
+ }
159
+
160
+ let settled = false;
161
+ child.once('error', (cause) => {
162
+ if (settled) return;
163
+ settled = true;
164
+ reject(
165
+ new MeteorCloudError('failed to open the authorization URL in the system browser', {
166
+ kind: 'io',
167
+ action: 'open_browser',
168
+ cause
169
+ })
170
+ );
171
+ });
172
+ child.once('spawn', () => {
173
+ if (settled) return;
174
+ settled = true;
175
+ child.unref();
176
+ resolve();
177
+ });
178
+ });
179
+ }
180
+
181
+ /**
182
+ * A single-shot loopback listener on 127.0.0.1 with an OS-assigned port
183
+ * (RFC 8252 §7.3, contract §10.2). Bound to the literal loopback address, never
184
+ * 0.0.0.0 and never the name `localhost`, and it answers exactly one path —
185
+ * anything else gets a 404 without touching state.
186
+ *
187
+ * "Single-shot" is enforced, not assumed: the first request that reaches
188
+ * `/callback` settles the outcome and every later one is refused. Without that
189
+ * guard a second callback — a page reload, or a local process replaying the
190
+ * URL — would run the state comparison again against a listener that has
191
+ * already handed its code to the exchange.
192
+ */
193
+ function startLoopbackListener({ timeoutMs, expectedState, signal }) {
194
+ let settle;
195
+ const received = new Promise((resolve, reject) => {
196
+ settle = { resolve, reject };
197
+ });
198
+ // The callback can arrive (or the timer can fire) before the caller reaches
199
+ // `await received`, which would surface a correct rejection as an
200
+ // unhandledRejection. Attaching an inert handler now marks it handled without
201
+ // consuming it — the awaiter still sees the real error.
202
+ received.catch(() => {});
203
+
204
+ let done = false;
205
+ const resolveOnce = (code) => {
206
+ if (done) return;
207
+ done = true;
208
+ settle.resolve(code);
209
+ };
210
+ const rejectOnce = (error) => {
211
+ if (done) return;
212
+ done = true;
213
+ settle.reject(error);
214
+ };
215
+
216
+ const server = http.createServer((request, response) => {
217
+ let url;
218
+ try {
219
+ url = new URL(request.url, 'http://127.0.0.1');
220
+ } catch (_) {
221
+ response.writeHead(400).end();
222
+ return;
223
+ }
224
+ if (request.method !== 'GET' || url.pathname !== CALLBACK_PATH) {
225
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('not found');
226
+ return;
227
+ }
228
+ if (done) {
229
+ // The outcome is already decided; a second callback must not be able to
230
+ // reopen it, and answering 400 keeps the response shape uniform.
231
+ response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }).end(CALLBACK_HTML_FAIL);
232
+ return;
233
+ }
234
+
235
+ const error = url.searchParams.get('error');
236
+ const code = url.searchParams.get('code');
237
+ const state = url.searchParams.get('state') || '';
238
+ const expected = Buffer.from(expectedState, 'utf8');
239
+ const actual = Buffer.from(state, 'utf8');
240
+ const stateOk = expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
241
+
242
+ const ok = stateOk && !error && Boolean(code);
243
+ response
244
+ .writeHead(ok ? 200 : 400, {
245
+ 'Content-Type': 'text/html; charset=utf-8',
246
+ 'Cache-Control': 'no-store',
247
+ // The page is inert, but it is served on localhost where any other local
248
+ // page could try to frame or script it.
249
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
250
+ 'Referrer-Policy': 'no-referrer'
251
+ })
252
+ .end(ok ? CALLBACK_HTML_OK : CALLBACK_HTML_FAIL);
253
+
254
+ if (!stateOk) {
255
+ rejectOnce(new MeteorCloudError('authorization callback state did not match', { kind: 'auth' }));
256
+ return;
257
+ }
258
+ if (error) {
259
+ rejectOnce(
260
+ new MeteorCloudError(`authorization was refused: ${error}`, { kind: 'auth', oauthError: error })
261
+ );
262
+ return;
263
+ }
264
+ if (!code) {
265
+ rejectOnce(new MeteorCloudError('authorization callback carried no code', { kind: 'auth' }));
266
+ return;
267
+ }
268
+ resolveOnce(code);
269
+ });
270
+
271
+ // A user who closed the tab and a caller who called cancel() reach the same
272
+ // place — nothing will ever arrive — so both are `cancelled`. `action` is what
273
+ // tells them apart, because a timeout is a thing to retry and a cancel is not.
274
+ const timer = setTimeout(() => {
275
+ rejectOnce(cancelled('timed out waiting for the authorization callback', 'authorize_timeout'));
276
+ }, timeoutMs);
277
+ timer.unref();
278
+
279
+ const onAbort = () => {
280
+ rejectOnce(cancelled('the authorization flow was aborted', 'authorize_aborted'));
281
+ };
282
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
283
+
284
+ const listening = new Promise((resolve, reject) => {
285
+ server.once('error', reject);
286
+ server.listen(0, '127.0.0.1', () => resolve(server.address().port));
287
+ });
288
+
289
+ return {
290
+ listening,
291
+ received,
292
+ /** Settle the wait from outside — cancel(), or a caller-side failure. */
293
+ abort(error) {
294
+ rejectOnce(error);
295
+ },
296
+ async close() {
297
+ clearTimeout(timer);
298
+ if (signal) signal.removeEventListener('abort', onAbort);
299
+ // A browser (or a probe) that kept the connection alive would otherwise
300
+ // hold `close()` open until its idle timeout: `close()` stops new
301
+ // connections but waits for established ones.
302
+ if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
303
+ await new Promise((resolve) => server.close(resolve));
304
+ }
305
+ };
306
+ }
307
+
308
+ function resolveKeyStore(options) {
309
+ if (options.keyStore) return options.keyStore;
310
+ const scheme = options.keyStoreScheme || defaultKeyStoreScheme();
311
+ if (scheme === 'file') return new FileKeyStore();
312
+ if (scheme === 'dpapi') return new DpapiKeyStore(options.dpapi || {});
313
+ if (scheme === 'cng') return new CngKeyStore(options.cng || {});
314
+ return fail(`unsupported keyStoreScheme: ${scheme}`, { kind: 'keystore' });
315
+ }
316
+
317
+ /**
318
+ * Runs the client's own `apiBase` rule ahead of time, so a URL the
319
+ * `MeteorCloudClient` constructor will refuse cannot reach the constructor
320
+ * after a bind has already succeeded.
321
+ *
322
+ * The offending path is named, never the whole URL: an issuer can carry a
323
+ * deployment-specific host and this message is the string callers log most
324
+ * reliably.
325
+ */
326
+ function assertUsableAsApiBase(value, label) {
327
+ try {
328
+ normalizeApiBase(value);
329
+ } catch (cause) {
330
+ let where = '';
331
+ try {
332
+ where = ` (path '${new URL(value).pathname}')`;
333
+ } catch (_) {
334
+ /* not even a URL: the cause already says so */
335
+ }
336
+ throw new MeteorCloudError(
337
+ `${label} cannot be used as the Device API base${where}: it must be an origin, `
338
+ + 'optionally ending in /cloud. The client built by connect() derives its Device '
339
+ + 'API base from the issuer, so this is checked before any key is generated.',
340
+ { kind: 'validation', cause }
341
+ );
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Everything that can be decided without touching the keystore, the network or
347
+ * a port, decided first. The ordering is a requirement, not a style choice: a
348
+ * rejected option must not leave a generated private key behind, and the key
349
+ * store's `wx` guard would then make the next attempt fail on a file the caller
350
+ * never asked for.
351
+ */
352
+ function validateConnectOptions(options, entry) {
353
+ if (!isObject(options)) fail('connect options must be an object');
354
+
355
+ // First, before anything else can produce a more confusing complaint: a
356
+ // caller still on the 0.4.x shape needs to be told which parameter died.
357
+ for (const [name, replacement] of Object.entries(REMOVED_CONNECT_OPTIONS)) {
358
+ if (options[name] !== undefined) {
359
+ fail(`${name} was removed in 0.5.0: ${replacement}`);
360
+ }
361
+ }
362
+
363
+ const issuer = normalizeIssuer(options.issuer);
364
+ // `normalizeIssuer` keeps any path prefix; `normalizeApiBase` accepts only
365
+ // '', '/', '/cloud' or '/cloud/'. The client built from a completed bind
366
+ // derives its Device API base from the issuer, so an issuer path outside that
367
+ // set is fatal — and used to be fatal in the *worst* possible place: after the
368
+ // browser consent, after the server registered the installation, after the
369
+ // key and installation.json reached the disk. The caller saw a bare
370
+ // `kind: 'validation'` error with no `action` and no `configPath`, read it as
371
+ // "my arguments were wrong", and called connect() again — minting a second key
372
+ // and stranding a registered installation that then needs a human in the
373
+ // console to revoke. That is exactly the retry finishConnect()'s
374
+ // `bind_registered_load_config` comment exists to prevent. Here it costs a
375
+ // string parse and happens before a single byte of key material exists.
376
+ assertUsableAsApiBase(issuer, 'issuer');
377
+ const clientApiBase = isObject(options.clientOptions) ? options.clientOptions.apiBase : undefined;
378
+ if (clientApiBase !== undefined) assertUsableAsApiBase(clientApiBase, 'clientOptions.apiBase');
379
+
380
+ const clientId = options.clientId;
381
+ if (!PRODUCT_CLIENT_IDS.has(clientId)) fail('clientId is not an approved product client');
382
+ if (typeof options.configPath !== 'string' || !options.configPath) fail('configPath is required');
383
+
384
+ const browserMode = options.browserMode ?? 'system';
385
+ if (!BROWSER_MODES.includes(browserMode)) {
386
+ fail(`browserMode must be one of ${BROWSER_MODES.join(', ')}`);
387
+ }
388
+ if (browserMode === 'manual' && options.openBrowser) {
389
+ // Contradictory instructions, and the ambiguity is the dangerous part: one
390
+ // reading opens a browser the caller asked not to open, the other silently
391
+ // drops the caller's opener and the flow hangs until the timeout.
392
+ fail("browserMode 'manual' cannot be combined with openBrowser; the caller opens the URL itself");
393
+ }
394
+ if (entry === 'connect' && browserMode === 'manual' && !options.onAuthorizeUrl) {
395
+ // Nothing in `connect()` opens the URL in manual mode, so without
396
+ // `onAuthorizeUrl` the caller never receives it and the flow sits until the
397
+ // timeout with no diagnosis. `beginConnect()` is the API for that shape:
398
+ // there the URL comes back as a return value.
399
+ fail("browserMode 'manual' requires onAuthorizeUrl, or use beginConnect() and read authorizationUrl");
400
+ }
401
+
402
+ if (!options.keyReference && (typeof options.keyPath !== 'string' || !options.keyPath)) {
403
+ fail('either keyReference (existing key) or keyPath (new key) is required');
404
+ }
405
+
406
+ const signal = options.signal;
407
+ if (signal !== undefined && (!isObject(signal) || typeof signal.addEventListener !== 'function')) {
408
+ fail('signal must be an AbortSignal');
409
+ }
410
+ if (signal && signal.aborted) {
411
+ throw cancelled('the connect flow was aborted before it started', 'authorize_aborted');
412
+ }
413
+
414
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
415
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) fail('timeoutMs must be a positive number');
416
+
417
+ return {
418
+ issuer,
419
+ clientId,
420
+ browserMode,
421
+ signal,
422
+ timeoutMs,
423
+ httpTimeoutMs: options.httpTimeoutMs ?? 30000,
424
+ transport: options.transport || defaultTransport,
425
+ randomBytes: options.randomBytes || crypto.randomBytes
426
+ };
427
+ }
428
+
429
+ /** Contract §5.3, asserted rather than trusted. See {@link AUTHORIZE_PARAMS}. */
430
+ function buildAuthorizeUrl({ issuer, clientId, redirectUri, pkce, state, publicJwk }) {
431
+ const url = new URL(authorizeEndpoint(issuer));
432
+ url.searchParams.set('response_type', 'code');
433
+ url.searchParams.set('client_id', clientId);
434
+ url.searchParams.set('redirect_uri', redirectUri);
435
+ url.searchParams.set('scope', BIND_SCOPE);
436
+ url.searchParams.set('code_challenge', pkce.challenge);
437
+ url.searchParams.set('code_challenge_method', pkce.method);
438
+ url.searchParams.set('state', state);
439
+ url.searchParams.set('installation_jwk', base64UrlEncodeJson(publicJwk));
440
+
441
+ const emitted = [...url.searchParams.keys()];
442
+ if (emitted.length !== AUTHORIZE_PARAMS.length || emitted.some((key, i) => key !== AUTHORIZE_PARAMS[i])) {
443
+ // Deliberately does not name the offending parameter or echo the URL:
444
+ // contract §10.1 forbids logging the authorize URL, and an error message is
445
+ // the most reliably logged string in the SDK.
446
+ fail('the authorize URL carries parameters outside the published contract');
447
+ }
448
+ return url.toString();
449
+ }
450
+
451
+ /**
452
+ * Phase one of the browser bind (contract §6.2): generate the local key, bind
453
+ * the loopback listener, and hand back a URL that is *already answerable*.
454
+ *
455
+ * The ordering is the whole point of the two-phase API. A caller that opens the
456
+ * URL itself — Electron `openExternal`, a controlled browser, a QR code on
457
+ * another machine — has no way to know when the SDK's port came up, and a
458
+ * consent redirect that lands on a closed port is unrecoverable: the code is
459
+ * spent and the user sees a browser error. So `beginConnect()` does not return
460
+ * until `listen()` has succeeded.
461
+ *
462
+ * The flow then runs on its own from here, not from `wait()`. The callback
463
+ * arrives whether or not anybody is awaiting, and so do the timeout and the
464
+ * abort — driving them from `wait()` would mean a caller who cancels without
465
+ * ever awaiting leaks a bound port and an orphan key.
466
+ *
467
+ * @returns {Promise<{authorizationUrl: string, wait: () => Promise<object>,
468
+ * cancel: () => Promise<void>}>}
469
+ */
470
+ async function beginConnect(options = {}) {
471
+ const plan = validateConnectOptions(options, 'beginConnect');
472
+ const keyStore = resolveKeyStore(options);
473
+
474
+ let keyReference = options.keyReference;
475
+ // Only a key *this run* generated may ever be destroyed. A caller-supplied
476
+ // `keyReference` names a key with a history — possibly a registered
477
+ // installation — and cleaning up after a failed bind must not touch it.
478
+ let orphanKeyReference = null;
479
+ if (!keyReference) {
480
+ // `keyPath` is a filesystem path for the file-backed tiers and a container
481
+ // leaf for cng://, which has no files at all — CngKeyStore takes the
482
+ // basename either way, so a caller that hands over a path still gets a
483
+ // sensible container rather than a rejection.
484
+ keyReference = await keyStore.create(options.keyPath);
485
+ orphanKeyReference = keyReference;
486
+ }
487
+
488
+ const destroyOrphanKey = async () => {
489
+ // A key generated for a bind that never completed is dead weight the user
490
+ // cannot see; leaving it behind would also make the next attempt fail on the
491
+ // 'wx' guard in the key store.
492
+ if (!orphanKeyReference) return;
493
+ const doomed = orphanKeyReference;
494
+ orphanKeyReference = null;
495
+ try {
496
+ await keyStore.destroy(doomed);
497
+ } catch (_) {
498
+ /* best effort: the bind already failed, and this is tidying */
499
+ }
500
+ };
501
+
502
+ let listener = null;
503
+ try {
504
+ const publicJwk = await keyStore.publicJwk(keyReference);
505
+ const pkce = createPkcePair(plan.randomBytes);
506
+ const state = Buffer.from(plan.randomBytes(32)).toString('base64url');
507
+
508
+ listener = startLoopbackListener({
509
+ timeoutMs: plan.timeoutMs,
510
+ expectedState: state,
511
+ signal: plan.signal
512
+ });
513
+
514
+ let port;
515
+ try {
516
+ port = await listener.listening;
517
+ } catch (cause) {
518
+ throw new MeteorCloudError('cannot bind the loopback callback listener on 127.0.0.1', {
519
+ kind: 'io',
520
+ action: 'loopback_listen',
521
+ cause
522
+ });
523
+ }
524
+ const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
525
+ const authorizationUrl = buildAuthorizeUrl({
526
+ issuer: plan.issuer,
527
+ clientId: plan.clientId,
528
+ redirectUri,
529
+ pkce,
530
+ state,
531
+ publicJwk
532
+ });
533
+
534
+ const bound = listener;
535
+ const outcome = (async () => {
536
+ try {
537
+ const code = await bound.received;
538
+ // An abort that lands between the callback and the exchange still wins:
539
+ // nothing is registered yet, so stopping here is clean, whereas an
540
+ // abort *during* the exchange could leave a registered installation
541
+ // whose key we then destroy.
542
+ if (plan.signal && plan.signal.aborted) {
543
+ throw cancelled('the authorization flow was aborted', 'authorize_aborted');
544
+ }
545
+ const exchanged = await exchangeAuthorizationCode({
546
+ transport: plan.transport,
547
+ tokenEndpoint: tokenEndpoint(plan.issuer),
548
+ code,
549
+ redirectUri,
550
+ clientId: plan.clientId,
551
+ codeVerifier: pkce.verifier,
552
+ timeoutMs: plan.httpTimeoutMs
553
+ });
554
+ // The server now holds this public key and has minted an
555
+ // installation_uid (contract §10.3). Destroying the private key past
556
+ // this point would strand a registered installation that can never
557
+ // authenticate again and has to be revoked by hand in the web console —
558
+ // and the caller would see only the filesystem error that triggered it.
559
+ // From here the key is worth more than the tidiness of not leaving one
560
+ // behind, so the orphan is disowned *before* the config write.
561
+ orphanKeyReference = null;
562
+
563
+ const config = await saveInstallationConfig(options.configPath, {
564
+ issuer: plan.issuer,
565
+ client_id: plan.clientId,
566
+ installation_uid: exchanged.installationUid,
567
+ key_reference: keyReference
568
+ });
569
+
570
+ return {
571
+ config,
572
+ configPath: options.configPath,
573
+ keyReference,
574
+ installationUid: exchanged.installationUid,
575
+ publicJwk
576
+ };
577
+ } catch (error) {
578
+ await destroyOrphanKey();
579
+ throw error;
580
+ } finally {
581
+ await bound.close();
582
+ }
583
+ })();
584
+ // cancel() without a preceding wait() is a legitimate sequence, and the
585
+ // rejection it produces belongs to whoever calls wait() — if nobody does,
586
+ // it must not reach the process as an unhandledRejection.
587
+ outcome.catch(() => {});
588
+
589
+ return {
590
+ authorizationUrl,
591
+ wait: () => outcome,
592
+ async cancel() {
593
+ bound.abort(cancelled('the authorization flow was cancelled', 'authorize_cancelled'));
594
+ try {
595
+ await outcome;
596
+ } catch (_) {
597
+ /* cancel() reports nothing; wait() is where the error surfaces */
598
+ }
599
+ }
600
+ };
601
+ } catch (error) {
602
+ if (listener) await listener.close();
603
+ await destroyOrphanKey();
604
+ throw error;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * First bind, one call: generate a local key, drive the browser through
610
+ * authorization code + PKCE, exchange the code, and persist a config file that
611
+ * contains nothing an attacker could use.
612
+ *
613
+ * A thin wrapper over {@link beginConnect} on purpose — the listener is up
614
+ * before the browser is opened, which is the only ordering in which the consent
615
+ * redirect cannot race the port.
616
+ *
617
+ * The returned object is the raw bind outcome. Minting an operational token and
618
+ * reading the account (contract §6.1's `{client, token, account, installation}`)
619
+ * happen a layer up, in index.js, which is where the client type lives.
620
+ *
621
+ * @returns {Promise<{config: object, configPath: string, keyReference: string,
622
+ * installationUid: string, publicJwk: object}>}
623
+ */
624
+ async function connect(options = {}) {
625
+ // Validated twice, on purpose: this pass is the only one that knows the call
626
+ // came through `connect()`, and it has to run *before* `beginConnect()`
627
+ // generates a key that a rejected option would orphan. The pass itself has no
628
+ // side effects, so repeating it inside `beginConnect()` costs nothing.
629
+ validateConnectOptions(options, 'connect');
630
+
631
+ const pending = await beginConnect(options);
632
+ try {
633
+ if (options.onAuthorizeUrl) await options.onAuthorizeUrl(pending.authorizationUrl);
634
+ if ((options.browserMode ?? 'system') === 'system') {
635
+ const openBrowser = options.openBrowser || openSystemBrowser;
636
+ await openBrowser(pending.authorizationUrl, options.browserInternals);
637
+ }
638
+ } catch (error) {
639
+ // Nothing will arrive on a listener whose URL never reached a browser.
640
+ // Cancelling releases the port and cleans up the key this run created,
641
+ // before the caller sees why the browser could not be opened.
642
+ await pending.cancel();
643
+ throw error;
644
+ }
645
+ return pending.wait();
646
+ }
647
+
648
+ async function exchangeAuthorizationCode(options) {
649
+ const body = new URLSearchParams({
650
+ grant_type: 'authorization_code',
651
+ code: options.code,
652
+ redirect_uri: options.redirectUri,
653
+ client_id: options.clientId,
654
+ code_verifier: options.codeVerifier
655
+ }).toString();
656
+
657
+ const response = await options.transport({
658
+ method: 'POST',
659
+ url: options.tokenEndpoint,
660
+ headers: {
661
+ Accept: 'application/json',
662
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
663
+ },
664
+ body: Buffer.from(body, 'utf8'),
665
+ timeoutMs: options.timeoutMs,
666
+ maxResponseBytes: 64 * 1024
667
+ });
668
+
669
+ let decoded;
670
+ try {
671
+ decoded = JSON.parse(Buffer.from(response.body || '').toString('utf8'));
672
+ } catch (cause) {
673
+ throw new MeteorCloudError('token endpoint returned invalid JSON', {
674
+ kind: 'auth',
675
+ httpStatus: response.status,
676
+ cause
677
+ });
678
+ }
679
+ if (response.status !== 200) {
680
+ const oauthError = isObject(decoded) && typeof decoded.error === 'string' ? decoded.error : undefined;
681
+ throw new MeteorCloudError(
682
+ oauthError ? `code exchange failed: ${oauthError}` : 'code exchange failed',
683
+ { kind: 'auth', httpStatus: response.status, oauthError }
684
+ );
685
+ }
686
+ if (!isObject(decoded)) fail('code exchange response is not an object', { kind: 'auth' });
687
+ // Contract assertion, not paranoia: this stack issues no refresh tokens at any
688
+ // layer. One appearing means the server was reconfigured in a way that
689
+ // reintroduces a long-lived bearer credential on disk.
690
+ if (decoded.refresh_token !== undefined) {
691
+ fail('code exchange returned a refresh_token, which this stack must never issue', { kind: 'auth' });
692
+ }
693
+ const installationUid = decoded.installation_uid;
694
+ if (!INSTALLATION_UID_RE.test(installationUid || '')) {
695
+ fail('code exchange response omitted a canonical installation_uid', { kind: 'auth' });
696
+ }
697
+ return {
698
+ installationUid,
699
+ // Deliberately not returned to the caller: the bind access token carries
700
+ // only `mscloud.bind` and is useless for uploads.
701
+ tokenType: typeof decoded.token_type === 'string' ? decoded.token_type : 'Bearer',
702
+ dpopNonce: headerValue(response.headers, 'dpop-nonce')
703
+ };
704
+ }
705
+
706
+ module.exports = {
707
+ connect,
708
+ beginConnect,
709
+ exchangeAuthorizationCode,
710
+ startLoopbackListener,
711
+ openSystemBrowser,
712
+ BIND_SCOPE,
713
+ CALLBACK_PATH,
714
+ AUTHORIZE_PARAMS,
715
+ BROWSER_MODES,
716
+ REMOVED_CONNECT_OPTIONS,
717
+ DEFAULT_TIMEOUT_MS
718
+ };