signet-login 0.10.0 → 0.10.2
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 +60 -11
- package/dist/amber.d.ts +3 -7
- package/dist/amber.js +20 -10
- package/dist/modal.js +54 -25
- package/dist/redirect.d.ts +3 -18
- package/dist/redirect.js +22 -29
- package/dist/signet-login.d.ts +12 -3
- package/dist/signet-login.iife.js +16 -16
- package/dist/signet-login.js +24 -23
- package/dist/storage.d.ts +17 -1
- package/dist/storage.js +162 -25
- package/dist/types.d.ts +25 -6
- package/package.json +2 -3
- package/docs/competitive-audit.md +0 -164
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ Published as `signet-login`.
|
|
|
6
6
|
|
|
7
7
|
**Signet Access** is a drop-in auth and signer-access SDK for Nostr-aware websites. One picker, one session shape, multiple ways to prove identity and, when available, keep a live signer:
|
|
8
8
|
|
|
9
|
-
- **
|
|
9
|
+
- **Local Signet** on this device, against hosted or local-dev Signet
|
|
10
|
+
- **Remote Signet** by cross-device QR, so a phone or second machine can approve
|
|
10
11
|
- **Browser extension** via NIP-07 (bark, Alby, nos2x, Flamingo, ...)
|
|
11
12
|
- **Connect a Nostr signer** via app-initiated NIP-46 / NostrConnect
|
|
12
13
|
- **Paste or scan bunker URI** for Heartwood, nsecBunker, Amber, or compatible signers
|
|
@@ -76,13 +77,22 @@ interface LoginOptions {
|
|
|
76
77
|
signetAppOrigin?: string; // default https://mysignet.app
|
|
77
78
|
redirectCallback?: string; // for same-device redirect / Amber return
|
|
78
79
|
mode?: 'relay' | 'redirect'; // Signet delivery mode
|
|
79
|
-
|
|
80
|
+
storage?: SignetStorage; // default localStorage
|
|
81
|
+
persist?: boolean; // default true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface SignetStorage {
|
|
85
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
86
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
87
|
+
removeItem(key: string): void | Promise<void>;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
type LoginPickerMethod =
|
|
83
91
|
| 'nip07'
|
|
84
|
-
| '
|
|
85
|
-
| '
|
|
92
|
+
| 'local-signet' // same-device Signet, relay delivery
|
|
93
|
+
| 'remote-signet' // cross-device Signet QR
|
|
94
|
+
| 'redirect' // legacy alias for local-signet
|
|
95
|
+
| 'qr' // legacy alias for remote-signet
|
|
86
96
|
| 'bunker' // paste bunker://
|
|
87
97
|
| 'nostrconnect' // show nostrconnect:// QR
|
|
88
98
|
| 'amber' // Android NIP-55
|
|
@@ -103,7 +113,14 @@ By default, the picker shows ordinary user-facing methods first and groups `bunk
|
|
|
103
113
|
```js
|
|
104
114
|
await Signet.login({
|
|
105
115
|
appName: 'My Game',
|
|
106
|
-
methods: ['
|
|
116
|
+
methods: ['local-signet', 'remote-signet', 'nip07'],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await Signet.login({
|
|
120
|
+
appName: 'My Local Dev Game',
|
|
121
|
+
preferredMethod: 'local-signet',
|
|
122
|
+
signetAppOrigin: 'http://localhost:5174',
|
|
123
|
+
relayUrl: 'ws://localhost:7777',
|
|
107
124
|
});
|
|
108
125
|
|
|
109
126
|
await Signet.login({
|
|
@@ -114,6 +131,8 @@ await Signet.login({
|
|
|
114
131
|
});
|
|
115
132
|
```
|
|
116
133
|
|
|
134
|
+
`redirect` and `qr` remain supported picker aliases for existing apps, but new integrations should use `local-signet` and `remote-signet`.
|
|
135
|
+
|
|
117
136
|
When camera APIs are available, the bunker URI screen can scan `bunker://` QR codes directly. Paste remains the fallback.
|
|
118
137
|
|
|
119
138
|
### Headless/custom UI
|
|
@@ -148,9 +167,41 @@ await fetch('/api/login', {
|
|
|
148
167
|
Headless exports include `hasNip07`, `createNip07Signer`, `createBunkerSigner`, `createBunkerSignerFromNostrConnect`, `buildNostrConnectUri`, `createLocalSignerFromNsec`, `createLoginAuthEvent`, `createSessionFromSigner`, and `generateSecretKey`.
|
|
149
168
|
The IIFE bundle attaches the same helpers to `window.Signet`.
|
|
150
169
|
|
|
170
|
+
### Custom storage
|
|
171
|
+
|
|
172
|
+
By default, Signet Access stores session state in localStorage under `signet:login.*`. Pass `storage` when you need encrypted, async, IndexedDB, server-backed, or test storage:
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
const encryptedStorage = {
|
|
176
|
+
async getItem(key) {
|
|
177
|
+
const value = localStorage.getItem(key);
|
|
178
|
+
return value ? await decrypt(value) : null;
|
|
179
|
+
},
|
|
180
|
+
async setItem(key, value) {
|
|
181
|
+
localStorage.setItem(key, await encrypt(value));
|
|
182
|
+
},
|
|
183
|
+
async removeItem(key) {
|
|
184
|
+
localStorage.removeItem(key);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const session = await Signet.login({
|
|
189
|
+
appName: 'My Game',
|
|
190
|
+
storage: encryptedStorage,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await Signet.restoreSession({ storage: encryptedStorage });
|
|
194
|
+
await Signet.handleRedirectCallback({ storage: encryptedStorage });
|
|
195
|
+
await Signet.logout(session, { storage: encryptedStorage });
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Use the same storage adapter for `login`, `restoreSession`, `handleRedirectCallback`, and `logout`.
|
|
199
|
+
|
|
200
|
+
This adapter is deliberately not called "Stash". `@forgesworn/stash` is the separate encrypted cloud-save vault for app data; Signet Access storage is local session/reconnect state needed before a signer is available.
|
|
201
|
+
|
|
151
202
|
### `Signet.restoreSession(opts?)`
|
|
152
203
|
|
|
153
|
-
Restore a session from
|
|
204
|
+
Restore a session from configured storage. For bunker sessions this attempts to reconnect to the stored bunker. Returns `null` if no session is stored, the session is malformed, or reconnection fails.
|
|
154
205
|
|
|
155
206
|
```js
|
|
156
207
|
const session = await Signet.restoreSession();
|
|
@@ -159,7 +210,7 @@ if (session?.signer.capabilities.canSignEvents) {
|
|
|
159
210
|
}
|
|
160
211
|
```
|
|
161
212
|
|
|
162
|
-
### `Signet.logout(currentSession?)`
|
|
213
|
+
### `Signet.logout(currentSession?, opts?)`
|
|
163
214
|
|
|
164
215
|
Clear stored session and close the active signer.
|
|
165
216
|
|
|
@@ -226,7 +277,7 @@ The verifier checks: schnorr signature, canonical event ID, kind=21236, challeng
|
|
|
226
277
|
|
|
227
278
|
## Storage
|
|
228
279
|
|
|
229
|
-
|
|
280
|
+
By default, session data is stored in localStorage under `signet:login.*`:
|
|
230
281
|
|
|
231
282
|
| Key | Purpose |
|
|
232
283
|
|---|---|
|
|
@@ -264,7 +315,7 @@ The ESM entry is approx **5.9 KB gzipped** before bundling dependencies. The sta
|
|
|
264
315
|
|
|
265
316
|
## Browser support
|
|
266
317
|
|
|
267
|
-
ES2020 baseline. Tested on modern Chrome / Firefox / Safari. Requires `
|
|
318
|
+
ES2020 baseline. Tested on modern Chrome / Firefox / Safari. Requires `crypto.subtle`, `WebSocket`, and the native `<dialog>` element. Session persistence defaults to `localStorage`, but apps can provide a custom storage adapter.
|
|
268
319
|
|
|
269
320
|
## Development
|
|
270
321
|
|
|
@@ -282,8 +333,6 @@ Examples in `examples/`:
|
|
|
282
333
|
|
|
283
334
|
Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html` or `examples/headless.html`.
|
|
284
335
|
|
|
285
|
-
See [docs/competitive-audit.md](docs/competitive-audit.md) for the current competitor comparison and roadmap priorities.
|
|
286
|
-
|
|
287
336
|
## Out of scope
|
|
288
337
|
|
|
289
338
|
| Excluded | Where it lives |
|
package/dist/amber.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* environment. Smoke test on a real Android device with Amber installed
|
|
18
18
|
* before promoting to production.
|
|
19
19
|
*/
|
|
20
|
-
import type { SignetSession } from './types.js';
|
|
20
|
+
import type { SignetStorage, SignetSession } from './types.js';
|
|
21
21
|
/** True when running on a likely-Android browser. Lets the picker hide the
|
|
22
22
|
* Amber option on iOS/desktop where the `nostrsigner:` scheme is unhandled. */
|
|
23
23
|
export declare function isAndroid(): boolean;
|
|
@@ -27,6 +27,7 @@ export interface AmberStartOptions {
|
|
|
27
27
|
origin: string;
|
|
28
28
|
/** Optional override for the callback URL. Defaults to current page origin. */
|
|
29
29
|
redirectCallback?: string;
|
|
30
|
+
storage?: SignetStorage;
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
32
33
|
* Build the `nostrsigner:` URL that Android dispatches to Amber. The auth
|
|
@@ -52,10 +53,5 @@ export type ConsumeAmberResult = {
|
|
|
52
53
|
kind: 'invalid';
|
|
53
54
|
reason: string;
|
|
54
55
|
};
|
|
55
|
-
/**
|
|
56
|
-
* Consume an Amber callback. Detects `?event=<base64-or-json>` (or the
|
|
57
|
-
* `signet_amber=1` flag) on the URL and reconstructs a session. Idempotent:
|
|
58
|
-
* a second call after a successful consume returns 'no-callback' because
|
|
59
|
-
* the params have been stripped.
|
|
60
|
-
*/
|
|
61
56
|
export declare function consumeAmberCallback(): ConsumeAmberResult;
|
|
57
|
+
export declare function consumeAmberCallbackFromStorage(storage?: SignetStorage): Promise<ConsumeAmberResult>;
|
package/dist/amber.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PENDING_REDIRECT_TTL_MS } from './types.js';
|
|
2
|
-
import { clearPendingRedirect, loadPendingRedirect,
|
|
2
|
+
import { clearPendingRedirect, clearPendingRedirectFromStorage, loadPendingRedirect, loadPendingRedirectFromStorage, savePendingRedirectToStorage, } from './storage.js';
|
|
3
3
|
import { EphemeralSigner } from './signers.js';
|
|
4
4
|
/** True when running on a likely-Android browser. Lets the picker hide the
|
|
5
5
|
* Amber option on iOS/desktop where the `nostrsigner:` scheme is unhandled. */
|
|
@@ -55,7 +55,7 @@ export function buildAmberSignerUrl(opts) {
|
|
|
55
55
|
* web URL handled by signet-app. The promise never resolves — the page is
|
|
56
56
|
* gone before it can.
|
|
57
57
|
*/
|
|
58
|
-
export function startAmberSignIn(opts) {
|
|
58
|
+
export async function startAmberSignIn(opts) {
|
|
59
59
|
if (typeof window === 'undefined') {
|
|
60
60
|
throw new Error('signet-login: amber mode requires a browser environment');
|
|
61
61
|
}
|
|
@@ -65,7 +65,7 @@ export function startAmberSignIn(opts) {
|
|
|
65
65
|
appName: opts.appName,
|
|
66
66
|
createdAt: Date.now(),
|
|
67
67
|
};
|
|
68
|
-
|
|
68
|
+
await savePendingRedirectToStorage(pending, opts.storage);
|
|
69
69
|
window.location.href = buildAmberSignerUrl(opts);
|
|
70
70
|
return new Promise(() => { });
|
|
71
71
|
}
|
|
@@ -96,22 +96,16 @@ function cleanupAmberCallbackUrl() {
|
|
|
96
96
|
* a second call after a successful consume returns 'no-callback' because
|
|
97
97
|
* the params have been stripped.
|
|
98
98
|
*/
|
|
99
|
-
|
|
99
|
+
function consumeAmberCallbackWithPending(pending, finalize) {
|
|
100
100
|
if (typeof window === 'undefined')
|
|
101
101
|
return { kind: 'no-callback' };
|
|
102
102
|
const params = new URLSearchParams(window.location.search);
|
|
103
103
|
const flagged = params.has('signet_amber') || params.has('event');
|
|
104
104
|
if (!flagged)
|
|
105
105
|
return { kind: 'no-callback' };
|
|
106
|
-
const finalize = (result) => {
|
|
107
|
-
clearPendingRedirect();
|
|
108
|
-
cleanupAmberCallbackUrl();
|
|
109
|
-
return result;
|
|
110
|
-
};
|
|
111
106
|
if (params.get('error') === 'denied') {
|
|
112
107
|
return finalize({ kind: 'denied' });
|
|
113
108
|
}
|
|
114
|
-
const pending = loadPendingRedirect();
|
|
115
109
|
if (!pending) {
|
|
116
110
|
return finalize({ kind: 'invalid', reason: 'no-pending-state' });
|
|
117
111
|
}
|
|
@@ -179,3 +173,19 @@ export function consumeAmberCallback() {
|
|
|
179
173
|
};
|
|
180
174
|
return finalize({ kind: 'session', session });
|
|
181
175
|
}
|
|
176
|
+
export function consumeAmberCallback() {
|
|
177
|
+
const finalize = (result) => {
|
|
178
|
+
clearPendingRedirect();
|
|
179
|
+
cleanupAmberCallbackUrl();
|
|
180
|
+
return result;
|
|
181
|
+
};
|
|
182
|
+
return consumeAmberCallbackWithPending(loadPendingRedirect(), finalize);
|
|
183
|
+
}
|
|
184
|
+
export async function consumeAmberCallbackFromStorage(storage) {
|
|
185
|
+
const finalize = async (result) => {
|
|
186
|
+
await clearPendingRedirectFromStorage(storage);
|
|
187
|
+
cleanupAmberCallbackUrl();
|
|
188
|
+
return result;
|
|
189
|
+
};
|
|
190
|
+
return await consumeAmberCallbackWithPending(await loadPendingRedirectFromStorage(storage), finalize);
|
|
191
|
+
}
|
package/dist/modal.js
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
import { DEFAULTS } from './types.js';
|
|
8
8
|
import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, EphemeralSigner, createLocalSignerFromNsec } from './signers.js';
|
|
9
9
|
import { isAndroid, startAmberSignIn } from './amber.js';
|
|
10
|
-
import {
|
|
10
|
+
import { loadOrCreatePersistentClientSkFromStorage } 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
14
|
import QRCode from 'qrcode';
|
|
15
15
|
import jsQR from 'jsqr';
|
|
16
16
|
const QR_BUNKER_CONNECT_TIMEOUT_MS = 8000;
|
|
17
|
-
const DEFAULT_PICKER_METHODS = ['nip07', 'amber', '
|
|
17
|
+
const DEFAULT_PICKER_METHODS = ['nip07', 'amber', 'local-signet', 'remote-signet', 'bunker', 'nostrconnect', 'nsec'];
|
|
18
|
+
const ALL_PICKER_METHODS = [...DEFAULT_PICKER_METHODS, 'redirect', 'qr'];
|
|
18
19
|
const DEFAULT_ADVANCED_METHODS = ['bunker', 'nostrconnect', 'nsec'];
|
|
19
20
|
const DEFAULT_NOSTR_CONNECT_PERMS = ['sign_event', 'nip44_encrypt', 'nip44_decrypt'];
|
|
20
21
|
function escapeHtml(str) {
|
|
@@ -254,12 +255,28 @@ async function startCameraQrScanner(input) {
|
|
|
254
255
|
const METHOD_META = {
|
|
255
256
|
nip07: { icon: '🌐', title: 'Browser extension', hint: 'bark, Alby, nos2x' },
|
|
256
257
|
amber: { icon: '🤖', title: 'Sign in with Amber', hint: 'Android signer (NIP-55)' },
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
'local-signet': { icon: '🪪', title: 'Local Signet', hint: 'Open Signet on this device' },
|
|
259
|
+
'remote-signet': { icon: '📱', title: 'Remote Signet', hint: 'Scan with Signet on another device' },
|
|
260
|
+
redirect: { icon: '🪪', title: 'Local Signet', hint: 'Open Signet on this device' },
|
|
261
|
+
qr: { icon: '📱', title: 'Remote Signet', hint: 'Scan with Signet on another device' },
|
|
259
262
|
bunker: { icon: '🔑', title: 'Paste bunker URI', hint: 'For NIP-46 power users' },
|
|
260
263
|
nostrconnect: { icon: '📡', title: 'Connect a Nostr signer', hint: 'Scan with nsec.app, Amber, Keychat...' },
|
|
261
264
|
nsec: { icon: '⚠️', title: 'Paste private key', hint: 'In-memory only - risky, last resort' },
|
|
262
265
|
};
|
|
266
|
+
function pickerMethodKey(method) {
|
|
267
|
+
if (method === 'local-signet' || method === 'redirect')
|
|
268
|
+
return 'local-signet';
|
|
269
|
+
if (method === 'remote-signet' || method === 'qr')
|
|
270
|
+
return 'remote-signet';
|
|
271
|
+
return method;
|
|
272
|
+
}
|
|
273
|
+
function routePickerChoice(choice) {
|
|
274
|
+
if (choice === 'local-signet')
|
|
275
|
+
return 'redirect';
|
|
276
|
+
if (choice === 'remote-signet')
|
|
277
|
+
return 'qr';
|
|
278
|
+
return choice;
|
|
279
|
+
}
|
|
263
280
|
function isMethodAvailable(method) {
|
|
264
281
|
if (method === 'nip07')
|
|
265
282
|
return hasNip07();
|
|
@@ -277,9 +294,9 @@ function renderPicker(refs, opts) {
|
|
|
277
294
|
return new Promise(resolve => {
|
|
278
295
|
let advancedOpen = false;
|
|
279
296
|
const availableMethods = opts.methods.filter(isMethodAvailable);
|
|
280
|
-
const advancedSet = new Set(opts.advancedMethods);
|
|
281
|
-
const primaryMethods = availableMethods.filter(method => !advancedSet.has(method));
|
|
282
|
-
const advancedMethods = availableMethods.filter(method => advancedSet.has(method));
|
|
297
|
+
const advancedSet = new Set(opts.advancedMethods.map(pickerMethodKey));
|
|
298
|
+
const primaryMethods = availableMethods.filter(method => !advancedSet.has(pickerMethodKey(method)));
|
|
299
|
+
const advancedMethods = availableMethods.filter(method => advancedSet.has(pickerMethodKey(method)));
|
|
283
300
|
const attachChoiceHandlers = () => {
|
|
284
301
|
refs.dialog.querySelectorAll('button[data-choice]').forEach(btn => {
|
|
285
302
|
btn.addEventListener('click', () => {
|
|
@@ -506,7 +523,7 @@ async function runRedirectFlow(refs, opts, flowOpts = {}) {
|
|
|
506
523
|
});
|
|
507
524
|
});
|
|
508
525
|
}
|
|
509
|
-
async function buildSessionFromRedirectFlowResult(refs, result, _aborted) {
|
|
526
|
+
async function buildSessionFromRedirectFlowResult(refs, result, opts, _aborted) {
|
|
510
527
|
// Default: auth-only ephemeral signer (identity proof, no live signing).
|
|
511
528
|
let signer = new EphemeralSigner(result.pubkey, result.authEvent);
|
|
512
529
|
let method = 'redirect';
|
|
@@ -517,7 +534,7 @@ async function buildSessionFromRedirectFlowResult(refs, result, _aborted) {
|
|
|
517
534
|
// DeferredBunkerSigner makes them classify the session as non-signing before
|
|
518
535
|
// the relay handshake can finish.
|
|
519
536
|
if (result.bunkerUri) {
|
|
520
|
-
const clientSecretKey =
|
|
537
|
+
const clientSecretKey = await loadOrCreatePersistentClientSkFromStorage(opts.storage);
|
|
521
538
|
const expected = result.pubkey;
|
|
522
539
|
const status = refs.dialog.querySelector('#signet-login-status');
|
|
523
540
|
if (status)
|
|
@@ -649,7 +666,10 @@ async function runBunkerFlow(refs, opts) {
|
|
|
649
666
|
}
|
|
650
667
|
connectBtn.disabled = true;
|
|
651
668
|
try {
|
|
652
|
-
const signer = await createBunkerSigner({
|
|
669
|
+
const signer = await createBunkerSigner({
|
|
670
|
+
uri,
|
|
671
|
+
clientSecretKey: await loadOrCreatePersistentClientSkFromStorage(opts.storage),
|
|
672
|
+
});
|
|
653
673
|
settle(signer);
|
|
654
674
|
}
|
|
655
675
|
catch (err) {
|
|
@@ -675,7 +695,7 @@ async function runNostrConnectFlow(refs, opts) {
|
|
|
675
695
|
// Persistent client key so the advertised client pubkey is stable across
|
|
676
696
|
// logins (bunkers auto-approve a bound client pubkey). The connect `secret`
|
|
677
697
|
// stays fresh per handshake — it's a one-time challenge, not an identity.
|
|
678
|
-
const sk =
|
|
698
|
+
const sk = await loadOrCreatePersistentClientSkFromStorage(opts.storage);
|
|
679
699
|
const clientPubkey = bytesToHex(schnorr.getPublicKey(sk));
|
|
680
700
|
const secret = bytesToHex(schnorr.utils.randomPrivateKey()).slice(0, 32);
|
|
681
701
|
const uri = buildNostrConnectUri({
|
|
@@ -803,20 +823,25 @@ async function runNsecFlow(refs, opts) {
|
|
|
803
823
|
}
|
|
804
824
|
function uniquePickerMethods(input, fallback) {
|
|
805
825
|
const source = input ?? fallback;
|
|
806
|
-
const allowed = new Set(
|
|
826
|
+
const allowed = new Set(ALL_PICKER_METHODS);
|
|
827
|
+
const seen = new Set();
|
|
807
828
|
const out = [];
|
|
808
829
|
for (const method of source) {
|
|
809
830
|
if (!allowed.has(method))
|
|
810
831
|
continue;
|
|
811
|
-
|
|
812
|
-
|
|
832
|
+
const key = pickerMethodKey(method);
|
|
833
|
+
if (seen.has(key))
|
|
834
|
+
continue;
|
|
835
|
+
seen.add(key);
|
|
836
|
+
out.push(method);
|
|
813
837
|
}
|
|
814
838
|
return input === undefined && out.length === 0 ? [...fallback] : out;
|
|
815
839
|
}
|
|
816
840
|
function resolveMethodConfig(opts) {
|
|
817
841
|
const methods = uniquePickerMethods(opts.methods, DEFAULT_PICKER_METHODS);
|
|
842
|
+
const methodKeys = new Set(methods.map(pickerMethodKey));
|
|
818
843
|
const advancedMethods = uniquePickerMethods(opts.advancedMethods, DEFAULT_ADVANCED_METHODS)
|
|
819
|
-
.filter(method =>
|
|
844
|
+
.filter(method => methodKeys.has(pickerMethodKey(method)));
|
|
820
845
|
return { methods, advancedMethods };
|
|
821
846
|
}
|
|
822
847
|
function resolveRelayUrls(opts) {
|
|
@@ -849,6 +874,8 @@ function resolveOptions(opts) {
|
|
|
849
874
|
result.preferredMethod = opts.preferredMethod;
|
|
850
875
|
if (opts.redirectCallback !== undefined)
|
|
851
876
|
result.redirectCallback = opts.redirectCallback;
|
|
877
|
+
if (opts.storage !== undefined)
|
|
878
|
+
result.storage = opts.storage;
|
|
852
879
|
return result;
|
|
853
880
|
}
|
|
854
881
|
let modalQueue = Promise.resolve();
|
|
@@ -890,11 +917,12 @@ async function runLoginModal(opts) {
|
|
|
890
917
|
const choice = resolved.preferredMethod
|
|
891
918
|
? resolved.preferredMethod
|
|
892
919
|
: await Promise.race([renderPicker(refs, resolved), aborted]);
|
|
920
|
+
const routeChoice = choice === null ? null : routePickerChoice(choice);
|
|
893
921
|
if (userAborted)
|
|
894
922
|
return null;
|
|
895
|
-
if (
|
|
923
|
+
if (routeChoice === null || routeChoice === 'cancel')
|
|
896
924
|
return null;
|
|
897
|
-
if (
|
|
925
|
+
if (routeChoice === 'nip07') {
|
|
898
926
|
const result = await Promise.race([runNip07Flow(refs, resolved), aborted]);
|
|
899
927
|
if (userAborted)
|
|
900
928
|
return null;
|
|
@@ -914,7 +942,7 @@ async function runLoginModal(opts) {
|
|
|
914
942
|
authEvent: result.authEvent,
|
|
915
943
|
};
|
|
916
944
|
}
|
|
917
|
-
if (
|
|
945
|
+
if (routeChoice === 'redirect') {
|
|
918
946
|
// Same-device Signet in the modal must keep this app tab alive and keep
|
|
919
947
|
// the My Signet tab alive as the ongoing bunker. Use the relay-backed
|
|
920
948
|
// auth response path here; explicit `login({ mode: 'redirect' })`
|
|
@@ -927,7 +955,7 @@ async function runLoginModal(opts) {
|
|
|
927
955
|
return null;
|
|
928
956
|
continue;
|
|
929
957
|
}
|
|
930
|
-
const session = await buildSessionFromRedirectFlowResult(refs, result, aborted);
|
|
958
|
+
const session = await buildSessionFromRedirectFlowResult(refs, result, resolved, aborted);
|
|
931
959
|
if (userAborted)
|
|
932
960
|
return null;
|
|
933
961
|
if (!session) {
|
|
@@ -937,7 +965,7 @@ async function runLoginModal(opts) {
|
|
|
937
965
|
}
|
|
938
966
|
return session;
|
|
939
967
|
}
|
|
940
|
-
if (
|
|
968
|
+
if (routeChoice === 'amber') {
|
|
941
969
|
// Same-tab navigation to a `nostrsigner:` URL. Android dispatches
|
|
942
970
|
// it to Amber; the page comes back via callbackUrl with the signed
|
|
943
971
|
// event in `?event=`. Picked up on next boot by handleRedirectCallback.
|
|
@@ -946,10 +974,11 @@ async function runLoginModal(opts) {
|
|
|
946
974
|
challenge: resolved.challenge,
|
|
947
975
|
origin: resolved.origin,
|
|
948
976
|
...(resolved.redirectCallback !== undefined ? { redirectCallback: resolved.redirectCallback } : {}),
|
|
977
|
+
...(resolved.storage !== undefined ? { storage: resolved.storage } : {}),
|
|
949
978
|
});
|
|
950
979
|
return null; // unreachable
|
|
951
980
|
}
|
|
952
|
-
if (
|
|
981
|
+
if (routeChoice === 'qr') {
|
|
953
982
|
const result = await Promise.race([runRedirectFlow(refs, resolved), aborted]);
|
|
954
983
|
if (userAborted)
|
|
955
984
|
return null;
|
|
@@ -958,7 +987,7 @@ async function runLoginModal(opts) {
|
|
|
958
987
|
return null;
|
|
959
988
|
continue;
|
|
960
989
|
}
|
|
961
|
-
const session = await buildSessionFromRedirectFlowResult(refs, result, aborted);
|
|
990
|
+
const session = await buildSessionFromRedirectFlowResult(refs, result, resolved, aborted);
|
|
962
991
|
if (userAborted)
|
|
963
992
|
return null;
|
|
964
993
|
if (!session) {
|
|
@@ -968,7 +997,7 @@ async function runLoginModal(opts) {
|
|
|
968
997
|
}
|
|
969
998
|
return session;
|
|
970
999
|
}
|
|
971
|
-
if (
|
|
1000
|
+
if (routeChoice === 'bunker') {
|
|
972
1001
|
const signer = await Promise.race([runBunkerFlow(refs, resolved), aborted]);
|
|
973
1002
|
if (userAborted)
|
|
974
1003
|
return null;
|
|
@@ -994,7 +1023,7 @@ async function runLoginModal(opts) {
|
|
|
994
1023
|
authEvent,
|
|
995
1024
|
};
|
|
996
1025
|
}
|
|
997
|
-
if (
|
|
1026
|
+
if (routeChoice === 'nostrconnect') {
|
|
998
1027
|
const signer = await Promise.race([runNostrConnectFlow(refs, resolved), aborted]);
|
|
999
1028
|
if (userAborted)
|
|
1000
1029
|
return null;
|
|
@@ -1023,7 +1052,7 @@ async function runLoginModal(opts) {
|
|
|
1023
1052
|
authEvent,
|
|
1024
1053
|
};
|
|
1025
1054
|
}
|
|
1026
|
-
if (
|
|
1055
|
+
if (routeChoice === 'nsec') {
|
|
1027
1056
|
const signer = await Promise.race([runNsecFlow(refs, resolved), aborted]);
|
|
1028
1057
|
if (userAborted)
|
|
1029
1058
|
return null;
|
package/dist/redirect.d.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* logs a warning — server-side strict verification will fail until the
|
|
27
27
|
* issuer is upgraded.
|
|
28
28
|
*/
|
|
29
|
-
import type { SignetSession } from './types.js';
|
|
29
|
+
import type { SignetStorage, SignetSession } from './types.js';
|
|
30
30
|
import { DEFAULTS } from './types.js';
|
|
31
31
|
/** Subset of resolved options used by the redirect path. */
|
|
32
32
|
export interface RedirectStartOptions {
|
|
@@ -35,6 +35,7 @@ export interface RedirectStartOptions {
|
|
|
35
35
|
origin: string;
|
|
36
36
|
signetAppOrigin: string;
|
|
37
37
|
redirectCallback?: string;
|
|
38
|
+
storage?: SignetStorage;
|
|
38
39
|
}
|
|
39
40
|
/**
|
|
40
41
|
* Build the signet-app auth URL for redirect mode. Deliberately omits `relay`
|
|
@@ -74,22 +75,6 @@ export type ConsumeCallbackResult = {
|
|
|
74
75
|
kind: 'invalid';
|
|
75
76
|
reason: string;
|
|
76
77
|
};
|
|
77
|
-
/**
|
|
78
|
-
* Detect and consume a redirect-back callback. Returns:
|
|
79
|
-
*
|
|
80
|
-
* - { kind: 'session', session } — round-trip valid; clears pending state
|
|
81
|
-
* and strips auth params from the URL
|
|
82
|
-
* - { kind: 'denied' } — signet-app sent `error=denied`
|
|
83
|
-
* - { kind: 'no-callback' } — no auth params in the URL; do nothing
|
|
84
|
-
* - { kind: 'invalid', reason } — params present but failed validation
|
|
85
|
-
* (pending state mismatch, stale, hex
|
|
86
|
-
* malformed, …). Pending state is cleared
|
|
87
|
-
* in this case too — a stale or attacker-
|
|
88
|
-
* supplied URL shouldn't poison the next
|
|
89
|
-
* login attempt.
|
|
90
|
-
*
|
|
91
|
-
* Idempotent: calling it twice on the same loaded page returns 'no-callback'
|
|
92
|
-
* the second time because the URL params have been stripped.
|
|
93
|
-
*/
|
|
94
78
|
export declare function consumeCallback(): ConsumeCallbackResult;
|
|
79
|
+
export declare function consumeCallbackFromStorage(storage?: SignetStorage): Promise<ConsumeCallbackResult>;
|
|
95
80
|
export { DEFAULTS };
|
package/dist/redirect.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* issuer is upgraded.
|
|
28
28
|
*/
|
|
29
29
|
import { DEFAULTS, PENDING_REDIRECT_TTL_MS } from './types.js';
|
|
30
|
-
import { clearPendingRedirect, loadPendingRedirect,
|
|
30
|
+
import { clearPendingRedirect, clearPendingRedirectFromStorage, loadPendingRedirect, loadPendingRedirectFromStorage, savePendingRedirectToStorage, } from './storage.js';
|
|
31
31
|
import { EphemeralSigner } from './signers.js';
|
|
32
32
|
/** Hex regexes — kept local to avoid pulling in @noble for two patterns. */
|
|
33
33
|
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
@@ -58,7 +58,7 @@ export function buildRedirectAuthUrl(opts) {
|
|
|
58
58
|
* mode in non-browser code is a programming error, not something to silently
|
|
59
59
|
* swallow.
|
|
60
60
|
*/
|
|
61
|
-
export function startRedirect(opts) {
|
|
61
|
+
export async function startRedirect(opts) {
|
|
62
62
|
if (typeof window === 'undefined') {
|
|
63
63
|
throw new Error('signet-login: redirect mode requires a browser environment');
|
|
64
64
|
}
|
|
@@ -68,7 +68,7 @@ export function startRedirect(opts) {
|
|
|
68
68
|
appName: opts.appName,
|
|
69
69
|
createdAt: Date.now(),
|
|
70
70
|
};
|
|
71
|
-
|
|
71
|
+
await savePendingRedirectToStorage(pending, opts.storage);
|
|
72
72
|
const url = buildRedirectAuthUrl(opts);
|
|
73
73
|
// Use assignment (not replace) so the user can hit back to abort. The
|
|
74
74
|
// pending record stays put; consumeCallback will GC it via the freshness
|
|
@@ -106,24 +106,7 @@ function cleanupCallbackUrl() {
|
|
|
106
106
|
// history API blocked (file:// origin, sandboxed iframe, …) — leave URL alone
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
-
|
|
110
|
-
* Detect and consume a redirect-back callback. Returns:
|
|
111
|
-
*
|
|
112
|
-
* - { kind: 'session', session } — round-trip valid; clears pending state
|
|
113
|
-
* and strips auth params from the URL
|
|
114
|
-
* - { kind: 'denied' } — signet-app sent `error=denied`
|
|
115
|
-
* - { kind: 'no-callback' } — no auth params in the URL; do nothing
|
|
116
|
-
* - { kind: 'invalid', reason } — params present but failed validation
|
|
117
|
-
* (pending state mismatch, stale, hex
|
|
118
|
-
* malformed, …). Pending state is cleared
|
|
119
|
-
* in this case too — a stale or attacker-
|
|
120
|
-
* supplied URL shouldn't poison the next
|
|
121
|
-
* login attempt.
|
|
122
|
-
*
|
|
123
|
-
* Idempotent: calling it twice on the same loaded page returns 'no-callback'
|
|
124
|
-
* the second time because the URL params have been stripped.
|
|
125
|
-
*/
|
|
126
|
-
export function consumeCallback() {
|
|
109
|
+
function consumeCallbackWithPending(pending, finalize) {
|
|
127
110
|
if (typeof window === 'undefined')
|
|
128
111
|
return { kind: 'no-callback' };
|
|
129
112
|
const params = new URLSearchParams(window.location.search);
|
|
@@ -135,14 +118,6 @@ export function consumeCallback() {
|
|
|
135
118
|
if (!error && !pubkey && !signature && !eventId) {
|
|
136
119
|
return { kind: 'no-callback' };
|
|
137
120
|
}
|
|
138
|
-
const pending = loadPendingRedirect();
|
|
139
|
-
// From here on we're handling a callback — pending state must always be
|
|
140
|
-
// cleared on exit so a stale record can't be reused.
|
|
141
|
-
const finalize = (result) => {
|
|
142
|
-
clearPendingRedirect();
|
|
143
|
-
cleanupCallbackUrl();
|
|
144
|
-
return result;
|
|
145
|
-
};
|
|
146
121
|
if (error === 'denied') {
|
|
147
122
|
return finalize({ kind: 'denied' });
|
|
148
123
|
}
|
|
@@ -254,6 +229,24 @@ export function consumeCallback() {
|
|
|
254
229
|
}
|
|
255
230
|
return finalize(bunkerUri ? { kind: 'session', session, bunkerUri } : { kind: 'session', session });
|
|
256
231
|
}
|
|
232
|
+
export function consumeCallback() {
|
|
233
|
+
// From here on we're handling a callback — pending state must always be
|
|
234
|
+
// cleared on exit so a stale record can't be reused.
|
|
235
|
+
const finalize = (result) => {
|
|
236
|
+
clearPendingRedirect();
|
|
237
|
+
cleanupCallbackUrl();
|
|
238
|
+
return result;
|
|
239
|
+
};
|
|
240
|
+
return consumeCallbackWithPending(loadPendingRedirect(), finalize);
|
|
241
|
+
}
|
|
242
|
+
export async function consumeCallbackFromStorage(storage) {
|
|
243
|
+
const finalize = async (result) => {
|
|
244
|
+
await clearPendingRedirectFromStorage(storage);
|
|
245
|
+
cleanupCallbackUrl();
|
|
246
|
+
return result;
|
|
247
|
+
};
|
|
248
|
+
return await consumeCallbackWithPending(await loadPendingRedirectFromStorage(storage), finalize);
|
|
249
|
+
}
|
|
257
250
|
// Re-export DEFAULTS for tree-shaking-friendly callers that want to avoid
|
|
258
251
|
// importing the full types module just for one constant.
|
|
259
252
|
export { DEFAULTS };
|
package/dist/signet-login.d.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* already there (so `signet-verify.iife.js` and `signet-login.iife.js` coexist
|
|
15
15
|
* in either load order on the same page).
|
|
16
16
|
*/
|
|
17
|
-
export type { NostrEvent, EventTemplate, LoginMethod, LoginPickerMethod, SignerCapabilities, SignetSigner, SignetAuthEvent, SignetSession, LoginOptions, RestoreOptions, } from './types.js';
|
|
18
|
-
import type { SignetSigner, LoginOptions, RestoreOptions, SignetSession, SignetAuthEvent } from './types.js';
|
|
17
|
+
export type { NostrEvent, EventTemplate, LoginMethod, LoginPickerMethod, SignerCapabilities, SignetSigner, SignetAuthEvent, SignetSession, LoginOptions, RestoreOptions, SignetStorage, } from './types.js';
|
|
18
|
+
import type { SignetSigner, LoginOptions, RestoreOptions, SignetSession, SignetAuthEvent, SignetStorage } from './types.js';
|
|
19
19
|
import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, createLocalSignerFromNsec, generateSecretKey, Nip07Signer, BunkerSignerImpl, LocalSigner } from './signers.js';
|
|
20
20
|
import { type ConsumeAmberResult } from './amber.js';
|
|
21
21
|
import { handleCallback as handlePopupCallback } from './callback.js';
|
|
@@ -34,6 +34,11 @@ export interface HandleRedirectCallbackOptions {
|
|
|
34
34
|
* should set this true and reject auth-only returns at their boundary.
|
|
35
35
|
*/
|
|
36
36
|
waitForBunker?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Storage backend for pending redirect consumption and session persistence.
|
|
39
|
+
* Must match the backend passed to `login({ mode: 'redirect', storage })`.
|
|
40
|
+
*/
|
|
41
|
+
storage?: SignetStorage;
|
|
37
42
|
}
|
|
38
43
|
export interface CreateLoginAuthEventOptions {
|
|
39
44
|
/** Required. Bound into the auth event's `app` tag. */
|
|
@@ -43,6 +48,10 @@ export interface CreateLoginAuthEventOptions {
|
|
|
43
48
|
/** Origin to bind into the proof. Defaults to `window.location.origin`. */
|
|
44
49
|
origin?: string;
|
|
45
50
|
}
|
|
51
|
+
export interface LogoutOptions {
|
|
52
|
+
/** Storage backend to clear. Defaults to localStorage. */
|
|
53
|
+
storage?: SignetStorage;
|
|
54
|
+
}
|
|
46
55
|
/**
|
|
47
56
|
* Show the login picker and resolve to a SignetSession on success, or null on
|
|
48
57
|
* cancel / timeout.
|
|
@@ -117,4 +126,4 @@ export declare function handleRedirectCallback(options?: HandleRedirectCallbackO
|
|
|
117
126
|
/**
|
|
118
127
|
* Clear the stored session and close the active signer.
|
|
119
128
|
*/
|
|
120
|
-
export declare function logout(currentSession?: SignetSession): Promise<void>;
|
|
129
|
+
export declare function logout(currentSession?: SignetSession, opts?: LogoutOptions): Promise<void>;
|