@mdbase-dev/connect 0.1.0-beta.23 → 0.1.0-beta.25
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 +26 -8
- package/dist/application-identity.d.ts +5 -0
- package/dist/application-identity.js +56 -0
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +3 -3
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/connect-options.d.ts +22 -0
- package/dist/connect-options.js +1 -0
- package/dist/connect.d.ts +40 -29
- package/dist/connect.js +183 -79
- package/dist/connection.d.ts +14 -2
- package/dist/first-contact-presentation.d.ts +5 -0
- package/dist/first-contact-presentation.js +51 -0
- package/dist/first-contact.d.ts +14 -0
- package/dist/first-contact.js +145 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/internal-types.d.ts +5 -1
- package/dist/mdbase-connect.d.ts +21 -0
- package/dist/mdbase-connect.js +42 -0
- package/dist/outcomes.d.ts +3 -3
- package/dist/outcomes.js +2 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
sha384-
|
|
1
|
+
sha384-Rejwyx0zN9iv54K0UaKMWs09sQLXcxnOBtKT574KVKlC7aR4FZ7CqpPRMd7mKlNl
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { JsonObject, MdbaseAppManifest } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import type { GrantKeyStore } from "./crypto.js";
|
|
3
|
+
export interface MdbaseConnectOptions {
|
|
4
|
+
serverUrl: string;
|
|
5
|
+
/**
|
|
6
|
+
* A bundled v1 application manifest or its app-local URL. String values
|
|
7
|
+
* are loaded by this SDK and posted inline; Connect never fetches them.
|
|
8
|
+
*/
|
|
9
|
+
manifest?: MdbaseAppManifest | string;
|
|
10
|
+
redirectUri?: string;
|
|
11
|
+
storage?: Storage;
|
|
12
|
+
/** Encrypted relay is required by default for newly authorized grants. */
|
|
13
|
+
relayEncryption?: "required" | "disabled";
|
|
14
|
+
keyStore?: GrantKeyStore;
|
|
15
|
+
/** Prefer same-computer connector access when the browser permits it. */
|
|
16
|
+
directAccess?: "auto" | "disabled";
|
|
17
|
+
/** Loopback origin override for development and automated testing. */
|
|
18
|
+
loopbackUrl?: string;
|
|
19
|
+
/** Override browser navigation, for example to use a native system browser. */
|
|
20
|
+
navigate?: (url: string) => void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export type MdbaseFrontmatter = JsonObject;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/connect.d.ts
CHANGED
|
@@ -1,40 +1,51 @@
|
|
|
1
1
|
import type { JsonObject, MdbaseAppManifest } from "@mdbase-dev/connect-protocol";
|
|
2
2
|
import { MdbaseConnection, type MdbaseAuthorizationOutcome, type MdbaseAuthorizationResult, type MdbaseAuthorizeOptions, type MdbaseConnectEnvironment } from "./connection.js";
|
|
3
|
+
import type { MdbaseConnectOptions } from "./connect-options.js";
|
|
3
4
|
import type { MdbaseConnectionInfo } from "./connection-types.js";
|
|
4
5
|
import { type GrantKeyStore } from "./crypto.js";
|
|
5
|
-
import { type Application } from "./internal-types.js";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
/** Override browser navigation, for example to use a native system browser. */
|
|
25
|
-
navigate?: (url: string) => void | Promise<void>;
|
|
26
|
-
}
|
|
27
|
-
export declare class MdbaseConnect<Frontmatter extends JsonObject = JsonObject> {
|
|
28
|
-
private readonly internals;
|
|
6
|
+
import { type Application, type StoredToken } from "./internal-types.js";
|
|
7
|
+
import type { MdbaseUnavailableReason } from "./session.js";
|
|
8
|
+
export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
|
|
9
|
+
readonly serverUrl: string;
|
|
10
|
+
readonly manifest: MdbaseAppManifest | string;
|
|
11
|
+
readonly manifestSource: string;
|
|
12
|
+
readonly redirectUri: string;
|
|
13
|
+
readonly storage: Storage;
|
|
14
|
+
readonly relayEncryption: "required" | "disabled";
|
|
15
|
+
readonly keyStore: GrantKeyStore;
|
|
16
|
+
readonly directAccessMode: "auto" | "disabled";
|
|
17
|
+
readonly loopbackUrl: string;
|
|
18
|
+
readonly navigate?: (url: string) => void | Promise<void>;
|
|
19
|
+
readonly credentialStorage: MdbaseConnectEnvironment["credentialStorage"];
|
|
20
|
+
private application;
|
|
21
|
+
private readonly completionPromises;
|
|
22
|
+
private readonly connectionCache;
|
|
23
|
+
private readonly listeners;
|
|
24
|
+
private readonly invalidatedConnections;
|
|
29
25
|
constructor(options: MdbaseConnectOptions);
|
|
30
|
-
register(): Promise<ConnectOutcome<Application, RegistrationProblemCode>>;
|
|
31
|
-
authorize(options?: MdbaseAuthorizeOptions): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter>, AuthorizationProblemCode>>;
|
|
32
|
-
createSession(options: MdbaseSessionOptions): MdbaseSession<Frontmatter>;
|
|
33
26
|
environment(): MdbaseConnectEnvironment;
|
|
34
|
-
|
|
27
|
+
register(): Promise<Application>;
|
|
28
|
+
private loadManifest;
|
|
29
|
+
authorize(options?: MdbaseAuthorizeOptions): Promise<MdbaseAuthorizationOutcome<Frontmatter>>;
|
|
30
|
+
private authorizeWeb;
|
|
31
|
+
private authorizePortable;
|
|
32
|
+
completeAuthorization(callbackUrl: string): Promise<MdbaseAuthorizationResult<Frontmatter>>;
|
|
33
|
+
private performAuthorizationCompletion;
|
|
35
34
|
connections(): MdbaseConnectionInfo[];
|
|
36
35
|
connection(collectionId: string): MdbaseConnection<Frontmatter> | null;
|
|
37
36
|
unavailableReason(collectionId: string): MdbaseUnavailableReason | null;
|
|
38
37
|
onConnectionsChange(listener: (connections: MdbaseConnectionInfo[]) => void): () => void;
|
|
39
|
-
|
|
38
|
+
storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
|
|
39
|
+
removeToken(collectionId: string, keyHandle?: string, reason?: MdbaseUnavailableReason): void;
|
|
40
|
+
tokenKey(collectionId: string): string;
|
|
41
|
+
pendingMutationKey(collectionId: string): string;
|
|
42
|
+
notificationKey(collectionId: string, transport?: "web_push" | "fcm"): string;
|
|
43
|
+
directPreferenceKey(): string;
|
|
44
|
+
defaultApplicationOrigin(): string;
|
|
45
|
+
private pendingKey;
|
|
46
|
+
private storagePrefix;
|
|
47
|
+
private connectionsKey;
|
|
48
|
+
private connectionIds;
|
|
49
|
+
private addConnectionId;
|
|
50
|
+
private emitConnections;
|
|
40
51
|
}
|
package/dist/connect.js
CHANGED
|
@@ -1,53 +1,15 @@
|
|
|
1
|
-
import { DEFAULT_LOOPBACK_PORT, ENCRYPTED_RELAY_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
|
|
1
|
+
import { FIRST_CONTACT_PROTOCOL_VERSION, DEFAULT_LOOPBACK_PORT, ENCRYPTED_RELAY_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
|
|
2
2
|
import { abortableDelay } from "./async.js";
|
|
3
3
|
import { randomBase64Url } from "./base64.js";
|
|
4
|
+
import { applicationInstallationId, signApplicationAuthorization } from "./application-identity.js";
|
|
4
5
|
import { MdbaseConnection } from "./connection.js";
|
|
5
6
|
import { IndexedDbGrantKeyStore, MemoryGrantKeyStore, validateGrantEncryption } from "./crypto.js";
|
|
7
|
+
import { applicationIdentity, presentFirstContact } from "./first-contact-presentation.js";
|
|
6
8
|
import { connectError, serverConnectError } from "./errors.js";
|
|
7
9
|
import { DEFAULT_OPERATIONS } from "./internal-types.js";
|
|
8
10
|
import { uniqueOperations } from "./operation-helpers.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
import { MemoryStorage, apiError, canonicalLoopbackUrl, connectFetch, createPkce, defaultCallbackUrl, defaultManifestSource, defaultRedirectUri, defaultStorage, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseGrantScope, parseStored, stripTrailingSlash, validFileCapability, validAuthorityTokenResponse } from "./runtime-utils.js";
|
|
12
|
-
export class MdbaseConnect {
|
|
13
|
-
internals;
|
|
14
|
-
constructor(options) {
|
|
15
|
-
this.internals = new MdbaseConnectInternals(options);
|
|
16
|
-
}
|
|
17
|
-
register() {
|
|
18
|
-
return captureConnectOutcome(() => this.internals.register(), REGISTRATION_PROBLEM_CODES);
|
|
19
|
-
}
|
|
20
|
-
authorize(options = {}) {
|
|
21
|
-
return captureConnectOutcome(() => this.internals.authorize(options), AUTHORIZATION_PROBLEM_CODES);
|
|
22
|
-
}
|
|
23
|
-
createSession(options) {
|
|
24
|
-
return new MdbaseSession(this, options);
|
|
25
|
-
}
|
|
26
|
-
environment() {
|
|
27
|
-
return this.internals.environment();
|
|
28
|
-
}
|
|
29
|
-
completeAuthorization(callbackUrl) {
|
|
30
|
-
return captureConnectOutcome(() => this.internals.completeAuthorization(callbackUrl ?? defaultCallbackUrl()), AUTHORIZATION_PROBLEM_CODES);
|
|
31
|
-
}
|
|
32
|
-
connections() {
|
|
33
|
-
return this.internals.connections();
|
|
34
|
-
}
|
|
35
|
-
connection(collectionId) {
|
|
36
|
-
return this.internals.connection(collectionId);
|
|
37
|
-
}
|
|
38
|
-
unavailableReason(collectionId) {
|
|
39
|
-
return this.internals.unavailableReason(collectionId);
|
|
40
|
-
}
|
|
41
|
-
onConnectionsChange(listener) {
|
|
42
|
-
return this.internals.onConnectionsChange(listener);
|
|
43
|
-
}
|
|
44
|
-
forgetAll() {
|
|
45
|
-
for (const connection of this.connections()) {
|
|
46
|
-
this.connection(connection.collectionId)?.forget();
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
class MdbaseConnectInternals {
|
|
11
|
+
import { MemoryStorage, apiError, canonicalLoopbackUrl, connectFetch, createPkce, defaultManifestSource, defaultRedirectUri, defaultStorage, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseGrantScope, parseStored, stripTrailingSlash, validFileCapability, validAuthorityTokenResponse } from "./runtime-utils.js";
|
|
12
|
+
export class MdbaseConnectInternals {
|
|
51
13
|
serverUrl;
|
|
52
14
|
manifest;
|
|
53
15
|
manifestSource;
|
|
@@ -150,10 +112,10 @@ class MdbaseConnectInternals {
|
|
|
150
112
|
}
|
|
151
113
|
const portableDeclared = typeof this.manifest !== "string"
|
|
152
114
|
&& this.manifest.distribution === "portable";
|
|
153
|
-
const popup = portableDeclared
|
|
154
|
-
&& !
|
|
115
|
+
const popup = !(portableDeclared && options.openVerification)
|
|
116
|
+
&& !this.navigate
|
|
155
117
|
&& typeof window !== "undefined"
|
|
156
|
-
? window.open(
|
|
118
|
+
? window.open("", "mdbase-connect-authorization", "popup,width=620,height=760")
|
|
157
119
|
: null;
|
|
158
120
|
let application;
|
|
159
121
|
try {
|
|
@@ -164,18 +126,47 @@ class MdbaseConnectInternals {
|
|
|
164
126
|
throw error;
|
|
165
127
|
}
|
|
166
128
|
if (application.distribution === "portable") {
|
|
167
|
-
|
|
129
|
+
if (options.openVerification)
|
|
130
|
+
popup?.close();
|
|
131
|
+
return this.authorizePortable(application, options, options.openVerification ? null : popup);
|
|
168
132
|
}
|
|
169
|
-
popup
|
|
133
|
+
return this.authorizeWeb(application, options, popup);
|
|
134
|
+
}
|
|
135
|
+
async authorizeWeb(application, options, popup) {
|
|
170
136
|
const { verifier, challenge } = await createPkce();
|
|
171
137
|
const state = randomBase64Url(24);
|
|
138
|
+
const authorizationId = crypto.randomUUID();
|
|
172
139
|
const targetCollectionId = options.target?.kind === "collection"
|
|
173
140
|
? options.target.collectionId
|
|
174
141
|
: undefined;
|
|
175
|
-
const keyHandle =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
142
|
+
const keyHandle = `grant:${application.id}:${state}`;
|
|
143
|
+
const grantKey = await this.keyStore.create(keyHandle);
|
|
144
|
+
const installation = await applicationIdentity(this.keyStore, this.serverUrl, application);
|
|
145
|
+
const operations = uniqueOperations(options.operations ?? DEFAULT_OPERATIONS);
|
|
146
|
+
const issuedAt = new Date();
|
|
147
|
+
const proof = await signApplicationAuthorization({
|
|
148
|
+
protocol_version: FIRST_CONTACT_PROTOCOL_VERSION,
|
|
149
|
+
authorization_id: authorizationId,
|
|
150
|
+
application_id: application.id,
|
|
151
|
+
application_manifest_digest: application.manifest_digest,
|
|
152
|
+
application_installation_id: await applicationInstallationId(installation),
|
|
153
|
+
installation_agreement_public_key: installation.agreementPublicKey,
|
|
154
|
+
installation_signing_public_key: installation.signingPublicKey,
|
|
155
|
+
grant_agreement_public_key: grantKey.agreementPublicKey,
|
|
156
|
+
grant_signing_public_key: grantKey.signingPublicKey,
|
|
157
|
+
flow: "authorization_code",
|
|
158
|
+
authorization_nonce: randomBase64Url(32),
|
|
159
|
+
issued_at: issuedAt.toISOString(),
|
|
160
|
+
expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
|
|
161
|
+
redirect_uri: this.redirectUri,
|
|
162
|
+
state,
|
|
163
|
+
code_challenge: challenge,
|
|
164
|
+
requested_operations: operations,
|
|
165
|
+
...(application.requirements?.files
|
|
166
|
+
? { requested_files: application.requirements.files }
|
|
167
|
+
: {}),
|
|
168
|
+
...(targetCollectionId ? { collection_id: targetCollectionId } : {})
|
|
169
|
+
}, installation);
|
|
179
170
|
const pending = {
|
|
180
171
|
version: 1,
|
|
181
172
|
verifier,
|
|
@@ -186,30 +177,107 @@ class MdbaseConnectInternals {
|
|
|
186
177
|
collectionId: targetCollectionId,
|
|
187
178
|
returnTo: options.returnTo,
|
|
188
179
|
keyHandle,
|
|
189
|
-
|
|
190
|
-
|
|
180
|
+
installationKeyHandle: installation.handle,
|
|
181
|
+
authorizationId,
|
|
182
|
+
applicationAgreementPublicKey: grantKey.agreementPublicKey,
|
|
183
|
+
applicationSigningPublicKey: grantKey.signingPublicKey
|
|
191
184
|
};
|
|
192
185
|
this.storage.setItem(this.pendingKey(state), JSON.stringify(pending));
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
await
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
186
|
+
try {
|
|
187
|
+
const response = await fetch(`${this.serverUrl}/oauth/authorization_request`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
190
|
+
body: new URLSearchParams({
|
|
191
|
+
client_id: application.id,
|
|
192
|
+
redirect_uri: this.redirectUri,
|
|
193
|
+
code_challenge: challenge,
|
|
194
|
+
code_challenge_method: "S256",
|
|
195
|
+
state,
|
|
196
|
+
operations: operations.join(","),
|
|
197
|
+
...(targetCollectionId ? { collection_id: targetCollectionId } : {}),
|
|
198
|
+
application_authorization: JSON.stringify(proof)
|
|
199
|
+
}),
|
|
200
|
+
signal: options.signal
|
|
201
|
+
});
|
|
202
|
+
const body = await response.json();
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
throw apiError(body, "device_authorization_failed", "Application authorization could not be started.", response.status);
|
|
205
|
+
}
|
|
206
|
+
if (typeof body.authorization_uri !== "string"
|
|
207
|
+
|| body.authorization_id !== authorizationId
|
|
208
|
+
|| !Number.isInteger(body.expires_in)
|
|
209
|
+
|| !Number.isInteger(body.interval)
|
|
210
|
+
|| body.expires_in <= 0
|
|
211
|
+
|| body.interval <= 0) {
|
|
212
|
+
throw connectError("invalid_device_authorization_response", "The authorization service returned an invalid polling response.");
|
|
213
|
+
}
|
|
214
|
+
if (this.navigate)
|
|
215
|
+
await this.navigate(body.authorization_uri);
|
|
216
|
+
else if (popup)
|
|
217
|
+
popup.location.href = body.authorization_uri;
|
|
218
|
+
else {
|
|
219
|
+
throw connectError("browser_required", "The approval window was blocked. Allow popups for this application and try again.");
|
|
220
|
+
}
|
|
221
|
+
const expiresAt = Date.now() + body.expires_in * 1_000;
|
|
222
|
+
let intervalSeconds = body.interval;
|
|
223
|
+
let shownFirstContact = "";
|
|
224
|
+
while (Date.now() < expiresAt) {
|
|
225
|
+
await abortableDelay(intervalSeconds * 1_000, options.signal);
|
|
226
|
+
if (options.signal?.aborted) {
|
|
227
|
+
throw connectError("authorization_cancelled", "Application authorization was cancelled.");
|
|
228
|
+
}
|
|
229
|
+
let statusResponse;
|
|
230
|
+
try {
|
|
231
|
+
statusResponse = await fetch(`${this.serverUrl}/oauth/authorization_status`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
234
|
+
body: new URLSearchParams({
|
|
235
|
+
client_id: application.id,
|
|
236
|
+
authorization_id: authorizationId,
|
|
237
|
+
code_verifier: verifier
|
|
238
|
+
}),
|
|
239
|
+
signal: options.signal
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch (cause) {
|
|
243
|
+
if (options.signal?.aborted) {
|
|
244
|
+
throw connectError("authorization_cancelled", "Application authorization was cancelled.", { cause });
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const statusBody = await statusResponse.json();
|
|
249
|
+
if (!statusResponse.ok) {
|
|
250
|
+
const code = oauthErrorCode(statusBody);
|
|
251
|
+
if (code === "authorization_pending") {
|
|
252
|
+
if (statusBody.first_contact) {
|
|
253
|
+
shownFirstContact = await presentFirstContact(statusBody.first_contact, application, installation, options, shownFirstContact);
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (code === "slow_down") {
|
|
258
|
+
intervalSeconds += 2;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
throw apiError(statusBody, "token_exchange_failed", "Application authorization could not be completed.", statusResponse.status);
|
|
262
|
+
}
|
|
263
|
+
if (typeof statusBody.authorization_redirect !== "string") {
|
|
264
|
+
throw connectError("invalid_token_response", "The authorization service returned an invalid completion response.");
|
|
265
|
+
}
|
|
266
|
+
const result = await this.performAuthorizationCompletion(statusBody.authorization_redirect, state);
|
|
267
|
+
popup?.close();
|
|
268
|
+
return { kind: "connected", ...result };
|
|
269
|
+
}
|
|
270
|
+
throw connectError("expired_token", "The application authorization expired.");
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
popup?.close();
|
|
274
|
+
this.storage.removeItem(this.pendingKey(state));
|
|
275
|
+
await this.keyStore.delete(keyHandle);
|
|
276
|
+
if (options.signal?.aborted) {
|
|
277
|
+
throw connectError("authorization_cancelled", "Application authorization was cancelled.", { cause: error });
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
213
281
|
}
|
|
214
282
|
async authorizePortable(application, options, popup) {
|
|
215
283
|
if (this.relayEncryption !== "required") {
|
|
@@ -219,6 +287,33 @@ class MdbaseConnectInternals {
|
|
|
219
287
|
const { verifier, challenge } = await createPkce();
|
|
220
288
|
const keyHandle = `grant:${application.id}:${randomBase64Url(24)}`;
|
|
221
289
|
const grantKey = await this.keyStore.create(keyHandle);
|
|
290
|
+
const installation = await applicationIdentity(this.keyStore, this.serverUrl, application);
|
|
291
|
+
const operations = uniqueOperations(options.operations ?? DEFAULT_OPERATIONS);
|
|
292
|
+
const authorizationId = crypto.randomUUID();
|
|
293
|
+
const issuedAt = new Date();
|
|
294
|
+
const proof = await signApplicationAuthorization({
|
|
295
|
+
protocol_version: FIRST_CONTACT_PROTOCOL_VERSION,
|
|
296
|
+
authorization_id: authorizationId,
|
|
297
|
+
application_id: application.id,
|
|
298
|
+
application_manifest_digest: application.manifest_digest,
|
|
299
|
+
application_installation_id: await applicationInstallationId(installation),
|
|
300
|
+
installation_agreement_public_key: installation.agreementPublicKey,
|
|
301
|
+
installation_signing_public_key: installation.signingPublicKey,
|
|
302
|
+
grant_agreement_public_key: grantKey.agreementPublicKey,
|
|
303
|
+
grant_signing_public_key: grantKey.signingPublicKey,
|
|
304
|
+
flow: "device_code",
|
|
305
|
+
authorization_nonce: randomBase64Url(32),
|
|
306
|
+
issued_at: issuedAt.toISOString(),
|
|
307
|
+
expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
|
|
308
|
+
code_challenge: challenge,
|
|
309
|
+
requested_operations: operations,
|
|
310
|
+
...(application.requirements?.files
|
|
311
|
+
? { requested_files: application.requirements.files }
|
|
312
|
+
: {}),
|
|
313
|
+
...(options.target?.kind === "collection"
|
|
314
|
+
? { collection_id: options.target.collectionId }
|
|
315
|
+
: {})
|
|
316
|
+
}, installation);
|
|
222
317
|
let response;
|
|
223
318
|
let body;
|
|
224
319
|
try {
|
|
@@ -227,15 +322,13 @@ class MdbaseConnectInternals {
|
|
|
227
322
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
228
323
|
body: new URLSearchParams({
|
|
229
324
|
client_id: application.id,
|
|
230
|
-
operations:
|
|
325
|
+
operations: operations.join(","),
|
|
231
326
|
...(options.target?.kind === "collection"
|
|
232
327
|
? { collection_id: options.target.collectionId }
|
|
233
328
|
: {}),
|
|
234
329
|
code_challenge: challenge,
|
|
235
330
|
code_challenge_method: "S256",
|
|
236
|
-
|
|
237
|
-
application_agreement_public_key: grantKey.agreementPublicKey,
|
|
238
|
-
application_signing_public_key: grantKey.signingPublicKey
|
|
331
|
+
application_authorization: JSON.stringify(proof)
|
|
239
332
|
})
|
|
240
333
|
});
|
|
241
334
|
body = await response.json();
|
|
@@ -257,6 +350,9 @@ class MdbaseConnectInternals {
|
|
|
257
350
|
catch (error) {
|
|
258
351
|
popup?.close();
|
|
259
352
|
await this.keyStore.delete(keyHandle);
|
|
353
|
+
if (options.signal?.aborted) {
|
|
354
|
+
throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause: error });
|
|
355
|
+
}
|
|
260
356
|
throw error;
|
|
261
357
|
}
|
|
262
358
|
options.onDeviceCode?.(authorization);
|
|
@@ -284,6 +380,7 @@ class MdbaseConnectInternals {
|
|
|
284
380
|
});
|
|
285
381
|
}
|
|
286
382
|
let intervalSeconds = authorization.intervalSeconds;
|
|
383
|
+
let shownFirstContact = "";
|
|
287
384
|
try {
|
|
288
385
|
while (Date.now() < authorization.expiresAt) {
|
|
289
386
|
await abortableDelay(intervalSeconds * 1_000, options.signal);
|
|
@@ -313,8 +410,12 @@ class MdbaseConnectInternals {
|
|
|
313
410
|
const tokenBody = await tokenResponse.json();
|
|
314
411
|
if (!tokenResponse.ok) {
|
|
315
412
|
const code = oauthErrorCode(tokenBody);
|
|
316
|
-
if (code === "authorization_pending")
|
|
413
|
+
if (code === "authorization_pending") {
|
|
414
|
+
if (tokenBody.first_contact) {
|
|
415
|
+
shownFirstContact = await presentFirstContact(tokenBody.first_contact, application, installation, options, shownFirstContact);
|
|
416
|
+
}
|
|
317
417
|
continue;
|
|
418
|
+
}
|
|
318
419
|
if (code === "slow_down") {
|
|
319
420
|
intervalSeconds += 5;
|
|
320
421
|
continue;
|
|
@@ -357,6 +458,9 @@ class MdbaseConnectInternals {
|
|
|
357
458
|
catch (error) {
|
|
358
459
|
popup?.close();
|
|
359
460
|
await this.keyStore.delete(keyHandle);
|
|
461
|
+
if (options.signal?.aborted) {
|
|
462
|
+
throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause: error });
|
|
463
|
+
}
|
|
360
464
|
throw error;
|
|
361
465
|
}
|
|
362
466
|
}
|
package/dist/connection.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CollectionChange, CollectionChangesPage, CollectionDescription, CollectionOperation, CollectionTypeDocument, CreateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, FileCapability, GrantScope, JsonObject, ReadViewSourceInput, RecordDocument, SavedViewExecution, SavedViewList, SavedViewSourceDocument, TypePackInstallResult, TypePackProvision, UpdateViewSourceInput } from "@mdbase-dev/connect-protocol";
|
|
1
|
+
import type { CollectionChange, CollectionChangesPage, CollectionDescription, CollectionOperation, FirstContactBinding, CollectionTypeDocument, CreateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, FileCapability, GrantScope, JsonObject, ReadViewSourceInput, RecordDocument, SavedViewExecution, SavedViewList, SavedViewSourceDocument, TypePackInstallResult, TypePackProvision, UpdateViewSourceInput } from "@mdbase-dev/connect-protocol";
|
|
2
2
|
import { type ConnectionTransportInternals } from "./connection-transport.js";
|
|
3
3
|
import type { DirectAccessStatus, MdbaseAuthorizationCapabilities, MdbaseConnectionInfo, MdbaseConnectionRoute, MdbaseSyncConnection } from "./connection-types.js";
|
|
4
4
|
import type { GrantKeyStore } from "./crypto.js";
|
|
@@ -22,6 +22,11 @@ export interface MdbaseAuthorizeOptions {
|
|
|
22
22
|
returnTo?: string;
|
|
23
23
|
/** Receives the short code even when the SDK also opens the approval page. */
|
|
24
24
|
onDeviceCode?: (authorization: MdbaseDeviceAuthorization) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Displays the independently derived first-contact code. Keep it visible
|
|
27
|
+
* while the user compares and accepts the same code on the connector.
|
|
28
|
+
*/
|
|
29
|
+
onFirstContact?: (challenge: MdbaseFirstContactChallenge) => void | Promise<void>;
|
|
25
30
|
/** Replace the default popup for a downloaded application's approval page. */
|
|
26
31
|
openVerification?: (authorization: MdbaseDeviceAuthorization) => void | Promise<void>;
|
|
27
32
|
/** Stop polling and discard the unapproved, in-memory key. */
|
|
@@ -31,9 +36,16 @@ export interface MdbaseConnectionAuthorizeOptions {
|
|
|
31
36
|
operations?: CollectionOperation[];
|
|
32
37
|
returnTo?: string;
|
|
33
38
|
onDeviceCode?: (authorization: MdbaseDeviceAuthorization) => void;
|
|
39
|
+
onFirstContact?: (challenge: MdbaseFirstContactChallenge) => void | Promise<void>;
|
|
34
40
|
openVerification?: (authorization: MdbaseDeviceAuthorization) => void | Promise<void>;
|
|
35
41
|
signal?: AbortSignal;
|
|
36
42
|
}
|
|
43
|
+
export interface MdbaseFirstContactChallenge {
|
|
44
|
+
binding: FirstContactBinding;
|
|
45
|
+
authenticationString: string;
|
|
46
|
+
applicationId: string;
|
|
47
|
+
connectorId: string;
|
|
48
|
+
}
|
|
37
49
|
export interface MdbaseConnectEnvironment {
|
|
38
50
|
distribution: "web" | "portable";
|
|
39
51
|
applicationOrigin: string;
|
|
@@ -78,7 +90,7 @@ export declare class MdbaseConnection<Frontmatter extends JsonObject = JsonObjec
|
|
|
78
90
|
authorizationCapabilities(requiredOperations?: CollectionOperation[]): MdbaseAuthorizationCapabilities;
|
|
79
91
|
hasOperations(requiredOperations: CollectionOperation[]): boolean;
|
|
80
92
|
authorize(options?: MdbaseConnectionAuthorizeOptions): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter>, AuthorizationProblemCode>>;
|
|
81
|
-
requestOperations(requiredOperations: CollectionOperation[], options?:
|
|
93
|
+
requestOperations(requiredOperations: CollectionOperation[], options?: Omit<MdbaseConnectionAuthorizeOptions, "operations">): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter> | {
|
|
82
94
|
kind: "unchanged";
|
|
83
95
|
connection: MdbaseConnection<Frontmatter>;
|
|
84
96
|
}, AuthorizationProblemCode>>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MdbaseAuthorizeOptions } from "./connection.js";
|
|
2
|
+
import type { GrantKeyRecord, GrantKeyStore } from "./crypto.js";
|
|
3
|
+
import type { Application } from "./internal-types.js";
|
|
4
|
+
export declare function presentFirstContact(value: unknown, application: Application, installation: GrantKeyRecord, options: MdbaseAuthorizeOptions, previousFingerprint: string): Promise<string>;
|
|
5
|
+
export declare function applicationIdentity(keyStore: GrantKeyStore, serverUrl: string, application: Application): Promise<GrantKeyRecord>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { applicationInstallationId } from "./application-identity.js";
|
|
2
|
+
import { deriveFirstContactSas } from "./first-contact.js";
|
|
3
|
+
import { connectError } from "./errors.js";
|
|
4
|
+
export async function presentFirstContact(value, application, installation, options, previousFingerprint) {
|
|
5
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
6
|
+
throw connectError("invalid_first_contact", "The authorization service returned an invalid first-contact challenge.");
|
|
7
|
+
}
|
|
8
|
+
const binding = value;
|
|
9
|
+
const installationId = await applicationInstallationId(installation);
|
|
10
|
+
if (binding.application_id !== application.id
|
|
11
|
+
|| binding.application_installation_id !== installationId
|
|
12
|
+
|| binding.application_agreement_public_key !== installation.agreementPublicKey
|
|
13
|
+
|| binding.application_signing_public_key !== installation.signingPublicKey) {
|
|
14
|
+
throw connectError("invalid_first_contact", "The first-contact challenge does not match this application installation.");
|
|
15
|
+
}
|
|
16
|
+
const fingerprint = JSON.stringify(binding);
|
|
17
|
+
if (fingerprint === previousFingerprint)
|
|
18
|
+
return previousFingerprint;
|
|
19
|
+
if (!options.onFirstContact) {
|
|
20
|
+
throw connectError("first_contact_handler_required", "Local access requires the application to display a first-contact comparison code.");
|
|
21
|
+
}
|
|
22
|
+
let authenticationString;
|
|
23
|
+
try {
|
|
24
|
+
authenticationString = await deriveFirstContactSas(binding, "application", installation);
|
|
25
|
+
}
|
|
26
|
+
catch (cause) {
|
|
27
|
+
throw connectError("invalid_first_contact", "The authorization service returned an invalid first-contact challenge.", { cause });
|
|
28
|
+
}
|
|
29
|
+
await options.onFirstContact({
|
|
30
|
+
binding,
|
|
31
|
+
authenticationString,
|
|
32
|
+
applicationId: binding.application_id,
|
|
33
|
+
connectorId: binding.connector_id
|
|
34
|
+
});
|
|
35
|
+
return fingerprint;
|
|
36
|
+
}
|
|
37
|
+
export async function applicationIdentity(keyStore, serverUrl, application) {
|
|
38
|
+
const handle = `application-installation:${serverUrl}:${application.id}`;
|
|
39
|
+
const existing = await keyStore.get(handle);
|
|
40
|
+
if (existing)
|
|
41
|
+
return existing;
|
|
42
|
+
try {
|
|
43
|
+
return await keyStore.create(handle);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const raced = await keyStore.get(handle);
|
|
47
|
+
if (raced)
|
|
48
|
+
return raced;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type FirstContactBinding } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
export type FirstContactRole = "application" | "connector";
|
|
3
|
+
export declare class FirstContactCryptoError extends Error {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
constructor(code: string, message: string);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Derive the independently displayed first-contact authentication string.
|
|
9
|
+
* The private key must belong to the endpoint role named in the transcript.
|
|
10
|
+
*/
|
|
11
|
+
export declare function deriveFirstContactSas(binding: FirstContactBinding, role: FirstContactRole, identity: {
|
|
12
|
+
agreementPrivateKey: CryptoKey;
|
|
13
|
+
agreementPublicKey: string;
|
|
14
|
+
}): Promise<string>;
|