@mdbase-dev/connect 0.1.0-beta.21
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/LICENSE +21 -0
- package/README.md +405 -0
- package/dist/async.d.ts +1 -0
- package/dist/async.js +13 -0
- package/dist/authorization-types.d.ts +7 -0
- package/dist/authorization-types.js +1 -0
- package/dist/authorization-url.d.ts +3 -0
- package/dist/authorization-url.js +27 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +21 -0
- package/dist/browser/integrity.json +4 -0
- package/dist/browser/mdbase-connect.min.js +3 -0
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
- package/dist/collection-client.d.ts +57 -0
- package/dist/collection-client.js +214 -0
- package/dist/connect.d.ts +39 -0
- package/dist/connect.js +620 -0
- package/dist/connection-notifications.d.ts +25 -0
- package/dist/connection-notifications.js +179 -0
- package/dist/connection-transport.d.ts +80 -0
- package/dist/connection-transport.js +670 -0
- package/dist/connection-types.d.ts +29 -0
- package/dist/connection-types.js +1 -0
- package/dist/connection.d.ts +154 -0
- package/dist/connection.js +371 -0
- package/dist/crypto.d.ts +64 -0
- package/dist/crypto.js +391 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +83 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/internal-types.d.ts +70 -0
- package/dist/internal-types.js +1 -0
- package/dist/notifications.d.ts +47 -0
- package/dist/notifications.js +52 -0
- package/dist/operation-helpers.d.ts +24 -0
- package/dist/operation-helpers.js +129 -0
- package/dist/operation-types.d.ts +254 -0
- package/dist/operation-types.js +1 -0
- package/dist/runtime-utils.d.ts +33 -0
- package/dist/runtime-utils.js +224 -0
- package/dist/selection.d.ts +48 -0
- package/dist/selection.js +112 -0
- package/dist/session.d.ts +76 -0
- package/dist/session.js +271 -0
- package/package.json +41 -0
package/dist/connect.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { DEFAULT_LOOPBACK_PORT, ENCRYPTED_RELAY_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { abortableDelay } from "./async.js";
|
|
3
|
+
import { randomBase64Url } from "./base64.js";
|
|
4
|
+
import { MdbaseConnection } from "./connection.js";
|
|
5
|
+
import { IndexedDbGrantKeyStore, MemoryGrantKeyStore, validateGrantEncryption } from "./crypto.js";
|
|
6
|
+
import { MdbaseConnectError } from "./errors.js";
|
|
7
|
+
import { DEFAULT_OPERATIONS } from "./internal-types.js";
|
|
8
|
+
import { uniqueOperations } from "./operation-helpers.js";
|
|
9
|
+
import { MdbaseSession } from "./session.js";
|
|
10
|
+
import { MemoryStorage, apiError, canonicalLoopbackUrl, createPkce, defaultCallbackUrl, defaultManifestSource, defaultRedirectUri, defaultStorage, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseGrantScope, parseStored, stripTrailingSlash, validAuthorityTokenResponse } from "./runtime-utils.js";
|
|
11
|
+
export class MdbaseConnect {
|
|
12
|
+
internals;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.internals = new MdbaseConnectInternals(options);
|
|
15
|
+
}
|
|
16
|
+
register() {
|
|
17
|
+
return this.internals.register();
|
|
18
|
+
}
|
|
19
|
+
authorize(options = {}) {
|
|
20
|
+
return this.internals.authorize(options);
|
|
21
|
+
}
|
|
22
|
+
createSession(options) {
|
|
23
|
+
return new MdbaseSession(this, options);
|
|
24
|
+
}
|
|
25
|
+
environment() {
|
|
26
|
+
return this.internals.environment();
|
|
27
|
+
}
|
|
28
|
+
completeAuthorization(callbackUrl = defaultCallbackUrl()) {
|
|
29
|
+
return this.internals.completeAuthorization(callbackUrl);
|
|
30
|
+
}
|
|
31
|
+
connections() {
|
|
32
|
+
return this.internals.connections();
|
|
33
|
+
}
|
|
34
|
+
connection(collectionId) {
|
|
35
|
+
return this.internals.connection(collectionId);
|
|
36
|
+
}
|
|
37
|
+
unavailableReason(collectionId) {
|
|
38
|
+
return this.internals.unavailableReason(collectionId);
|
|
39
|
+
}
|
|
40
|
+
onConnectionsChange(listener) {
|
|
41
|
+
return this.internals.onConnectionsChange(listener);
|
|
42
|
+
}
|
|
43
|
+
forgetAll() {
|
|
44
|
+
for (const connection of this.connections()) {
|
|
45
|
+
this.connection(connection.collectionId)?.forget();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
class MdbaseConnectInternals {
|
|
50
|
+
serverUrl;
|
|
51
|
+
manifest;
|
|
52
|
+
manifestSource;
|
|
53
|
+
redirectUri;
|
|
54
|
+
storage;
|
|
55
|
+
relayEncryption;
|
|
56
|
+
keyStore;
|
|
57
|
+
directAccessMode;
|
|
58
|
+
loopbackUrl;
|
|
59
|
+
navigate;
|
|
60
|
+
credentialStorage;
|
|
61
|
+
application = null;
|
|
62
|
+
completionPromises = new Map();
|
|
63
|
+
connectionCache = new Map();
|
|
64
|
+
listeners = new Set();
|
|
65
|
+
invalidatedConnections = new Map();
|
|
66
|
+
constructor(options) {
|
|
67
|
+
this.serverUrl = stripTrailingSlash(options.serverUrl);
|
|
68
|
+
this.manifest = options.manifest ?? defaultManifestSource();
|
|
69
|
+
this.manifestSource = typeof this.manifest === "string"
|
|
70
|
+
? this.manifest
|
|
71
|
+
: this.manifest.distribution === "portable"
|
|
72
|
+
? `bundle:${this.manifest.id}:${manifestStorageFingerprint(this.manifest)}`
|
|
73
|
+
: `bundle:${this.manifest.id}`;
|
|
74
|
+
const opaquePortable = isOpaquePortableManifest(this.manifest);
|
|
75
|
+
this.redirectUri = options.redirectUri ?? (typeof this.manifest !== "string" && this.manifest.distribution === "portable"
|
|
76
|
+
? ""
|
|
77
|
+
: defaultRedirectUri());
|
|
78
|
+
this.storage = options.storage ?? defaultStorage(opaquePortable);
|
|
79
|
+
this.relayEncryption = options.relayEncryption ?? "required";
|
|
80
|
+
this.keyStore = options.keyStore ?? (opaquePortable ? new MemoryGrantKeyStore() : new IndexedDbGrantKeyStore());
|
|
81
|
+
this.credentialStorage = options.storage || options.keyStore
|
|
82
|
+
? "custom"
|
|
83
|
+
: this.storage instanceof MemoryStorage ? "memory" : "persistent";
|
|
84
|
+
this.directAccessMode = options.directAccess ?? "auto";
|
|
85
|
+
this.loopbackUrl = canonicalLoopbackUrl(options.loopbackUrl ?? `http://127.0.0.1:${DEFAULT_LOOPBACK_PORT}`);
|
|
86
|
+
this.navigate = options.navigate;
|
|
87
|
+
if (typeof window !== "undefined" && this.storage === window.localStorage) {
|
|
88
|
+
window.addEventListener("storage", (event) => {
|
|
89
|
+
if (event.storageArea !== this.storage || !event.key?.startsWith(this.storagePrefix()))
|
|
90
|
+
return;
|
|
91
|
+
this.connectionCache.get(collectionIdFromTokenKey(event.key))?.notifyStorageChanged();
|
|
92
|
+
this.emitConnections();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
environment() {
|
|
97
|
+
const distribution = typeof this.manifest !== "string"
|
|
98
|
+
&& this.manifest.distribution === "portable"
|
|
99
|
+
? "portable"
|
|
100
|
+
: this.application?.distribution ?? "web";
|
|
101
|
+
return {
|
|
102
|
+
distribution,
|
|
103
|
+
applicationOrigin: distribution === "portable"
|
|
104
|
+
? "null"
|
|
105
|
+
: this.defaultApplicationOrigin(),
|
|
106
|
+
credentialStorage: this.credentialStorage
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async register() {
|
|
110
|
+
if (this.application)
|
|
111
|
+
return this.application;
|
|
112
|
+
const response = await fetch(`${this.serverUrl}/v1/apps/register`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "content-type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ manifest: await this.loadManifest() })
|
|
116
|
+
});
|
|
117
|
+
const body = await response.json();
|
|
118
|
+
if (!response.ok)
|
|
119
|
+
throw apiError(body, "discovery_failed", "Application discovery failed.", response.status);
|
|
120
|
+
this.application = body.application;
|
|
121
|
+
return this.application;
|
|
122
|
+
}
|
|
123
|
+
async loadManifest() {
|
|
124
|
+
if (typeof this.manifest !== "string")
|
|
125
|
+
return this.manifest;
|
|
126
|
+
const source = this.manifest;
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await fetch(source, {
|
|
130
|
+
headers: { accept: "application/json" }
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (cause) {
|
|
134
|
+
throw new MdbaseConnectError("manifest_load_failed", "The bundled application declaration could not be loaded.", { cause });
|
|
135
|
+
}
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
throw new MdbaseConnectError("manifest_load_failed", `The bundled application declaration returned HTTP ${response.status}.`, { status: response.status });
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return await response.json();
|
|
141
|
+
}
|
|
142
|
+
catch (cause) {
|
|
143
|
+
throw new MdbaseConnectError("invalid_application_manifest", "The bundled application declaration is not valid JSON.", { cause });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async authorize(options = {}) {
|
|
147
|
+
if (typeof location === "undefined" && !this.navigate && !options.openVerification) {
|
|
148
|
+
throw new MdbaseConnectError("browser_required", "Authorization navigation requires a browser environment.");
|
|
149
|
+
}
|
|
150
|
+
const portableDeclared = typeof this.manifest !== "string"
|
|
151
|
+
&& this.manifest.distribution === "portable";
|
|
152
|
+
const popup = portableDeclared
|
|
153
|
+
&& !options.openVerification
|
|
154
|
+
&& typeof window !== "undefined"
|
|
155
|
+
? window.open(`${this.serverUrl}/device`, "mdbase-connect-authorization", "popup,width=620,height=760")
|
|
156
|
+
: null;
|
|
157
|
+
let application;
|
|
158
|
+
try {
|
|
159
|
+
application = await this.register();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
popup?.close();
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
if (application.distribution === "portable") {
|
|
166
|
+
return this.authorizePortable(application, options, popup);
|
|
167
|
+
}
|
|
168
|
+
popup?.close();
|
|
169
|
+
const { verifier, challenge } = await createPkce();
|
|
170
|
+
const state = randomBase64Url(24);
|
|
171
|
+
const targetCollectionId = options.target?.kind === "collection"
|
|
172
|
+
? options.target.collectionId
|
|
173
|
+
: undefined;
|
|
174
|
+
const keyHandle = this.relayEncryption === "required"
|
|
175
|
+
? `grant:${application.id}:${state}`
|
|
176
|
+
: undefined;
|
|
177
|
+
const grantKey = keyHandle ? await this.keyStore.create(keyHandle) : undefined;
|
|
178
|
+
const pending = {
|
|
179
|
+
version: 1,
|
|
180
|
+
verifier,
|
|
181
|
+
state,
|
|
182
|
+
clientId: application.id,
|
|
183
|
+
redirectUri: this.redirectUri,
|
|
184
|
+
relayEncryption: this.relayEncryption,
|
|
185
|
+
collectionId: targetCollectionId,
|
|
186
|
+
returnTo: options.returnTo,
|
|
187
|
+
keyHandle,
|
|
188
|
+
applicationAgreementPublicKey: grantKey?.agreementPublicKey,
|
|
189
|
+
applicationSigningPublicKey: grantKey?.signingPublicKey
|
|
190
|
+
};
|
|
191
|
+
this.storage.setItem(this.pendingKey(state), JSON.stringify(pending));
|
|
192
|
+
const authorize = new URL(`${this.serverUrl}/oauth/authorize`);
|
|
193
|
+
authorize.searchParams.set("client_id", application.id);
|
|
194
|
+
authorize.searchParams.set("redirect_uri", this.redirectUri);
|
|
195
|
+
authorize.searchParams.set("code_challenge", challenge);
|
|
196
|
+
authorize.searchParams.set("code_challenge_method", "S256");
|
|
197
|
+
authorize.searchParams.set("state", state);
|
|
198
|
+
authorize.searchParams.set("operations", uniqueOperations(options.operations ?? DEFAULT_OPERATIONS).join(","));
|
|
199
|
+
if (targetCollectionId) {
|
|
200
|
+
authorize.searchParams.set("collection_id", targetCollectionId);
|
|
201
|
+
}
|
|
202
|
+
if (grantKey) {
|
|
203
|
+
authorize.searchParams.set("relay_protocol", "1");
|
|
204
|
+
authorize.searchParams.set("application_agreement_public_key", grantKey.agreementPublicKey);
|
|
205
|
+
authorize.searchParams.set("application_signing_public_key", grantKey.signingPublicKey);
|
|
206
|
+
}
|
|
207
|
+
if (this.navigate)
|
|
208
|
+
await this.navigate(authorize.href);
|
|
209
|
+
else
|
|
210
|
+
location.assign(authorize.href);
|
|
211
|
+
return { kind: "redirecting" };
|
|
212
|
+
}
|
|
213
|
+
async authorizePortable(application, options, popup) {
|
|
214
|
+
if (this.relayEncryption !== "required") {
|
|
215
|
+
popup?.close();
|
|
216
|
+
throw new MdbaseConnectError("encryption_required", "Downloaded applications require encrypted relay authorization.");
|
|
217
|
+
}
|
|
218
|
+
const { verifier, challenge } = await createPkce();
|
|
219
|
+
const keyHandle = `grant:${application.id}:${randomBase64Url(24)}`;
|
|
220
|
+
const grantKey = await this.keyStore.create(keyHandle);
|
|
221
|
+
let response;
|
|
222
|
+
let body;
|
|
223
|
+
try {
|
|
224
|
+
response = await fetch(`${this.serverUrl}/oauth/device_authorization`, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
227
|
+
body: new URLSearchParams({
|
|
228
|
+
client_id: application.id,
|
|
229
|
+
operations: uniqueOperations(options.operations ?? DEFAULT_OPERATIONS).join(","),
|
|
230
|
+
...(options.target?.kind === "collection"
|
|
231
|
+
? { collection_id: options.target.collectionId }
|
|
232
|
+
: {}),
|
|
233
|
+
code_challenge: challenge,
|
|
234
|
+
code_challenge_method: "S256",
|
|
235
|
+
relay_protocol: "1",
|
|
236
|
+
application_agreement_public_key: grantKey.agreementPublicKey,
|
|
237
|
+
application_signing_public_key: grantKey.signingPublicKey
|
|
238
|
+
})
|
|
239
|
+
});
|
|
240
|
+
body = await response.json();
|
|
241
|
+
}
|
|
242
|
+
catch (cause) {
|
|
243
|
+
popup?.close();
|
|
244
|
+
await this.keyStore.delete(keyHandle);
|
|
245
|
+
throw new MdbaseConnectError("device_authorization_failed", "Downloaded application authorization could not be started.", { cause });
|
|
246
|
+
}
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
popup?.close();
|
|
249
|
+
await this.keyStore.delete(keyHandle);
|
|
250
|
+
throw apiError(body, "device_authorization_failed", "Downloaded application authorization could not be started.", response.status);
|
|
251
|
+
}
|
|
252
|
+
let authorization;
|
|
253
|
+
try {
|
|
254
|
+
authorization = parseDeviceAuthorization(body);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
popup?.close();
|
|
258
|
+
await this.keyStore.delete(keyHandle);
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
options.onDeviceCode?.(authorization);
|
|
262
|
+
if (options.openVerification) {
|
|
263
|
+
await options.openVerification(authorization);
|
|
264
|
+
}
|
|
265
|
+
else if (popup) {
|
|
266
|
+
try {
|
|
267
|
+
popup.location.href = authorization.verificationUriComplete;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// The already-open verification page still accepts the displayed code.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
await this.keyStore.delete(keyHandle);
|
|
275
|
+
throw new MdbaseConnectError("approval_window_blocked", "The approval window was blocked. Show the provided verification link and code, then try again.", { details: authorization });
|
|
276
|
+
}
|
|
277
|
+
let intervalSeconds = authorization.intervalSeconds;
|
|
278
|
+
try {
|
|
279
|
+
while (Date.now() < authorization.expiresAt) {
|
|
280
|
+
await abortableDelay(intervalSeconds * 1_000, options.signal);
|
|
281
|
+
if (options.signal?.aborted) {
|
|
282
|
+
throw new MdbaseConnectError("authorization_cancelled", "Downloaded application authorization was cancelled.");
|
|
283
|
+
}
|
|
284
|
+
let tokenResponse;
|
|
285
|
+
try {
|
|
286
|
+
tokenResponse = await fetch(`${this.serverUrl}/oauth/token`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
289
|
+
body: new URLSearchParams({
|
|
290
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
291
|
+
device_code: body.device_code,
|
|
292
|
+
client_id: application.id,
|
|
293
|
+
code_verifier: verifier
|
|
294
|
+
}),
|
|
295
|
+
signal: options.signal
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (cause) {
|
|
299
|
+
if (options.signal?.aborted) {
|
|
300
|
+
throw new MdbaseConnectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause });
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const tokenBody = await tokenResponse.json();
|
|
305
|
+
if (!tokenResponse.ok) {
|
|
306
|
+
const code = oauthErrorCode(tokenBody);
|
|
307
|
+
if (code === "authorization_pending")
|
|
308
|
+
continue;
|
|
309
|
+
if (code === "slow_down") {
|
|
310
|
+
intervalSeconds += 5;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
throw apiError(tokenBody, "token_exchange_failed", "Downloaded application authorization could not be completed.", tokenResponse.status);
|
|
314
|
+
}
|
|
315
|
+
const authority = validAuthorityTokenResponse(tokenBody.authority, tokenBody.collection_id);
|
|
316
|
+
const localEncryption = tokenBody.encryption
|
|
317
|
+
&& tokenBody.encryption.protocol_version === ENCRYPTED_RELAY_PROTOCOL_VERSION
|
|
318
|
+
&& tokenBody.encryption.suite === RELAY_ENCRYPTION_SUITE
|
|
319
|
+
&& tokenBody.encryption.application_agreement_public_key
|
|
320
|
+
=== grantKey.agreementPublicKey;
|
|
321
|
+
if (tokenBody.application_origin !== "null") {
|
|
322
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization did not bind the portable grant to its opaque application origin.");
|
|
323
|
+
}
|
|
324
|
+
if (tokenBody.authority
|
|
325
|
+
&& (!authority
|
|
326
|
+
|| tokenBody.encryption != null
|
|
327
|
+
|| tokenBody.authority.proof_public_key !== grantKey.signingPublicKey)) {
|
|
328
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization returned a remote authority capability that is not bound to this portable grant key.");
|
|
329
|
+
}
|
|
330
|
+
if (!tokenBody.authority && !localEncryption) {
|
|
331
|
+
throw new MdbaseConnectError("encryption_required", "Authorization did not establish the expected key-bound local portable grant.");
|
|
332
|
+
}
|
|
333
|
+
const token = this.storeTokenResponse(tokenBody, application.id, keyHandle);
|
|
334
|
+
if (options.target?.kind === "collection"
|
|
335
|
+
&& options.target.collectionId !== token.collectionId) {
|
|
336
|
+
this.removeToken(token.collectionId, token.keyHandle);
|
|
337
|
+
throw new MdbaseConnectError("collection_mismatch", "The approved collection does not match the collection requested by this link.");
|
|
338
|
+
}
|
|
339
|
+
popup?.close();
|
|
340
|
+
return {
|
|
341
|
+
kind: "connected",
|
|
342
|
+
connection: this.connection(token.collectionId),
|
|
343
|
+
...(options.returnTo ? { returnTo: options.returnTo } : {})
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
throw new MdbaseConnectError("expired_token", "The downloaded application authorization code expired.");
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
popup?.close();
|
|
350
|
+
await this.keyStore.delete(keyHandle);
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
completeAuthorization(callbackUrl) {
|
|
355
|
+
const state = new URL(callbackUrl).searchParams.get("state");
|
|
356
|
+
if (!state) {
|
|
357
|
+
return Promise.reject(new MdbaseConnectError("invalid_callback", "Authorization callback is missing its state."));
|
|
358
|
+
}
|
|
359
|
+
const existing = this.completionPromises.get(state);
|
|
360
|
+
if (existing)
|
|
361
|
+
return existing;
|
|
362
|
+
const completion = this.performAuthorizationCompletion(callbackUrl, state);
|
|
363
|
+
const shared = completion.finally(() => {
|
|
364
|
+
if (this.completionPromises.get(state) === shared)
|
|
365
|
+
this.completionPromises.delete(state);
|
|
366
|
+
});
|
|
367
|
+
this.completionPromises.set(state, shared);
|
|
368
|
+
return shared;
|
|
369
|
+
}
|
|
370
|
+
async performAuthorizationCompletion(callbackUrl, state) {
|
|
371
|
+
const callback = new URL(callbackUrl);
|
|
372
|
+
const code = callback.searchParams.get("code");
|
|
373
|
+
const pendingKey = this.pendingKey(state);
|
|
374
|
+
const pending = parseStored(this.storage.getItem(pendingKey));
|
|
375
|
+
if (!pending || pending.version !== 1 || state !== pending.state) {
|
|
376
|
+
throw new MdbaseConnectError("invalid_callback", "Authorization callback is missing or does not match this browser session.");
|
|
377
|
+
}
|
|
378
|
+
if (callback.searchParams.has("error")) {
|
|
379
|
+
if (pending.keyHandle)
|
|
380
|
+
await this.keyStore.delete(pending.keyHandle);
|
|
381
|
+
this.storage.removeItem(pendingKey);
|
|
382
|
+
throw new MdbaseConnectError(callback.searchParams.get("error") ?? "access_denied", callback.searchParams.get("error_description") ?? "Collection access was not approved.", { details: pending.returnTo ? { returnTo: pending.returnTo } : undefined });
|
|
383
|
+
}
|
|
384
|
+
if (!code) {
|
|
385
|
+
throw new MdbaseConnectError("invalid_callback", "Authorization callback is missing its code.");
|
|
386
|
+
}
|
|
387
|
+
const response = await fetch(`${this.serverUrl}/oauth/token`, {
|
|
388
|
+
method: "POST",
|
|
389
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
390
|
+
body: new URLSearchParams({
|
|
391
|
+
grant_type: "authorization_code",
|
|
392
|
+
code,
|
|
393
|
+
client_id: pending.clientId,
|
|
394
|
+
redirect_uri: pending.redirectUri,
|
|
395
|
+
code_verifier: pending.verifier
|
|
396
|
+
})
|
|
397
|
+
});
|
|
398
|
+
const body = await response.json();
|
|
399
|
+
if (!response.ok)
|
|
400
|
+
throw apiError(body, "token_exchange_failed", "Authorization could not be completed.", response.status);
|
|
401
|
+
if (pending.relayEncryption === "required" && !body.authority && (!body.encryption
|
|
402
|
+
|| !pending.keyHandle
|
|
403
|
+
|| body.encryption.application_agreement_public_key
|
|
404
|
+
!== pending.applicationAgreementPublicKey)) {
|
|
405
|
+
if (pending.keyHandle)
|
|
406
|
+
await this.keyStore.delete(pending.keyHandle);
|
|
407
|
+
this.storage.removeItem(pendingKey);
|
|
408
|
+
throw new MdbaseConnectError("encryption_required", "Authorization did not establish the required encrypted relay grant.");
|
|
409
|
+
}
|
|
410
|
+
if (body.authority?.proof_public_key) {
|
|
411
|
+
if (!pending.keyHandle
|
|
412
|
+
|| !pending.applicationSigningPublicKey
|
|
413
|
+
|| body.authority.proof_public_key !== pending.applicationSigningPublicKey) {
|
|
414
|
+
if (pending.keyHandle)
|
|
415
|
+
await this.keyStore.delete(pending.keyHandle);
|
|
416
|
+
this.storage.removeItem(pendingKey);
|
|
417
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization returned a remote authority capability bound to another application key.");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else if (body.authority && pending.keyHandle) {
|
|
421
|
+
await this.keyStore.delete(pending.keyHandle);
|
|
422
|
+
}
|
|
423
|
+
const token = this.storeTokenResponse(body, pending.clientId, body.authority?.proof_public_key ? pending.keyHandle : body.authority ? undefined : pending.keyHandle);
|
|
424
|
+
if (pending.collectionId && pending.collectionId !== token.collectionId) {
|
|
425
|
+
this.removeToken(token.collectionId, token.keyHandle);
|
|
426
|
+
this.storage.removeItem(pendingKey);
|
|
427
|
+
throw new MdbaseConnectError("collection_mismatch", "The approved collection does not match the collection requested by this link.");
|
|
428
|
+
}
|
|
429
|
+
this.storage.removeItem(pendingKey);
|
|
430
|
+
return {
|
|
431
|
+
connection: this.connection(token.collectionId),
|
|
432
|
+
...(pending.returnTo ? { returnTo: pending.returnTo } : {})
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
connections() {
|
|
436
|
+
const connections = [];
|
|
437
|
+
for (const collectionId of this.connectionIds()) {
|
|
438
|
+
const info = this.connection(collectionId)?.info();
|
|
439
|
+
if (info)
|
|
440
|
+
connections.push(info);
|
|
441
|
+
}
|
|
442
|
+
return connections.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.collectionId.localeCompare(right.collectionId));
|
|
443
|
+
}
|
|
444
|
+
connection(collectionId) {
|
|
445
|
+
if (!this.storage.getItem(this.tokenKey(collectionId)))
|
|
446
|
+
return null;
|
|
447
|
+
let connection = this.connectionCache.get(collectionId);
|
|
448
|
+
if (!connection) {
|
|
449
|
+
connection = new MdbaseConnection(this, collectionId);
|
|
450
|
+
this.connectionCache.set(collectionId, connection);
|
|
451
|
+
}
|
|
452
|
+
return connection.info() ? connection : null;
|
|
453
|
+
}
|
|
454
|
+
unavailableReason(collectionId) {
|
|
455
|
+
return this.invalidatedConnections.get(collectionId) ?? null;
|
|
456
|
+
}
|
|
457
|
+
onConnectionsChange(listener) {
|
|
458
|
+
this.listeners.add(listener);
|
|
459
|
+
listener(this.connections());
|
|
460
|
+
return () => this.listeners.delete(listener);
|
|
461
|
+
}
|
|
462
|
+
storeTokenResponse(body, clientId, keyHandle) {
|
|
463
|
+
const collectionId = body.collection_id;
|
|
464
|
+
if (typeof collectionId !== "string") {
|
|
465
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization returned no collection ID.");
|
|
466
|
+
}
|
|
467
|
+
const scope = parseGrantScope(body.scope);
|
|
468
|
+
if (!scope) {
|
|
469
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization returned no valid collection scope.");
|
|
470
|
+
}
|
|
471
|
+
if (body.authority && !validAuthorityTokenResponse(body.authority, collectionId)) {
|
|
472
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization returned an invalid remote authority capability.");
|
|
473
|
+
}
|
|
474
|
+
if (body.authority && body.encryption) {
|
|
475
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization returned conflicting collection transports.");
|
|
476
|
+
}
|
|
477
|
+
if (body.encryption) {
|
|
478
|
+
try {
|
|
479
|
+
validateGrantEncryption(body.encryption);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization returned an invalid encrypted relay binding.");
|
|
483
|
+
}
|
|
484
|
+
if (body.encryption.collection_id !== collectionId
|
|
485
|
+
|| typeof body.grant_id !== "string"
|
|
486
|
+
|| body.grant_id.length === 0) {
|
|
487
|
+
throw new MdbaseConnectError("invalid_token_response", "Authorization returned an encrypted relay binding for another grant.");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const previous = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
|
|
491
|
+
if (previous?.encryption
|
|
492
|
+
&& body.encryption
|
|
493
|
+
&& previous.keyHandle
|
|
494
|
+
&& previous.keyHandle === keyHandle
|
|
495
|
+
&& (previous.grantId !== body.grant_id
|
|
496
|
+
|| previous.encryption.connector_id !== body.encryption.connector_id
|
|
497
|
+
|| previous.encryption.connector_agreement_public_key
|
|
498
|
+
!== body.encryption.connector_agreement_public_key
|
|
499
|
+
|| previous.encryption.application_agreement_public_key
|
|
500
|
+
!== body.encryption.application_agreement_public_key)) {
|
|
501
|
+
throw new MdbaseConnectError("connector_identity_changed", "The connector identity changed during authorization renewal. Reauthorize before sending collection data.", { requiresAuthorization: true, recovery: "reauthorize" });
|
|
502
|
+
}
|
|
503
|
+
if (previous?.keyHandle && previous.keyHandle !== keyHandle) {
|
|
504
|
+
void this.keyStore.delete(previous.keyHandle).catch(() => undefined);
|
|
505
|
+
}
|
|
506
|
+
const token = {
|
|
507
|
+
version: 1,
|
|
508
|
+
accessToken: body.access_token,
|
|
509
|
+
refreshToken: body.refresh_token,
|
|
510
|
+
clientId,
|
|
511
|
+
collectionId,
|
|
512
|
+
collectionName: body.collection_name ?? `Collection ${collectionId.slice(0, 8)}`,
|
|
513
|
+
operations: body.operations,
|
|
514
|
+
scope,
|
|
515
|
+
expiresAt: Date.now() + body.expires_in * 1_000,
|
|
516
|
+
refreshExpiresAt: body.refresh_expires_in
|
|
517
|
+
? Date.now() + body.refresh_expires_in * 1_000
|
|
518
|
+
: undefined,
|
|
519
|
+
grantId: body.grant_id,
|
|
520
|
+
encryption: body.encryption ?? undefined,
|
|
521
|
+
applicationOrigin: body.application_origin ?? this.defaultApplicationOrigin(),
|
|
522
|
+
keyHandle,
|
|
523
|
+
savedAt: Date.now(),
|
|
524
|
+
authority: body.authority ? {
|
|
525
|
+
operationsUrl: body.authority.operations_url,
|
|
526
|
+
syncUrl: body.authority.sync_url,
|
|
527
|
+
replicaId: body.authority.replica_id,
|
|
528
|
+
accessToken: body.authority.access_token,
|
|
529
|
+
proofPublicKey: body.authority.proof_public_key
|
|
530
|
+
} : undefined
|
|
531
|
+
};
|
|
532
|
+
this.storage.setItem(this.tokenKey(collectionId), JSON.stringify(token));
|
|
533
|
+
this.addConnectionId(collectionId);
|
|
534
|
+
this.invalidatedConnections.delete(collectionId);
|
|
535
|
+
this.connectionCache.delete(collectionId);
|
|
536
|
+
this.emitConnections();
|
|
537
|
+
return token;
|
|
538
|
+
}
|
|
539
|
+
removeToken(collectionId, keyHandle, reason = "authorization_lost") {
|
|
540
|
+
this.invalidatedConnections.set(collectionId, reason);
|
|
541
|
+
if (keyHandle)
|
|
542
|
+
void this.keyStore.delete(keyHandle).catch(() => undefined);
|
|
543
|
+
this.storage.removeItem(this.tokenKey(collectionId));
|
|
544
|
+
this.storage.removeItem(this.pendingMutationKey(collectionId));
|
|
545
|
+
for (const transport of ["web_push", "fcm"]) {
|
|
546
|
+
this.storage.removeItem(this.notificationKey(collectionId, transport));
|
|
547
|
+
}
|
|
548
|
+
this.storage.setItem(this.connectionsKey(), JSON.stringify({
|
|
549
|
+
version: 1,
|
|
550
|
+
collectionIds: this.connectionIds().filter((id) => id !== collectionId)
|
|
551
|
+
}));
|
|
552
|
+
this.connectionCache.delete(collectionId);
|
|
553
|
+
this.emitConnections();
|
|
554
|
+
}
|
|
555
|
+
tokenKey(collectionId) {
|
|
556
|
+
return `${this.storagePrefix()}:token:${collectionId}`;
|
|
557
|
+
}
|
|
558
|
+
pendingMutationKey(collectionId) {
|
|
559
|
+
return `${this.storagePrefix()}:pending-mutation:${collectionId}`;
|
|
560
|
+
}
|
|
561
|
+
notificationKey(collectionId, transport = "web_push") {
|
|
562
|
+
return `${this.storagePrefix()}:notifications:${collectionId}:${transport}`;
|
|
563
|
+
}
|
|
564
|
+
directPreferenceKey() {
|
|
565
|
+
return `mdbase-connect:direct:${this.defaultApplicationOrigin()}`;
|
|
566
|
+
}
|
|
567
|
+
defaultApplicationOrigin() {
|
|
568
|
+
if ((typeof this.manifest !== "string" && this.manifest.distribution === "portable")
|
|
569
|
+
|| this.application?.distribution === "portable") {
|
|
570
|
+
return "null";
|
|
571
|
+
}
|
|
572
|
+
const redirect = new URL(this.redirectUri);
|
|
573
|
+
if (["http:", "https:"].includes(redirect.protocol))
|
|
574
|
+
return redirect.origin;
|
|
575
|
+
if (typeof location !== "undefined")
|
|
576
|
+
return location.origin;
|
|
577
|
+
if (this.manifest
|
|
578
|
+
&& typeof this.manifest !== "string") {
|
|
579
|
+
return new URL(this.manifest.homepage).origin;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
return new URL(this.manifestSource).origin;
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return "";
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
pendingKey(state) {
|
|
589
|
+
return `${this.storagePrefix()}:pending:${state}`;
|
|
590
|
+
}
|
|
591
|
+
storagePrefix() {
|
|
592
|
+
return `mdbase-connect:${this.serverUrl}:${this.manifestSource}`;
|
|
593
|
+
}
|
|
594
|
+
connectionsKey() {
|
|
595
|
+
return `${this.storagePrefix()}:connections`;
|
|
596
|
+
}
|
|
597
|
+
connectionIds() {
|
|
598
|
+
const index = parseStored(this.storage.getItem(this.connectionsKey()));
|
|
599
|
+
return index?.version === 1 && Array.isArray(index.collectionIds)
|
|
600
|
+
&& index.collectionIds.every((collectionId) => typeof collectionId === "string")
|
|
601
|
+
? index.collectionIds
|
|
602
|
+
: [];
|
|
603
|
+
}
|
|
604
|
+
addConnectionId(collectionId) {
|
|
605
|
+
this.storage.setItem(this.connectionsKey(), JSON.stringify({
|
|
606
|
+
version: 1,
|
|
607
|
+
collectionIds: [...new Set([...this.connectionIds(), collectionId])]
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
emitConnections() {
|
|
611
|
+
const connections = this.connections();
|
|
612
|
+
for (const listener of this.listeners)
|
|
613
|
+
listener(connections);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function collectionIdFromTokenKey(key) {
|
|
617
|
+
const marker = ":token:";
|
|
618
|
+
const index = key.lastIndexOf(marker);
|
|
619
|
+
return index < 0 ? "" : key.slice(index + marker.length);
|
|
620
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Application } from "./internal-types.js";
|
|
2
|
+
import type { StoredToken } from "./internal-types.js";
|
|
3
|
+
import type { MdbaseNativeNotificationRegistration, MdbaseNativeNotificationRegistrationOptions, MdbaseNotificationRegistration, MdbaseNotificationRegistrationOptions } from "./notifications.js";
|
|
4
|
+
export interface ConnectionNotificationContext {
|
|
5
|
+
serverUrl: string;
|
|
6
|
+
storage: Storage;
|
|
7
|
+
authorizedToken(): Promise<StoredToken | null>;
|
|
8
|
+
register(): Promise<Application>;
|
|
9
|
+
notificationKey(transport?: "web_push" | "fcm"): string;
|
|
10
|
+
}
|
|
11
|
+
export declare class ConnectionNotifications {
|
|
12
|
+
private readonly context;
|
|
13
|
+
constructor(context: ConnectionNotificationContext);
|
|
14
|
+
registerNotifications(options: MdbaseNotificationRegistrationOptions): Promise<MdbaseNotificationRegistration>;
|
|
15
|
+
/**
|
|
16
|
+
* Register an iOS or Android installation for Connect-managed FCM.
|
|
17
|
+
*
|
|
18
|
+
* The application manifest selects the Firebase project. Re-register the
|
|
19
|
+
* same installation whenever Firebase refreshes its token.
|
|
20
|
+
*/
|
|
21
|
+
registerNativeNotifications(options: MdbaseNativeNotificationRegistrationOptions): Promise<MdbaseNativeNotificationRegistration>;
|
|
22
|
+
unregisterNativeNotifications(): Promise<void>;
|
|
23
|
+
unregisterNotifications(serviceWorker?: ServiceWorkerRegistration): Promise<void>;
|
|
24
|
+
private deleteNotificationChannel;
|
|
25
|
+
}
|