@matanetwork/sovereign-id 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/package.json +49 -0
- package/src/index.d.ts +216 -0
- package/src/index.js +616 -0
- package/src/install-upsell.js +592 -0
- package/src/resume.js +115 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @matanetwork/sovereign-id — page-side SDK for MATA's permissionless self-
|
|
3
|
+
* issued identity protocol (mID).
|
|
4
|
+
*
|
|
5
|
+
* Phase 5 of the mID Mission. This package is what relying parties
|
|
6
|
+
* (RPs) `npm install` and call from their sign-in button. It:
|
|
7
|
+
*
|
|
8
|
+
* 1. Probes for the MATA browser extension via `window.__mata_mid__`.
|
|
9
|
+
* 2. Falls back to deep-linking the MATA native app via
|
|
10
|
+
* `mata-mid://request?payload=...` if no extension is found.
|
|
11
|
+
* 3. Surfaces an install prompt if neither is available.
|
|
12
|
+
*
|
|
13
|
+
* The wire protocol the SDK speaks (page ↔ content-script / native
|
|
14
|
+
* app) is locked by ADR 0005 Decision 2/3 — see the Rust
|
|
15
|
+
* `mid-consent-types` crate for the canonical types this package
|
|
16
|
+
* mirrors.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ─── Wire protocol constants (ADR 0005 Decision 2/3) ────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The JavaScript global the extension's content script injects on every
|
|
23
|
+
* page. The SDK probes for this presence to detect the extension.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export const WINDOW_MID_GLOBAL = '__mata_mid__';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The discriminator key on every page ↔ content-script postMessage.
|
|
30
|
+
* Lets the SDK filter `window.postMessage` events cheaply.
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export const MESSAGE_DISCRIMINATOR = '__mata_mid_v1';
|
|
34
|
+
|
|
35
|
+
/** The `kind` value on the sign-in request message. @internal */
|
|
36
|
+
export const KIND_SIGN_IN_REQUEST = 'sign_in_request';
|
|
37
|
+
|
|
38
|
+
/** The `kind` value on the sign-in response message. @internal */
|
|
39
|
+
export const KIND_SIGN_IN_RESPONSE = 'sign_in_response';
|
|
40
|
+
|
|
41
|
+
/** The native app's URL scheme. @internal */
|
|
42
|
+
export const URL_SCHEME = 'mata-mid';
|
|
43
|
+
|
|
44
|
+
/** The path under the URL scheme. @internal */
|
|
45
|
+
export const SCHEME_PATH_REQUEST = 'request';
|
|
46
|
+
|
|
47
|
+
/** The query parameter that carries the base64url-encoded payload. @internal */
|
|
48
|
+
export const QUERY_PARAM_PAYLOAD = 'payload';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The URL-fragment key in the response callback that carries the
|
|
52
|
+
* base64url-encoded response.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export const FRAGMENT_KEY_RESPONSE = 'mid_response';
|
|
56
|
+
|
|
57
|
+
/** Native protocol version. Pinned at 1 per ADR. @internal */
|
|
58
|
+
export const PROTOCOL_VERSION = 1;
|
|
59
|
+
|
|
60
|
+
// ─── Standard error codes ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export const ERR_USER_DENIED = 'user_denied';
|
|
63
|
+
export const ERR_ORIGIN_MISMATCH = 'origin_mismatch';
|
|
64
|
+
export const ERR_INVALID_REQUEST = 'invalid_request';
|
|
65
|
+
export const ERR_WALLET_UNAVAILABLE = 'wallet_unavailable';
|
|
66
|
+
export const ERR_REQUIRED_CLAIM_UNAVAILABLE = 'required_claim_unavailable';
|
|
67
|
+
export const ERR_INTERNAL = 'internal_error';
|
|
68
|
+
export const ERR_NO_WALLET_INSTALLED = 'no_wallet_installed';
|
|
69
|
+
export const ERR_TIMEOUT = 'timeout';
|
|
70
|
+
/**
|
|
71
|
+
* The user dismissed the install upsell ("Cancel" or Escape).
|
|
72
|
+
* Distinct from `user_denied` (the user reached the consent screen
|
|
73
|
+
* and denied disclosure). RPs typically render the same UI for both,
|
|
74
|
+
* but the distinct code lets analytics separate "didn't install" from
|
|
75
|
+
* "installed but said no."
|
|
76
|
+
*/
|
|
77
|
+
export const ERR_UPSELL_CANCELED = 'upsell_canceled';
|
|
78
|
+
|
|
79
|
+
// ─── SignInError class ─────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Error thrown by `signIn()` on any failure path. The `code` is one of
|
|
83
|
+
* the `ERR_*` constants exported above; the RP's catch block can
|
|
84
|
+
* branch on it.
|
|
85
|
+
*/
|
|
86
|
+
export class SignInError extends Error {
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} code - one of the `ERR_*` constants
|
|
89
|
+
* @param {string} message - human-readable detail
|
|
90
|
+
*/
|
|
91
|
+
constructor(code, message) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.name = 'SignInError';
|
|
94
|
+
/** @type {string} */
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── signIn() — the main API ───────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
import {
|
|
102
|
+
stashPendingSignIn,
|
|
103
|
+
readPendingSignIn,
|
|
104
|
+
clearPendingSignIn,
|
|
105
|
+
} from './resume.js';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Default token request timeout: 2 minutes. The user has this long to
|
|
109
|
+
* approve or deny the consent screen before the SDK rejects.
|
|
110
|
+
*/
|
|
111
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Request a sign-in from the user's MATA wallet.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} request
|
|
117
|
+
* @param {string} request.rpOrigin - RP's bare origin, e.g. "https://acme.com"
|
|
118
|
+
* @param {string} request.nonce - RP-issued single-use nonce
|
|
119
|
+
* @param {object} request.claims - claim catalog
|
|
120
|
+
* @param {string[]} request.claims.required - required claim keys
|
|
121
|
+
* @param {string[]} [request.claims.optional] - optional claim keys
|
|
122
|
+
* @param {Record<string, {optional: true, description?: string}>} [request.claims.custom] - custom claims
|
|
123
|
+
* @param {object} [options]
|
|
124
|
+
* @param {number} [options.timeoutMs] - request timeout (default: 2 min)
|
|
125
|
+
* @param {string} [options.nativeAppCallback] - URL the native app opens to return (default: window.location.href)
|
|
126
|
+
* @param {boolean} [options.installUpsell] - whether to show the install upsell when no wallet is detected (default: true). Set to false to get the raw `ERR_NO_WALLET_INSTALLED` error and handle the upsell UI yourself.
|
|
127
|
+
* @param {string | null} [options.ref] - referral code attributed to signups that flow through the install upsell. Defaults to the hostname extracted from `rpOrigin` (e.g., `"acme.com"`) so RPs get attribution by default. Pass `null` to opt out, or a custom string to override (e.g., a configured MATA referral code). Follows the existing `?ref=` convention captured by my.mata.network's signup flow.
|
|
128
|
+
* @returns {Promise<{jwt: string, surface: "extension" | "native_app"}>}
|
|
129
|
+
* @throws {SignInError} with `.code` matching one of the `ERR_*` constants
|
|
130
|
+
*/
|
|
131
|
+
export async function signIn(request, options = {}) {
|
|
132
|
+
validateRequest(request);
|
|
133
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
134
|
+
const upsellEnabled = options.installUpsell !== false;
|
|
135
|
+
|
|
136
|
+
if (hasExtension()) {
|
|
137
|
+
// A direct extension path supersedes any stashed resume entry —
|
|
138
|
+
// the user is no longer waiting on the install upsell. Clear so
|
|
139
|
+
// a later `resumePendingSignIn()` call doesn't re-fire this
|
|
140
|
+
// same request.
|
|
141
|
+
clearPendingSignIn();
|
|
142
|
+
return await signInViaExtension(request, timeoutMs);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Extension not present — try native app surface. If that also
|
|
146
|
+
// can't find a wallet AND the RP opted into the install upsell, run
|
|
147
|
+
// the upsell and retry signIn on success.
|
|
148
|
+
try {
|
|
149
|
+
const result = await signInViaNativeApp(request, options, timeoutMs);
|
|
150
|
+
clearPendingSignIn();
|
|
151
|
+
return result;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (
|
|
154
|
+
upsellEnabled &&
|
|
155
|
+
err instanceof SignInError &&
|
|
156
|
+
err.code === ERR_NO_WALLET_INSTALLED
|
|
157
|
+
) {
|
|
158
|
+
return await runInstallUpsellAndRetry(request, options, timeoutMs);
|
|
159
|
+
}
|
|
160
|
+
// Any other error means the request itself was bad — purge the
|
|
161
|
+
// stash so the next reload doesn't pointlessly re-attempt a
|
|
162
|
+
// request that's structurally broken (malformed nonce, denied,
|
|
163
|
+
// etc.). Network-flake retries are the RP's job.
|
|
164
|
+
clearPendingSignIn();
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run the install upsell modal. On `"installed"`, retry signIn (the
|
|
171
|
+
* extension is now present, so the retry hits the extension surface).
|
|
172
|
+
* On `"canceled"`, throw `ERR_UPSELL_CANCELED`.
|
|
173
|
+
*
|
|
174
|
+
* Before opening the modal, stash a resume entry in sessionStorage so
|
|
175
|
+
* that a manual page reload (or Chrome's "extension installed" toast
|
|
176
|
+
* nudging the user) doesn't lose the in-flight request. See
|
|
177
|
+
* [`resume.js`](./resume.js) for the storage model.
|
|
178
|
+
*
|
|
179
|
+
* @internal
|
|
180
|
+
*/
|
|
181
|
+
async function runInstallUpsellAndRetry(request, options, timeoutMs) {
|
|
182
|
+
// Lazy import so RPs who opt out of the upsell don't pay the bundle
|
|
183
|
+
// cost. Bundlers (Webpack/Vite/esbuild) tree-shake this when
|
|
184
|
+
// `installUpsell: false` is the only call site.
|
|
185
|
+
const { showInstallUpsell } = await import('./install-upsell.js');
|
|
186
|
+
|
|
187
|
+
// Stash for resume-after-reload. `options` may carry `installUpsell:
|
|
188
|
+
// false` from a self-retry path; strip it when stashing so the
|
|
189
|
+
// resumed signIn doesn't skip the upsell entirely.
|
|
190
|
+
stashPendingSignIn({
|
|
191
|
+
request,
|
|
192
|
+
options: { ...options, installUpsell: undefined },
|
|
193
|
+
expiresAt: Date.now() + timeoutMs,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
let result;
|
|
197
|
+
try {
|
|
198
|
+
result = await showInstallUpsell({
|
|
199
|
+
rpOrigin: request.rpOrigin,
|
|
200
|
+
hasExtensionFn: hasExtension,
|
|
201
|
+
// Thread the RP's explicit ref through. `undefined` (i.e. RP
|
|
202
|
+
// didn't set the option) leaves the upsell to auto-derive from
|
|
203
|
+
// rpOrigin; `null` opts the RP out of attribution entirely.
|
|
204
|
+
ref: options.ref,
|
|
205
|
+
});
|
|
206
|
+
} catch (e) {
|
|
207
|
+
clearPendingSignIn();
|
|
208
|
+
throw e;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (result === 'installed') {
|
|
212
|
+
clearPendingSignIn();
|
|
213
|
+
if (hasExtension()) {
|
|
214
|
+
return await signInViaExtension(request, timeoutMs);
|
|
215
|
+
}
|
|
216
|
+
// Race — extension was detected by the modal's poll but is now
|
|
217
|
+
// gone (uninstall? content-script crash?). Fall through to the
|
|
218
|
+
// native attempt with upsell disabled to avoid infinite-modal.
|
|
219
|
+
return await signInViaNativeApp(request, { ...options, installUpsell: false }, timeoutMs);
|
|
220
|
+
}
|
|
221
|
+
clearPendingSignIn();
|
|
222
|
+
throw new SignInError(
|
|
223
|
+
ERR_UPSELL_CANCELED,
|
|
224
|
+
'user dismissed the install upsell'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Resume a sign-in that was interrupted by a page reload during the
|
|
230
|
+
* install upsell.
|
|
231
|
+
*
|
|
232
|
+
* Call this once on page boot — ideally as early as possible in your
|
|
233
|
+
* app's startup so the user sees the sign-in continue without a
|
|
234
|
+
* visible flicker through your logged-out state.
|
|
235
|
+
*
|
|
236
|
+
* Returns:
|
|
237
|
+
*
|
|
238
|
+
* - `{jwt, surface}` — a pending request was found, the extension is
|
|
239
|
+
* now installed, and the resumed sign-in succeeded.
|
|
240
|
+
* - `null` — no pending request was stashed, or the stash is stale,
|
|
241
|
+
* or the extension is still not installed (the user reloaded
|
|
242
|
+
* before completing the install).
|
|
243
|
+
*
|
|
244
|
+
* Rejects with a `SignInError` if a pending request exists AND the
|
|
245
|
+
* extension is present but the sign-in itself failed (user denied,
|
|
246
|
+
* timeout, etc.) — same error shape as `signIn()`.
|
|
247
|
+
*
|
|
248
|
+
* Typical usage:
|
|
249
|
+
*
|
|
250
|
+
* ```js
|
|
251
|
+
* import { resumePendingSignIn } from '@matanetwork/sovereign-id';
|
|
252
|
+
*
|
|
253
|
+
* resumePendingSignIn().then((result) => {
|
|
254
|
+
* if (result) {
|
|
255
|
+
* // user installed the extension and the sign-in completed.
|
|
256
|
+
* handleSignedIn(result.jwt);
|
|
257
|
+
* }
|
|
258
|
+
* // else: no pending request, the boot path proceeds normally.
|
|
259
|
+
* });
|
|
260
|
+
* ```
|
|
261
|
+
*
|
|
262
|
+
* @returns {Promise<SignInSuccess | null>}
|
|
263
|
+
*/
|
|
264
|
+
export async function resumePendingSignIn() {
|
|
265
|
+
const pending = readPendingSignIn();
|
|
266
|
+
if (!pending) return null;
|
|
267
|
+
|
|
268
|
+
// Extension still not installed → keep the stash in place so a
|
|
269
|
+
// follow-up reload (after the user finally installs) still resumes.
|
|
270
|
+
// Returning null lets the RP's normal boot flow continue rendering
|
|
271
|
+
// the sign-in button.
|
|
272
|
+
if (!hasExtension()) return null;
|
|
273
|
+
|
|
274
|
+
// Compute the residual timeout — total budget minus the elapsed
|
|
275
|
+
// wall-clock since the original signIn(). Floor at 1s so a very
|
|
276
|
+
// late reload still gets a real try at the extension surface.
|
|
277
|
+
const remainingMs = Math.max(1_000, pending.expiresAt - Date.now());
|
|
278
|
+
|
|
279
|
+
clearPendingSignIn();
|
|
280
|
+
try {
|
|
281
|
+
return await signInViaExtension(pending.request, remainingMs);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
// Surface the error to the RP — they decide whether to show the
|
|
284
|
+
// sign-in button again, surface "your previous attempt timed
|
|
285
|
+
// out," etc.
|
|
286
|
+
throw e;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Whether the MATA browser extension is installed on this page.
|
|
292
|
+
*
|
|
293
|
+
* @returns {boolean}
|
|
294
|
+
*/
|
|
295
|
+
export function hasExtension() {
|
|
296
|
+
return (
|
|
297
|
+
typeof window !== 'undefined' &&
|
|
298
|
+
typeof window[WINDOW_MID_GLOBAL] === 'object' &&
|
|
299
|
+
window[WINDOW_MID_GLOBAL] !== null &&
|
|
300
|
+
window[WINDOW_MID_GLOBAL].version === PROTOCOL_VERSION
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Validate the request shape. Throws SignInError with code
|
|
306
|
+
* `invalid_request` if anything is off.
|
|
307
|
+
*
|
|
308
|
+
* @internal
|
|
309
|
+
* @param {object} request
|
|
310
|
+
*/
|
|
311
|
+
function validateRequest(request) {
|
|
312
|
+
if (typeof request !== 'object' || request === null) {
|
|
313
|
+
throw new SignInError(ERR_INVALID_REQUEST, 'request must be an object');
|
|
314
|
+
}
|
|
315
|
+
if (typeof request.rpOrigin !== 'string' || request.rpOrigin.length === 0) {
|
|
316
|
+
throw new SignInError(ERR_INVALID_REQUEST, 'rpOrigin must be a non-empty string');
|
|
317
|
+
}
|
|
318
|
+
if (typeof request.nonce !== 'string' || request.nonce.length === 0) {
|
|
319
|
+
throw new SignInError(ERR_INVALID_REQUEST, 'nonce must be a non-empty string');
|
|
320
|
+
}
|
|
321
|
+
if (typeof request.claims !== 'object' || request.claims === null) {
|
|
322
|
+
throw new SignInError(ERR_INVALID_REQUEST, 'claims must be an object');
|
|
323
|
+
}
|
|
324
|
+
if (!Array.isArray(request.claims.required)) {
|
|
325
|
+
throw new SignInError(
|
|
326
|
+
ERR_INVALID_REQUEST,
|
|
327
|
+
'claims.required must be an array (use [] if no required claims)'
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─── Extension surface ─────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Send the sign-in request via the extension's `window.postMessage`
|
|
336
|
+
* channel, wait for the response.
|
|
337
|
+
*
|
|
338
|
+
* @internal
|
|
339
|
+
* @param {object} request
|
|
340
|
+
* @param {number} timeoutMs
|
|
341
|
+
* @returns {Promise<{jwt: string, surface: "extension"}>}
|
|
342
|
+
*/
|
|
343
|
+
function signInViaExtension(request, timeoutMs) {
|
|
344
|
+
return new Promise((resolve, reject) => {
|
|
345
|
+
const requestId = generateRequestId();
|
|
346
|
+
let timeoutHandle = null;
|
|
347
|
+
|
|
348
|
+
const messageHandler = (event) => {
|
|
349
|
+
const data = event.data;
|
|
350
|
+
if (typeof data !== 'object' || data === null) return;
|
|
351
|
+
if (data[MESSAGE_DISCRIMINATOR] !== true) return;
|
|
352
|
+
if (data.kind !== KIND_SIGN_IN_RESPONSE) return;
|
|
353
|
+
if (data.request_id !== requestId) return;
|
|
354
|
+
|
|
355
|
+
// It's our response.
|
|
356
|
+
window.removeEventListener('message', messageHandler);
|
|
357
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
358
|
+
|
|
359
|
+
const result = data.result;
|
|
360
|
+
if (typeof result !== 'object' || result === null) {
|
|
361
|
+
reject(new SignInError(ERR_INTERNAL, 'malformed response from wallet'));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (result.outcome === 'ok') {
|
|
365
|
+
resolve({ jwt: result.jwt, surface: 'extension' });
|
|
366
|
+
} else if (result.outcome === 'denied') {
|
|
367
|
+
reject(new SignInError(ERR_USER_DENIED, 'user denied disclosure'));
|
|
368
|
+
} else if (result.outcome === 'error') {
|
|
369
|
+
reject(new SignInError(result.error_code ?? ERR_INTERNAL, result.message ?? ''));
|
|
370
|
+
} else {
|
|
371
|
+
reject(new SignInError(ERR_INTERNAL, `unknown outcome: ${result.outcome}`));
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
window.addEventListener('message', messageHandler);
|
|
376
|
+
|
|
377
|
+
// Send the request.
|
|
378
|
+
const payload = {
|
|
379
|
+
[MESSAGE_DISCRIMINATOR]: true,
|
|
380
|
+
kind: KIND_SIGN_IN_REQUEST,
|
|
381
|
+
request_id: requestId,
|
|
382
|
+
rp_origin: request.rpOrigin,
|
|
383
|
+
nonce: request.nonce,
|
|
384
|
+
claims: {
|
|
385
|
+
required: request.claims.required,
|
|
386
|
+
optional: request.claims.optional ?? [],
|
|
387
|
+
custom: request.claims.custom ?? {},
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
window.postMessage(payload, '*');
|
|
391
|
+
|
|
392
|
+
// Timeout fallback.
|
|
393
|
+
timeoutHandle = setTimeout(() => {
|
|
394
|
+
window.removeEventListener('message', messageHandler);
|
|
395
|
+
reject(new SignInError(ERR_TIMEOUT, `no response within ${timeoutMs}ms`));
|
|
396
|
+
}, timeoutMs);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ─── Native app surface ────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Deep-link the native MATA app via `mata-mid://`. Resolves when the
|
|
404
|
+
* user returns to the browser tab and the SDK reads `location.hash`
|
|
405
|
+
* for the `mid_response` payload.
|
|
406
|
+
*
|
|
407
|
+
* @internal
|
|
408
|
+
* @param {object} request
|
|
409
|
+
* @param {object} options
|
|
410
|
+
* @param {number} timeoutMs
|
|
411
|
+
* @returns {Promise<{jwt: string, surface: "native_app"}>}
|
|
412
|
+
*/
|
|
413
|
+
function signInViaNativeApp(request, options, timeoutMs) {
|
|
414
|
+
return new Promise((resolve, reject) => {
|
|
415
|
+
if (typeof window === 'undefined') {
|
|
416
|
+
reject(new SignInError(ERR_NO_WALLET_INSTALLED, 'no window — server context?'));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const requestId = generateRequestId();
|
|
421
|
+
const callback = options.nativeAppCallback ?? window.location.href;
|
|
422
|
+
|
|
423
|
+
const payload = {
|
|
424
|
+
version: PROTOCOL_VERSION,
|
|
425
|
+
request_id: requestId,
|
|
426
|
+
rp_origin: request.rpOrigin,
|
|
427
|
+
nonce: request.nonce,
|
|
428
|
+
claims: {
|
|
429
|
+
required: request.claims.required,
|
|
430
|
+
optional: request.claims.optional ?? [],
|
|
431
|
+
custom: request.claims.custom ?? {},
|
|
432
|
+
},
|
|
433
|
+
callback,
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const payloadJson = JSON.stringify(payload);
|
|
437
|
+
const payloadB64 = base64UrlEncode(payloadJson);
|
|
438
|
+
const url = `${URL_SCHEME}://${SCHEME_PATH_REQUEST}?${QUERY_PARAM_PAYLOAD}=${payloadB64}`;
|
|
439
|
+
|
|
440
|
+
// Detect resume — when the page comes back (visibilitychange ->
|
|
441
|
+
// visible OR hashchange), inspect location.hash for our response.
|
|
442
|
+
let timeoutHandle = null;
|
|
443
|
+
const resumeHandler = () => {
|
|
444
|
+
const response = extractResponseFromHash(window.location.hash, requestId);
|
|
445
|
+
if (response === null) {
|
|
446
|
+
// Not our response (yet) — keep listening.
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
cleanup();
|
|
450
|
+
// Wipe the fragment so a refresh doesn't replay the response.
|
|
451
|
+
try {
|
|
452
|
+
const cleanUrl = window.location.href.split('#')[0];
|
|
453
|
+
window.history.replaceState(null, '', cleanUrl);
|
|
454
|
+
} catch (_) {
|
|
455
|
+
/* best-effort */
|
|
456
|
+
}
|
|
457
|
+
const result = response.result;
|
|
458
|
+
if (result.outcome === 'ok') {
|
|
459
|
+
resolve({ jwt: result.jwt, surface: 'native_app' });
|
|
460
|
+
} else if (result.outcome === 'denied') {
|
|
461
|
+
reject(new SignInError(ERR_USER_DENIED, 'user denied disclosure'));
|
|
462
|
+
} else {
|
|
463
|
+
reject(new SignInError(result.error_code ?? ERR_INTERNAL, result.message ?? ''));
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const cleanup = () => {
|
|
468
|
+
window.removeEventListener('hashchange', resumeHandler);
|
|
469
|
+
document.removeEventListener('visibilitychange', resumeHandler);
|
|
470
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
window.addEventListener('hashchange', resumeHandler);
|
|
474
|
+
document.addEventListener('visibilitychange', resumeHandler);
|
|
475
|
+
|
|
476
|
+
// Try to detect that the URL scheme actually launched a registered
|
|
477
|
+
// app. If the page is still visible 1.5s after dispatching the
|
|
478
|
+
// deep link AND no hash response has arrived, assume no app is
|
|
479
|
+
// installed and reject with `no_wallet_installed`.
|
|
480
|
+
const NO_APP_DETECT_MS = 1500;
|
|
481
|
+
const noAppCheckHandle = setTimeout(() => {
|
|
482
|
+
if (document.visibilityState === 'visible') {
|
|
483
|
+
cleanup();
|
|
484
|
+
clearTimeout(noAppCheckHandle);
|
|
485
|
+
reject(
|
|
486
|
+
new SignInError(
|
|
487
|
+
ERR_NO_WALLET_INSTALLED,
|
|
488
|
+
'no MATA app responded to the deep link'
|
|
489
|
+
)
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}, NO_APP_DETECT_MS);
|
|
493
|
+
|
|
494
|
+
// Hard timeout — user has the configured timeoutMs to complete.
|
|
495
|
+
timeoutHandle = setTimeout(() => {
|
|
496
|
+
cleanup();
|
|
497
|
+
clearTimeout(noAppCheckHandle);
|
|
498
|
+
reject(new SignInError(ERR_TIMEOUT, `no response within ${timeoutMs}ms`));
|
|
499
|
+
}, timeoutMs);
|
|
500
|
+
|
|
501
|
+
// Dispatch the deep link.
|
|
502
|
+
window.location.href = url;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Try to extract a `PageSignInResponse` from the URL hash. Returns
|
|
508
|
+
* `null` if the hash doesn't contain our fragment or the embedded
|
|
509
|
+
* response doesn't match `expectedRequestId`.
|
|
510
|
+
*
|
|
511
|
+
* @internal
|
|
512
|
+
* @param {string} hash
|
|
513
|
+
* @param {string} expectedRequestId
|
|
514
|
+
* @returns {object | null}
|
|
515
|
+
*/
|
|
516
|
+
function extractResponseFromHash(hash, expectedRequestId) {
|
|
517
|
+
if (!hash || hash.length < 2) return null;
|
|
518
|
+
// Strip leading #
|
|
519
|
+
const fragment = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
520
|
+
const pairs = fragment.split('&');
|
|
521
|
+
for (const pair of pairs) {
|
|
522
|
+
const idx = pair.indexOf('=');
|
|
523
|
+
if (idx === -1) continue;
|
|
524
|
+
const key = pair.slice(0, idx);
|
|
525
|
+
if (key !== FRAGMENT_KEY_RESPONSE) continue;
|
|
526
|
+
const value = pair.slice(idx + 1);
|
|
527
|
+
try {
|
|
528
|
+
const json = base64UrlDecode(value);
|
|
529
|
+
const obj = JSON.parse(json);
|
|
530
|
+
if (obj.request_id === expectedRequestId) {
|
|
531
|
+
return obj;
|
|
532
|
+
}
|
|
533
|
+
} catch (_) {
|
|
534
|
+
// Malformed; keep looking.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Generate a UUID v4 for the request_id. Uses `crypto.randomUUID()`
|
|
544
|
+
* where available (modern browsers + Node 19+); falls back to a
|
|
545
|
+
* Math.random()-based generator if not.
|
|
546
|
+
*
|
|
547
|
+
* @internal
|
|
548
|
+
* @returns {string}
|
|
549
|
+
*/
|
|
550
|
+
export function generateRequestId() {
|
|
551
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
552
|
+
return crypto.randomUUID();
|
|
553
|
+
}
|
|
554
|
+
// Fallback — RFC 4122 v4 layout, not cryptographically random but
|
|
555
|
+
// sufficient for in-flight correlation.
|
|
556
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
557
|
+
const r = (Math.random() * 16) | 0;
|
|
558
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
559
|
+
return v.toString(16);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* base64url-encode a string. Browser-compatible (no Buffer).
|
|
565
|
+
*
|
|
566
|
+
* @internal
|
|
567
|
+
* @param {string} str
|
|
568
|
+
* @returns {string}
|
|
569
|
+
*/
|
|
570
|
+
export function base64UrlEncode(str) {
|
|
571
|
+
// UTF-8 → bytes → base64 → URL-safe charset, no padding.
|
|
572
|
+
const utf8Bytes = new TextEncoder().encode(str);
|
|
573
|
+
let binary = '';
|
|
574
|
+
for (const b of utf8Bytes) binary += String.fromCharCode(b);
|
|
575
|
+
const b64 = btoa(binary);
|
|
576
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* base64url-decode to a string.
|
|
581
|
+
*
|
|
582
|
+
* @internal
|
|
583
|
+
* @param {string} b64url
|
|
584
|
+
* @returns {string}
|
|
585
|
+
*/
|
|
586
|
+
export function base64UrlDecode(b64url) {
|
|
587
|
+
// URL-safe charset → standard base64. Add padding back.
|
|
588
|
+
let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
589
|
+
const pad = b64.length % 4;
|
|
590
|
+
if (pad === 2) b64 += '==';
|
|
591
|
+
else if (pad === 3) b64 += '=';
|
|
592
|
+
const binary = atob(b64);
|
|
593
|
+
const bytes = new Uint8Array(binary.length);
|
|
594
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
595
|
+
return new TextDecoder().decode(bytes);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─── Upsell surface re-exports ─────────────────────────────────────────────
|
|
599
|
+
//
|
|
600
|
+
// Re-exported so RPs that want full control (e.g. their own modal UI)
|
|
601
|
+
// can call `showInstallUpsell` directly without depending on the
|
|
602
|
+
// internal lazy-import path. They'd typically pass `installUpsell:
|
|
603
|
+
// false` to `signIn()`, catch `ERR_NO_WALLET_INSTALLED`, and call
|
|
604
|
+
// `showInstallUpsell()` from their own handler.
|
|
605
|
+
|
|
606
|
+
export {
|
|
607
|
+
showInstallUpsell,
|
|
608
|
+
pickInstallCta,
|
|
609
|
+
defaultRefFromOrigin,
|
|
610
|
+
} from './install-upsell.js';
|
|
611
|
+
|
|
612
|
+
// Resume-after-reload surface. `resumePendingSignIn` is the public
|
|
613
|
+
// entry; `clearPendingSignIn` is exposed for RPs who need to drop
|
|
614
|
+
// the stash imperatively (e.g. after navigating to a different
|
|
615
|
+
// sign-in flow that supersedes the pending mID request).
|
|
616
|
+
export { clearPendingSignIn } from './resume.js';
|