signet-login 0.9.11 → 0.9.14
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/dist/modal.js +114 -80
- package/dist/signet-login.iife.js +37 -37
- package/package.json +1 -1
package/dist/modal.js
CHANGED
|
@@ -11,7 +11,6 @@ import { loadOrCreatePersistentClientSk } from './storage.js';
|
|
|
11
11
|
import { waitForAuthResponse } from 'signet-verify';
|
|
12
12
|
import { schnorr } from '@noble/curves/secp256k1';
|
|
13
13
|
import { bytesToHex } from '@noble/hashes/utils';
|
|
14
|
-
import { startRedirect } from './redirect.js';
|
|
15
14
|
import QRCode from 'qrcode';
|
|
16
15
|
const QR_BUNKER_CONNECT_TIMEOUT_MS = 8000;
|
|
17
16
|
function escapeHtml(str) {
|
|
@@ -256,9 +255,10 @@ async function runNip07Flow(refs, opts) {
|
|
|
256
255
|
window.clearInterval(ticker);
|
|
257
256
|
}
|
|
258
257
|
}
|
|
259
|
-
async function runRedirectFlow(refs, opts) {
|
|
258
|
+
async function runRedirectFlow(refs, opts, flowOpts = {}) {
|
|
260
259
|
const dark = isDarkMode(opts.theme);
|
|
261
260
|
const muted = dark ? '#888' : '#666';
|
|
261
|
+
const sameDevice = flowOpts.sameDevice === true;
|
|
262
262
|
// Generate session keypair for cross-device gift-wrap
|
|
263
263
|
const sessionPrivKey = schnorr.utils.randomPrivateKey();
|
|
264
264
|
const sessionPubkey = bytesToHex(schnorr.getPublicKey(sessionPrivKey));
|
|
@@ -274,13 +274,13 @@ async function runRedirectFlow(refs, opts) {
|
|
|
274
274
|
});
|
|
275
275
|
const authUrl = `${opts.signetAppOrigin}/?${params.toString()}`;
|
|
276
276
|
refs.dialog.innerHTML = `
|
|
277
|
-
<h2 style="margin:0 0 8px;font-size:1.2rem;"
|
|
278
|
-
<p style="margin:0 0 16px;color:${muted};font-size:0.85rem;"
|
|
277
|
+
<h2 style="margin:0 0 8px;font-size:1.2rem;">${sameDevice ? 'Open My Signet' : 'Sign in with Signet'}</h2>
|
|
278
|
+
<p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">${sameDevice ? 'Approve in My Signet and keep that tab open so it can sign for this app.' : 'Open the link on your phone, or scan the QR if rendered.'}</p>
|
|
279
279
|
<div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
|
|
280
280
|
<canvas id="signet-login-qr" width="200" height="200" style="display:block;width:200px;height:200px;margin:0 auto 12px;background:#ffffff;border-radius:6px;box-sizing:border-box;"></canvas>
|
|
281
|
-
<a href="${escapeHtml(authUrl)}" target="_blank" rel="noopener" style="display:block;color:#5b6dff;font-size:0.75rem;word-break:break-all;text-decoration:none;">${escapeHtml(authUrl.slice(0, 80))}
|
|
281
|
+
<a id="signet-login-open-signet" href="${escapeHtml(authUrl)}" target="_blank" rel="noopener" style="${sameDevice ? buttonStyle(dark, true) + 'justify-content:center;text-align:center;text-decoration:none;' : 'display:block;color:#5b6dff;font-size:0.75rem;word-break:break-all;text-decoration:none;'}">${sameDevice ? 'Open My Signet' : `${escapeHtml(authUrl.slice(0, 80))}…`}</a>
|
|
282
282
|
</div>
|
|
283
|
-
<p id="signet-login-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;"
|
|
283
|
+
<p id="signet-login-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">${sameDevice ? 'Waiting for My Signet approval…' : 'Waiting for approval…'}</p>
|
|
284
284
|
<div style="display:flex;gap:8px;justify-content:space-between;">
|
|
285
285
|
<button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
|
|
286
286
|
<button data-action="cancel" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Cancel</button>
|
|
@@ -301,6 +301,14 @@ async function runRedirectFlow(refs, opts) {
|
|
|
301
301
|
// — the visible link below the canvas still gets the user across.
|
|
302
302
|
});
|
|
303
303
|
}
|
|
304
|
+
if (sameDevice && typeof window !== 'undefined') {
|
|
305
|
+
try {
|
|
306
|
+
window.open(authUrl, '_blank', 'noopener,noreferrer');
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// Popup blocked or unavailable — the explicit link remains visible.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
304
312
|
return new Promise(resolve => {
|
|
305
313
|
let settled = false;
|
|
306
314
|
const settle = (v) => {
|
|
@@ -348,6 +356,65 @@ async function runRedirectFlow(refs, opts) {
|
|
|
348
356
|
});
|
|
349
357
|
});
|
|
350
358
|
}
|
|
359
|
+
async function buildSessionFromRedirectFlowResult(refs, result, _aborted) {
|
|
360
|
+
// Default: auth-only ephemeral signer (identity proof, no live signing).
|
|
361
|
+
let signer = new EphemeralSigner(result.pubkey, result.authEvent);
|
|
362
|
+
let method = 'redirect';
|
|
363
|
+
// Cross-device / same-device bunker passthrough: when the signer device hands
|
|
364
|
+
// back a `bunker://` URI (its own NIP-46 server, or an upstream hardware
|
|
365
|
+
// bunker), connect before resolving the flow. Consumers such as Pallasite
|
|
366
|
+
// reject auth-only at their auth boundary; returning a cold
|
|
367
|
+
// DeferredBunkerSigner makes them classify the session as non-signing before
|
|
368
|
+
// the relay handshake can finish.
|
|
369
|
+
if (result.bunkerUri) {
|
|
370
|
+
const clientSecretKey = loadOrCreatePersistentClientSk();
|
|
371
|
+
const expected = result.pubkey;
|
|
372
|
+
const status = refs.dialog.querySelector('#signet-login-status');
|
|
373
|
+
if (status)
|
|
374
|
+
status.textContent = 'Connecting signer...';
|
|
375
|
+
try {
|
|
376
|
+
const bunkerSigner = await createBunkerSigner({
|
|
377
|
+
uri: result.bunkerUri,
|
|
378
|
+
clientSecretKey,
|
|
379
|
+
timeoutMs: QR_BUNKER_CONNECT_TIMEOUT_MS,
|
|
380
|
+
});
|
|
381
|
+
if (bunkerSigner.pubkey.toLowerCase() !== expected.toLowerCase()) {
|
|
382
|
+
// Bunker came back as a different key than the identity we proved.
|
|
383
|
+
// Discard it and keep the auth-only identity (result.pubkey) rather than
|
|
384
|
+
// signing as the wrong key — the consumer can prompt for a proper signer
|
|
385
|
+
// if it needs one.
|
|
386
|
+
console.warn('[signet-login] Signet relay upgrade: bunker pubkey mismatch — continuing identity-only', { connected: bunkerSigner.pubkey, expected });
|
|
387
|
+
void bunkerSigner.close().catch(() => { });
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
signer = bunkerSigner;
|
|
391
|
+
method = 'bunker';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
// Bunker connect failed or timed out — signer offline, or a stale handoff
|
|
396
|
+
// URI (the common cross-device failure: the producer re-handed a dead
|
|
397
|
+
// connect string). Do NOT fail the whole sign-in: fall back to the
|
|
398
|
+
// auth-only identity we already hold (the kind-21236 authEvent proves
|
|
399
|
+
// result.pubkey). The consumer decides whether identity-only is enough —
|
|
400
|
+
// one that needs a live signer can prompt for an upgrade rather than being
|
|
401
|
+
// handed null and stranding the user at "couldn't sign in".
|
|
402
|
+
console.warn('[signet-login] Signet relay upgrade: createBunkerSigner failed — continuing identity-only (auth-only).', err);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
console.warn('[signet-login] Signet relay login carried no bunkerUri — auth-only ephemeral (cannot sign). The signer device must have its NIP-46 server enabled to hand back a bunker:// URI.');
|
|
407
|
+
}
|
|
408
|
+
const session = {
|
|
409
|
+
pubkey: result.pubkey,
|
|
410
|
+
method,
|
|
411
|
+
signer,
|
|
412
|
+
authEvent: result.authEvent,
|
|
413
|
+
};
|
|
414
|
+
if (result.displayName)
|
|
415
|
+
session.displayName = result.displayName;
|
|
416
|
+
return session;
|
|
417
|
+
}
|
|
351
418
|
// ── Paste bunker URI ──────────────────────────────────────────────────────────
|
|
352
419
|
async function runBunkerFlow(refs, opts) {
|
|
353
420
|
const dark = isDarkMode(opts.theme);
|
|
@@ -564,12 +631,25 @@ function resolveOptions(opts) {
|
|
|
564
631
|
result.redirectCallback = opts.redirectCallback;
|
|
565
632
|
return result;
|
|
566
633
|
}
|
|
634
|
+
let modalQueue = Promise.resolve();
|
|
567
635
|
/**
|
|
568
636
|
* Entry point — show the modal, route to the chosen method, return a session.
|
|
569
637
|
*
|
|
570
638
|
* Returns null when the user cancels or the flow times out.
|
|
571
639
|
*/
|
|
572
640
|
export async function showLoginModal(opts) {
|
|
641
|
+
const previous = modalQueue;
|
|
642
|
+
let release;
|
|
643
|
+
modalQueue = new Promise(resolve => { release = resolve; });
|
|
644
|
+
await previous;
|
|
645
|
+
try {
|
|
646
|
+
return await runLoginModal(opts);
|
|
647
|
+
}
|
|
648
|
+
finally {
|
|
649
|
+
release();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async function runLoginModal(opts) {
|
|
573
653
|
if (!opts.appName || opts.appName.length === 0)
|
|
574
654
|
throw new Error('appName-required');
|
|
575
655
|
if (opts.appName.length > 64)
|
|
@@ -615,21 +695,27 @@ export async function showLoginModal(opts) {
|
|
|
615
695
|
};
|
|
616
696
|
}
|
|
617
697
|
if (choice === 'redirect') {
|
|
618
|
-
// Same-
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
698
|
+
// Same-device Signet in the modal must keep this app tab alive and keep
|
|
699
|
+
// the My Signet tab alive as the ongoing bunker. Use the relay-backed
|
|
700
|
+
// auth response path here; explicit `login({ mode: 'redirect' })`
|
|
701
|
+
// remains the same-tab redirect API for mobile/single-device callers.
|
|
702
|
+
const result = await Promise.race([runRedirectFlow(refs, resolved, { sameDevice: true }), aborted]);
|
|
703
|
+
if (userAborted)
|
|
704
|
+
return null;
|
|
705
|
+
if (!result) {
|
|
706
|
+
if (resolved.preferredMethod)
|
|
707
|
+
return null;
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const session = await buildSessionFromRedirectFlowResult(refs, result, aborted);
|
|
711
|
+
if (userAborted)
|
|
712
|
+
return null;
|
|
713
|
+
if (!session) {
|
|
714
|
+
if (resolved.preferredMethod)
|
|
715
|
+
return null;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
return session;
|
|
633
719
|
}
|
|
634
720
|
if (choice === 'amber') {
|
|
635
721
|
// Same-tab navigation to a `nostrsigner:` URL. Android dispatches
|
|
@@ -652,66 +738,14 @@ export async function showLoginModal(opts) {
|
|
|
652
738
|
return null;
|
|
653
739
|
continue;
|
|
654
740
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
// their auth boundary; returning a cold DeferredBunkerSigner makes them
|
|
663
|
-
// classify the session as non-signing before the relay handshake can
|
|
664
|
-
// finish.
|
|
665
|
-
if (result.bunkerUri) {
|
|
666
|
-
const clientSecretKey = loadOrCreatePersistentClientSk();
|
|
667
|
-
const expected = result.pubkey;
|
|
668
|
-
const status = refs.dialog.querySelector('#signet-login-status');
|
|
669
|
-
if (status)
|
|
670
|
-
status.textContent = 'Connecting signer...';
|
|
671
|
-
try {
|
|
672
|
-
const bunkerSigner = await createBunkerSigner({
|
|
673
|
-
uri: result.bunkerUri,
|
|
674
|
-
clientSecretKey,
|
|
675
|
-
timeoutMs: QR_BUNKER_CONNECT_TIMEOUT_MS,
|
|
676
|
-
});
|
|
677
|
-
if (bunkerSigner.pubkey.toLowerCase() !== expected.toLowerCase()) {
|
|
678
|
-
console.warn('[signet-login] QR upgrade: bunker pubkey mismatch — cannot sign', { connected: bunkerSigner.pubkey, expected });
|
|
679
|
-
void bunkerSigner.close().catch(() => { });
|
|
680
|
-
if (status) {
|
|
681
|
-
status.textContent = 'Signer connected with the wrong public key.';
|
|
682
|
-
status.style.color = '#d04848';
|
|
683
|
-
}
|
|
684
|
-
await Promise.race([new Promise(resolve => setTimeout(resolve, 2500)), aborted]);
|
|
685
|
-
if (userAborted || resolved.preferredMethod)
|
|
686
|
-
return null;
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
signer = bunkerSigner;
|
|
690
|
-
}
|
|
691
|
-
catch (err) {
|
|
692
|
-
console.warn('[signet-login] QR upgrade: createBunkerSigner failed — signer did not become live.', err);
|
|
693
|
-
if (status) {
|
|
694
|
-
status.textContent = `Signer connection failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
695
|
-
status.style.color = '#d04848';
|
|
696
|
-
}
|
|
697
|
-
await Promise.race([new Promise(resolve => setTimeout(resolve, 2500)), aborted]);
|
|
698
|
-
if (userAborted || resolved.preferredMethod)
|
|
699
|
-
return null;
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
method = 'bunker';
|
|
703
|
-
}
|
|
704
|
-
else {
|
|
705
|
-
console.warn('[signet-login] QR login carried no bunkerUri — auth-only ephemeral (cannot sign). The signer device must have its NIP-46 server enabled to hand back a bunker:// URI.');
|
|
741
|
+
const session = await buildSessionFromRedirectFlowResult(refs, result, aborted);
|
|
742
|
+
if (userAborted)
|
|
743
|
+
return null;
|
|
744
|
+
if (!session) {
|
|
745
|
+
if (resolved.preferredMethod)
|
|
746
|
+
return null;
|
|
747
|
+
continue;
|
|
706
748
|
}
|
|
707
|
-
const session = {
|
|
708
|
-
pubkey: result.pubkey,
|
|
709
|
-
method,
|
|
710
|
-
signer,
|
|
711
|
-
authEvent: result.authEvent,
|
|
712
|
-
};
|
|
713
|
-
if (result.displayName)
|
|
714
|
-
session.displayName = result.displayName;
|
|
715
749
|
return session;
|
|
716
750
|
}
|
|
717
751
|
if (choice === 'bunker') {
|