@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/README.md +45 -0
- package/docs/NODE_INTEGRATION_GUIDE.zh-CN.md +414 -0
- package/index.d.ts +930 -0
- package/index.js +945 -0
- package/lib/artifacts.js +279 -0
- package/lib/camera.js +316 -0
- package/lib/config.js +257 -0
- package/lib/connect.js +718 -0
- package/lib/durable.js +85 -0
- package/lib/errors.js +70 -0
- package/lib/http.js +188 -0
- package/lib/jobs.js +54 -0
- package/lib/jose.js +146 -0
- package/lib/journal.js +116 -0
- package/lib/keystore.js +585 -0
- package/lib/resources.js +408 -0
- package/lib/tokens.js +311 -0
- package/lib/upload.js +188 -0
- package/package.json +44 -0
- package/tools/migrate-key-to-dpapi.js +386 -0
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
|
+
};
|