signet-login 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 +234 -0
- package/dist/callback.d.ts +27 -0
- package/dist/callback.js +46 -0
- package/dist/modal.d.ts +13 -0
- package/dist/modal.js +401 -0
- package/dist/redirect.d.ts +85 -0
- package/dist/redirect.js +226 -0
- package/dist/signers.d.ts +81 -0
- package/dist/signers.js +128 -0
- package/dist/signet-login.d.ts +87 -0
- package/dist/signet-login.iife.js +77 -0
- package/dist/signet-login.js +265 -0
- package/dist/storage.d.ts +38 -0
- package/dist/storage.js +159 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +35 -0
- package/dist/verify.d.ts +43 -0
- package/dist/verify.js +117 -0
- package/package.json +64 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signet Login SDK — Sign in with Signet for Nostr-aware websites.
|
|
3
|
+
*
|
|
4
|
+
* ESM / bundler usage:
|
|
5
|
+
* import { login, restoreSession, logout } from 'signet-login';
|
|
6
|
+
*
|
|
7
|
+
* Script-tag / IIFE usage (additively extends `window.Signet`):
|
|
8
|
+
* <script src="https://cdn.signet.forgesworn.dev/signet-login.iife.js"></script>
|
|
9
|
+
* <script>
|
|
10
|
+
* const session = await Signet.login({ appName: 'Asteroid Sats' });
|
|
11
|
+
* </script>
|
|
12
|
+
*
|
|
13
|
+
* The IIFE bundle does NOT overwrite `window.Signet` — it augments whatever is
|
|
14
|
+
* already there (so `signet-verify.iife.js` and `signet-login.iife.js` coexist
|
|
15
|
+
* in either load order on the same page).
|
|
16
|
+
*/
|
|
17
|
+
import { DEFAULTS } from './types.js';
|
|
18
|
+
import { showLoginModal } from './modal.js';
|
|
19
|
+
import { saveSession, loadSession, clearSession, bytesToHexLocal, hexToBytesLocal } from './storage.js';
|
|
20
|
+
import { hasNip07, createNip07Signer, createBunkerSigner, EphemeralSigner, } from './signers.js';
|
|
21
|
+
import { handleCallback as handlePopupCallback } from './callback.js';
|
|
22
|
+
import { consumeCallback, startRedirect } from './redirect.js';
|
|
23
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Show the login picker and resolve to a SignetSession on success, or null on
|
|
26
|
+
* cancel / timeout.
|
|
27
|
+
*
|
|
28
|
+
* When `mode: 'redirect'` is set, the picker is skipped entirely — the current
|
|
29
|
+
* tab navigates to signet-app and this promise NEVER resolves in this tab.
|
|
30
|
+
* Callers should treat the returned promise as "fire and forget" in that case
|
|
31
|
+
* and call `Signet.handleCallback()` on the next page load to receive the
|
|
32
|
+
* session. The other login methods (NIP-07, bunker) don't use redirect at all
|
|
33
|
+
* and are unaffected by this option.
|
|
34
|
+
*/
|
|
35
|
+
export async function login(opts) {
|
|
36
|
+
// Redirect mode short-circuits the picker — the user is going to signet-app.
|
|
37
|
+
// We don't gate on preferredMethod here: redirect mode implies the consumer
|
|
38
|
+
// wants the Sign in with Signet method, which is the only method that uses
|
|
39
|
+
// navigation. (NIP-07 and bunker resolve in-tab regardless of mode.)
|
|
40
|
+
if (opts.mode === 'redirect') {
|
|
41
|
+
if (typeof window === 'undefined') {
|
|
42
|
+
throw new Error('signet-login: redirect mode requires a browser environment');
|
|
43
|
+
}
|
|
44
|
+
const challenge = opts.challenge ?? generateChallenge();
|
|
45
|
+
if (!/^[0-9a-f]{64}$/i.test(challenge))
|
|
46
|
+
throw new Error('challenge-must-be-64-hex');
|
|
47
|
+
if (!opts.appName || opts.appName.length === 0)
|
|
48
|
+
throw new Error('appName-required');
|
|
49
|
+
if (opts.appName.length > 64)
|
|
50
|
+
throw new Error('appName-too-long');
|
|
51
|
+
return startRedirect({
|
|
52
|
+
appName: opts.appName,
|
|
53
|
+
challenge: challenge.toLowerCase(),
|
|
54
|
+
origin: window.location.origin,
|
|
55
|
+
signetAppOrigin: opts.signetAppOrigin ?? DEFAULTS.signetAppOrigin,
|
|
56
|
+
...(opts.redirectCallback !== undefined ? { redirectCallback: opts.redirectCallback } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const session = await showLoginModal(opts);
|
|
60
|
+
if (!session)
|
|
61
|
+
return null;
|
|
62
|
+
if (opts.persist !== false) {
|
|
63
|
+
persistSession(session);
|
|
64
|
+
}
|
|
65
|
+
return session;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Generate a 64-hex random challenge. Mirrors the modal's helper but lives at
|
|
69
|
+
* the module level so the redirect path can call it without pulling the modal
|
|
70
|
+
* into the bundle when only `mode: 'redirect'` is used.
|
|
71
|
+
*/
|
|
72
|
+
function generateChallenge() {
|
|
73
|
+
const bytes = new Uint8Array(32);
|
|
74
|
+
crypto.getRandomValues(bytes);
|
|
75
|
+
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Try to restore a session from localStorage. Returns null if no session is
|
|
79
|
+
* stored or it's malformed/expired.
|
|
80
|
+
*
|
|
81
|
+
* For bunker sessions, attempts to reconnect using the stored URI + client SK.
|
|
82
|
+
* If the bunker is unreachable, returns null and clears the stored session.
|
|
83
|
+
*/
|
|
84
|
+
export async function restoreSession(opts) {
|
|
85
|
+
const stored = loadSession();
|
|
86
|
+
if (!stored)
|
|
87
|
+
return null;
|
|
88
|
+
let authEvent;
|
|
89
|
+
try {
|
|
90
|
+
authEvent = JSON.parse(stored.authEventJson);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
clearSession();
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (stored.method === 'nip07') {
|
|
97
|
+
if (!hasNip07()) {
|
|
98
|
+
// Extension was uninstalled — return ephemeral identity-only session
|
|
99
|
+
const ephemeral = new EphemeralSigner(stored.pubkey, authEvent);
|
|
100
|
+
return {
|
|
101
|
+
pubkey: stored.pubkey,
|
|
102
|
+
method: 'redirect', // downgrade — caller sees "no signing"
|
|
103
|
+
signer: ephemeral,
|
|
104
|
+
authEvent,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const signer = await createNip07Signer();
|
|
109
|
+
// Verify the same pubkey is selected — extension may have switched accounts
|
|
110
|
+
if (signer.pubkey !== stored.pubkey) {
|
|
111
|
+
clearSession();
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
pubkey: stored.pubkey,
|
|
116
|
+
method: 'nip07',
|
|
117
|
+
signer,
|
|
118
|
+
authEvent,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
clearSession();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (stored.method === 'bunker') {
|
|
127
|
+
if (opts?.reconnectBunker === false) {
|
|
128
|
+
const ephemeral = new EphemeralSigner(stored.pubkey, authEvent);
|
|
129
|
+
return {
|
|
130
|
+
pubkey: stored.pubkey,
|
|
131
|
+
method: 'redirect',
|
|
132
|
+
signer: ephemeral,
|
|
133
|
+
authEvent,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!stored.bunkerUri || !stored.bunkerClientSkHex) {
|
|
137
|
+
clearSession();
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const sk = hexToBytesLocal(stored.bunkerClientSkHex);
|
|
142
|
+
const signer = await createBunkerSigner({ uri: stored.bunkerUri, clientSecretKey: sk });
|
|
143
|
+
if (signer.pubkey !== stored.pubkey) {
|
|
144
|
+
await signer.close();
|
|
145
|
+
clearSession();
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
pubkey: stored.pubkey,
|
|
150
|
+
method: 'bunker',
|
|
151
|
+
signer,
|
|
152
|
+
authEvent,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
clearSession();
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// method === 'redirect' — restore as ephemeral (auth-only)
|
|
161
|
+
const ephemeral = new EphemeralSigner(stored.pubkey, authEvent);
|
|
162
|
+
const session = {
|
|
163
|
+
pubkey: stored.pubkey,
|
|
164
|
+
method: 'redirect',
|
|
165
|
+
signer: ephemeral,
|
|
166
|
+
authEvent,
|
|
167
|
+
};
|
|
168
|
+
if (stored.displayName)
|
|
169
|
+
session.displayName = stored.displayName;
|
|
170
|
+
return session;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Popup-style callback receiver. Use on the page that signet-app redirects
|
|
174
|
+
* a popup to. Parses URL params and posts them to `window.opener`, then
|
|
175
|
+
* closes the popup. Returns the raw params for non-popup contexts.
|
|
176
|
+
*
|
|
177
|
+
* For the same-tab redirect flow (`mode: 'redirect'` on `login()`), use
|
|
178
|
+
* `Signet.handleRedirectCallback()` instead — that one validates against the
|
|
179
|
+
* persisted pending state and returns a fully-formed `SignetSession`.
|
|
180
|
+
*/
|
|
181
|
+
export const handleCallback = handlePopupCallback;
|
|
182
|
+
/**
|
|
183
|
+
* Same-tab redirect callback receiver. Call once on app boot, before
|
|
184
|
+
* `restoreSession()`, to consume an incoming `?pubkey&signature&eventId`
|
|
185
|
+
* payload from signet-app.
|
|
186
|
+
*
|
|
187
|
+
* Behaviour:
|
|
188
|
+
*
|
|
189
|
+
* - `'session'`: validates the round-trip against the pending state saved
|
|
190
|
+
* by `login({ mode: 'redirect' })`, builds and persists a SignetSession
|
|
191
|
+
* (so `restoreSession()` finds it next time), and strips the auth params
|
|
192
|
+
* from the URL via `history.replaceState`. The returned session uses an
|
|
193
|
+
* `EphemeralSigner` — `signer.capabilities.canSignEvents` is false. Pair
|
|
194
|
+
* with NIP-07 / bunker if you need ongoing signing.
|
|
195
|
+
*
|
|
196
|
+
* - `'denied'`: signet-app reported the user rejected the request.
|
|
197
|
+
*
|
|
198
|
+
* - `'no-callback'`: no auth params on the URL — the typical case on most
|
|
199
|
+
* page loads. Idempotent: a second call after success also returns this.
|
|
200
|
+
*
|
|
201
|
+
* - `'invalid'`: params present but failed validation. `reason` is a
|
|
202
|
+
* machine-readable token (`origin-mismatch`, `pending-stale`,
|
|
203
|
+
* `pubkey-malformed`, …). Pending state is cleared either way so a stale
|
|
204
|
+
* URL can't poison the next attempt.
|
|
205
|
+
*
|
|
206
|
+
* The returned shape is intentionally tagged so consumers can distinguish
|
|
207
|
+
* "user denied" from "no callback" without inspecting null. Persistence on
|
|
208
|
+
* success uses the same storage layer as relay-mode sessions, so downstream
|
|
209
|
+
* code that consumes `restoreSession()` doesn't need to care which path
|
|
210
|
+
* authenticated the user.
|
|
211
|
+
*/
|
|
212
|
+
export async function handleRedirectCallback() {
|
|
213
|
+
const result = consumeCallback();
|
|
214
|
+
if (result.kind === 'session') {
|
|
215
|
+
persistSession(result.session);
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Clear the stored session and close the active signer.
|
|
221
|
+
*/
|
|
222
|
+
export async function logout(currentSession) {
|
|
223
|
+
if (currentSession) {
|
|
224
|
+
try {
|
|
225
|
+
await currentSession.signer.close();
|
|
226
|
+
}
|
|
227
|
+
catch { /* ignore */ }
|
|
228
|
+
}
|
|
229
|
+
clearSession();
|
|
230
|
+
}
|
|
231
|
+
// ── Persistence helpers (internal) ────────────────────────────────────────────
|
|
232
|
+
function persistSession(session) {
|
|
233
|
+
const payload = {
|
|
234
|
+
pubkey: session.pubkey,
|
|
235
|
+
method: session.method,
|
|
236
|
+
authEventJson: JSON.stringify(session.authEvent),
|
|
237
|
+
};
|
|
238
|
+
if (session.method === 'bunker') {
|
|
239
|
+
// Cast: in bunker mode, the signer is a BunkerSignerImpl with bunkerUri + clientSecretKey
|
|
240
|
+
const bunkerSigner = session.signer;
|
|
241
|
+
if (bunkerSigner.bunkerUri && bunkerSigner.clientSecretKey instanceof Uint8Array) {
|
|
242
|
+
payload.bunkerUri = bunkerSigner.bunkerUri;
|
|
243
|
+
payload.bunkerClientSkHex = bytesToHexLocal(bunkerSigner.clientSecretKey);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (session.expiresAt !== undefined)
|
|
247
|
+
payload.expiresAt = session.expiresAt;
|
|
248
|
+
if (session.displayName !== undefined)
|
|
249
|
+
payload.displayName = session.displayName;
|
|
250
|
+
saveSession(payload);
|
|
251
|
+
}
|
|
252
|
+
// ── Auto-attach to window.Signet (additive) ───────────────────────────────────
|
|
253
|
+
if (typeof window !== 'undefined') {
|
|
254
|
+
// Never overwrite — additive only. Coexists with signet-verify on the same page.
|
|
255
|
+
const existing = window.Signet;
|
|
256
|
+
const SignetGlobal = existing ?? {};
|
|
257
|
+
Object.assign(SignetGlobal, {
|
|
258
|
+
login,
|
|
259
|
+
restoreSession,
|
|
260
|
+
logout,
|
|
261
|
+
handleCallback,
|
|
262
|
+
handleRedirectCallback,
|
|
263
|
+
});
|
|
264
|
+
window.Signet = SignetGlobal;
|
|
265
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage persistence for signet-login sessions.
|
|
3
|
+
*
|
|
4
|
+
* Storage keys are namespaced under `signet:login.*` so they don't collide
|
|
5
|
+
* with `signet:verify.*` or any future Signet SDK.
|
|
6
|
+
*/
|
|
7
|
+
import type { LoginMethod, PendingRedirect } from './types.js';
|
|
8
|
+
/** Raw shape of a persisted session — flat string fields, JSON for the auth event. */
|
|
9
|
+
export interface PersistedSession {
|
|
10
|
+
pubkey: string;
|
|
11
|
+
method: LoginMethod;
|
|
12
|
+
authEventJson: string;
|
|
13
|
+
bunkerUri?: string;
|
|
14
|
+
bunkerClientSkHex?: string;
|
|
15
|
+
expiresAt?: number;
|
|
16
|
+
displayName?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Save a session. Caller must serialise authEvent to JSON. */
|
|
19
|
+
export declare function saveSession(s: PersistedSession): void;
|
|
20
|
+
/** Load a session if one is present. Returns null if no session or it's malformed. */
|
|
21
|
+
export declare function loadSession(): PersistedSession | null;
|
|
22
|
+
/** Clear all signet-login keys. Does not touch other Signet SDK storage. */
|
|
23
|
+
export declare function clearSession(): void;
|
|
24
|
+
/**
|
|
25
|
+
* Persist the in-flight redirect state. Called immediately before navigating
|
|
26
|
+
* to signet-app so the callback consumer can validate the round-trip.
|
|
27
|
+
*
|
|
28
|
+
* Stored as a single JSON blob under `signet:login.pendingRedirect`. We keep
|
|
29
|
+
* it in localStorage rather than sessionStorage because some browsers (older
|
|
30
|
+
* iOS Safari especially) wipe sessionStorage on cross-origin navigation.
|
|
31
|
+
*/
|
|
32
|
+
export declare function savePendingRedirect(p: PendingRedirect): void;
|
|
33
|
+
/** Load and shape-validate the pending redirect. Returns null if absent or malformed. */
|
|
34
|
+
export declare function loadPendingRedirect(): PendingRedirect | null;
|
|
35
|
+
/** Clear the pending-redirect record. Safe to call when none exists. */
|
|
36
|
+
export declare function clearPendingRedirect(): void;
|
|
37
|
+
export declare function bytesToHexLocal(bytes: Uint8Array): string;
|
|
38
|
+
export declare function hexToBytesLocal(hex: string): Uint8Array;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage persistence for signet-login sessions.
|
|
3
|
+
*
|
|
4
|
+
* Storage keys are namespaced under `signet:login.*` so they don't collide
|
|
5
|
+
* with `signet:verify.*` or any future Signet SDK.
|
|
6
|
+
*/
|
|
7
|
+
import { STORAGE_KEYS } from './types.js';
|
|
8
|
+
function safeGet(key) {
|
|
9
|
+
try {
|
|
10
|
+
return typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function safeSet(key, value) {
|
|
17
|
+
try {
|
|
18
|
+
if (typeof localStorage !== 'undefined')
|
|
19
|
+
localStorage.setItem(key, value);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// localStorage unavailable (private mode, quota, etc.) — silently skip
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function safeRemove(key) {
|
|
26
|
+
try {
|
|
27
|
+
if (typeof localStorage !== 'undefined')
|
|
28
|
+
localStorage.removeItem(key);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Save a session. Caller must serialise authEvent to JSON. */
|
|
35
|
+
export function saveSession(s) {
|
|
36
|
+
safeSet(STORAGE_KEYS.pubkey, s.pubkey);
|
|
37
|
+
safeSet(STORAGE_KEYS.method, s.method);
|
|
38
|
+
safeSet(STORAGE_KEYS.authEvent, s.authEventJson);
|
|
39
|
+
if (s.bunkerUri !== undefined)
|
|
40
|
+
safeSet(STORAGE_KEYS.bunkerUri, s.bunkerUri);
|
|
41
|
+
if (s.bunkerClientSkHex !== undefined)
|
|
42
|
+
safeSet(STORAGE_KEYS.bunkerClientSk, s.bunkerClientSkHex);
|
|
43
|
+
if (s.expiresAt !== undefined)
|
|
44
|
+
safeSet(STORAGE_KEYS.expiresAt, String(s.expiresAt));
|
|
45
|
+
if (s.displayName !== undefined)
|
|
46
|
+
safeSet(STORAGE_KEYS.displayName, s.displayName);
|
|
47
|
+
}
|
|
48
|
+
/** Load a session if one is present. Returns null if no session or it's malformed. */
|
|
49
|
+
export function loadSession() {
|
|
50
|
+
const pubkey = safeGet(STORAGE_KEYS.pubkey);
|
|
51
|
+
const method = safeGet(STORAGE_KEYS.method);
|
|
52
|
+
const authEventJson = safeGet(STORAGE_KEYS.authEvent);
|
|
53
|
+
if (!pubkey || !method || !authEventJson)
|
|
54
|
+
return null;
|
|
55
|
+
if (!/^[0-9a-f]{64}$/i.test(pubkey))
|
|
56
|
+
return null;
|
|
57
|
+
if (method !== 'nip07' && method !== 'redirect' && method !== 'bunker')
|
|
58
|
+
return null;
|
|
59
|
+
// Sanity-parse the auth event before returning
|
|
60
|
+
let authEvent;
|
|
61
|
+
try {
|
|
62
|
+
authEvent = JSON.parse(authEventJson);
|
|
63
|
+
if (typeof authEvent !== 'object' || authEvent === null)
|
|
64
|
+
return null;
|
|
65
|
+
if (authEvent.pubkey !== pubkey)
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const expiresAtRaw = safeGet(STORAGE_KEYS.expiresAt);
|
|
72
|
+
const expiresAt = expiresAtRaw ? Number(expiresAtRaw) : undefined;
|
|
73
|
+
if (expiresAt !== undefined && Number.isFinite(expiresAt) && Date.now() > expiresAt) {
|
|
74
|
+
// Session expired — drop it
|
|
75
|
+
clearSession();
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const result = { pubkey, method, authEventJson };
|
|
79
|
+
const bunkerUri = safeGet(STORAGE_KEYS.bunkerUri);
|
|
80
|
+
const bunkerClientSkHex = safeGet(STORAGE_KEYS.bunkerClientSk);
|
|
81
|
+
const displayName = safeGet(STORAGE_KEYS.displayName);
|
|
82
|
+
if (bunkerUri)
|
|
83
|
+
result.bunkerUri = bunkerUri;
|
|
84
|
+
if (bunkerClientSkHex)
|
|
85
|
+
result.bunkerClientSkHex = bunkerClientSkHex;
|
|
86
|
+
if (expiresAt !== undefined && Number.isFinite(expiresAt))
|
|
87
|
+
result.expiresAt = expiresAt;
|
|
88
|
+
if (displayName)
|
|
89
|
+
result.displayName = displayName;
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/** Clear all signet-login keys. Does not touch other Signet SDK storage. */
|
|
93
|
+
export function clearSession() {
|
|
94
|
+
safeRemove(STORAGE_KEYS.pubkey);
|
|
95
|
+
safeRemove(STORAGE_KEYS.method);
|
|
96
|
+
safeRemove(STORAGE_KEYS.authEvent);
|
|
97
|
+
safeRemove(STORAGE_KEYS.bunkerUri);
|
|
98
|
+
safeRemove(STORAGE_KEYS.bunkerClientSk);
|
|
99
|
+
safeRemove(STORAGE_KEYS.expiresAt);
|
|
100
|
+
safeRemove(STORAGE_KEYS.displayName);
|
|
101
|
+
}
|
|
102
|
+
// ── Pending-redirect persistence ──────────────────────────────────────────────
|
|
103
|
+
/**
|
|
104
|
+
* Persist the in-flight redirect state. Called immediately before navigating
|
|
105
|
+
* to signet-app so the callback consumer can validate the round-trip.
|
|
106
|
+
*
|
|
107
|
+
* Stored as a single JSON blob under `signet:login.pendingRedirect`. We keep
|
|
108
|
+
* it in localStorage rather than sessionStorage because some browsers (older
|
|
109
|
+
* iOS Safari especially) wipe sessionStorage on cross-origin navigation.
|
|
110
|
+
*/
|
|
111
|
+
export function savePendingRedirect(p) {
|
|
112
|
+
safeSet(STORAGE_KEYS.pendingRedirect, JSON.stringify(p));
|
|
113
|
+
}
|
|
114
|
+
/** Load and shape-validate the pending redirect. Returns null if absent or malformed. */
|
|
115
|
+
export function loadPendingRedirect() {
|
|
116
|
+
const raw = safeGet(STORAGE_KEYS.pendingRedirect);
|
|
117
|
+
if (!raw)
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(raw);
|
|
121
|
+
const challenge = parsed.challenge;
|
|
122
|
+
const origin = parsed.origin;
|
|
123
|
+
const appName = parsed.appName;
|
|
124
|
+
const createdAt = parsed.createdAt;
|
|
125
|
+
if (typeof challenge !== 'string' || !/^[0-9a-f]{64}$/i.test(challenge))
|
|
126
|
+
return null;
|
|
127
|
+
if (typeof origin !== 'string' || origin.length === 0)
|
|
128
|
+
return null;
|
|
129
|
+
if (typeof appName !== 'string' || appName.length === 0)
|
|
130
|
+
return null;
|
|
131
|
+
if (typeof createdAt !== 'number' || !Number.isFinite(createdAt))
|
|
132
|
+
return null;
|
|
133
|
+
return { challenge, origin, appName, createdAt };
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Clear the pending-redirect record. Safe to call when none exists. */
|
|
140
|
+
export function clearPendingRedirect() {
|
|
141
|
+
safeRemove(STORAGE_KEYS.pendingRedirect);
|
|
142
|
+
}
|
|
143
|
+
// ── Hex helpers (avoid pulling in @noble for two functions) ───────────────────
|
|
144
|
+
export function bytesToHexLocal(bytes) {
|
|
145
|
+
let out = '';
|
|
146
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
147
|
+
out += bytes[i].toString(16).padStart(2, '0');
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
export function hexToBytesLocal(hex) {
|
|
152
|
+
if (hex.length % 2 !== 0)
|
|
153
|
+
throw new Error('odd-hex-length');
|
|
154
|
+
const out = new Uint8Array(hex.length / 2);
|
|
155
|
+
for (let i = 0; i < out.length; i++) {
|
|
156
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for signet-login.
|
|
3
|
+
*
|
|
4
|
+
* The SDK exposes a single SignetSigner interface that wraps three backends
|
|
5
|
+
* (NIP-07 extension, NIP-46 bunker, ephemeral redirect-only). Consumers code
|
|
6
|
+
* against the interface; the SDK picks the implementation based on user choice.
|
|
7
|
+
*/
|
|
8
|
+
/** A signed Nostr event. */
|
|
9
|
+
export interface NostrEvent {
|
|
10
|
+
id: string;
|
|
11
|
+
pubkey: string;
|
|
12
|
+
kind: number;
|
|
13
|
+
created_at: number;
|
|
14
|
+
tags: string[][];
|
|
15
|
+
content: string;
|
|
16
|
+
sig: string;
|
|
17
|
+
}
|
|
18
|
+
/** An unsigned event template ready for signing. */
|
|
19
|
+
export interface EventTemplate {
|
|
20
|
+
kind: number;
|
|
21
|
+
created_at?: number;
|
|
22
|
+
tags?: string[][];
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
/** Login method actually used to authenticate. */
|
|
26
|
+
export type LoginMethod = 'nip07' | 'redirect' | 'bunker';
|
|
27
|
+
/** Capability flags exposed by a signer. */
|
|
28
|
+
export interface SignerCapabilities {
|
|
29
|
+
/** True if the signer can sign arbitrary events going forward. False for redirect-auth-only sessions. */
|
|
30
|
+
canSignEvents: boolean;
|
|
31
|
+
/** True if NIP-44 encrypt/decrypt is available. */
|
|
32
|
+
hasNip44: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** Unified signer interface — three backends, one shape. */
|
|
35
|
+
export interface SignetSigner {
|
|
36
|
+
readonly pubkey: string;
|
|
37
|
+
readonly method: LoginMethod;
|
|
38
|
+
readonly capabilities: SignerCapabilities;
|
|
39
|
+
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
|
40
|
+
nip44?: {
|
|
41
|
+
encrypt(peerPubkey: string, plaintext: string): Promise<string>;
|
|
42
|
+
decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
|
|
43
|
+
};
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
/** A signed kind-21236 auth event proving pubkey ownership. */
|
|
47
|
+
export interface SignetAuthEvent extends NostrEvent {
|
|
48
|
+
kind: 21236;
|
|
49
|
+
}
|
|
50
|
+
/** An authenticated session — pubkey + signer + the signed challenge proof. */
|
|
51
|
+
export interface SignetSession {
|
|
52
|
+
pubkey: string;
|
|
53
|
+
method: LoginMethod;
|
|
54
|
+
signer: SignetSigner;
|
|
55
|
+
/** The signed challenge event — proves identity, useful for server-side verification. */
|
|
56
|
+
authEvent: SignetAuthEvent;
|
|
57
|
+
/** Unix-ms expiry, if the session can expire (bunker tokens). Absent = session does not expire. */
|
|
58
|
+
expiresAt?: number;
|
|
59
|
+
/** Optional display name the user shared at approval (sanitised). */
|
|
60
|
+
displayName?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Delivery mode for the "Sign in with Signet" method.
|
|
64
|
+
*
|
|
65
|
+
* - 'relay' (default): the modal shows a QR / link, signet-app gift-wraps the
|
|
66
|
+
* signed auth event back via a Nostr relay. The current tab stays put. Best
|
|
67
|
+
* for desktop where users have a phone alongside.
|
|
68
|
+
*
|
|
69
|
+
* - 'redirect': the current tab navigates to signet-app, the user signs in
|
|
70
|
+
* there, signet-app redirects the same tab back to `redirectCallback` with
|
|
71
|
+
* auth params in the query string. The consumer must call
|
|
72
|
+
* `Signet.handleCallback()` on boot to consume the params and resolve a
|
|
73
|
+
* session. Best for mobile / single-device flows.
|
|
74
|
+
*
|
|
75
|
+
* Only affects the 'redirect' login method. NIP-07 and bunker are unchanged.
|
|
76
|
+
*/
|
|
77
|
+
export type SignetDeliveryMode = 'relay' | 'redirect';
|
|
78
|
+
/** Options for Signet.login(). */
|
|
79
|
+
export interface LoginOptions {
|
|
80
|
+
/** Required. Shown in the consent UI (e.g. "Asteroid Sats"). */
|
|
81
|
+
appName: string;
|
|
82
|
+
/** Optional 64-hex challenge. Auto-generated if omitted. */
|
|
83
|
+
challenge?: string;
|
|
84
|
+
/** Skip the picker and force a specific method. */
|
|
85
|
+
preferredMethod?: LoginMethod;
|
|
86
|
+
/** Relay URL for cross-device communication. Default: wss://relay.damus.io */
|
|
87
|
+
relayUrl?: string;
|
|
88
|
+
/** Modal colour scheme. Default: 'auto'. */
|
|
89
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
90
|
+
/** Timeout in milliseconds. Default: 120_000. Clamped to [5_000, 600_000]. */
|
|
91
|
+
timeout?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Origin of the Signet app. Default: https://mysignet.app
|
|
94
|
+
* Override for local development against your own signet-app instance.
|
|
95
|
+
*/
|
|
96
|
+
signetAppOrigin?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Callback URL used by the same-device redirect path. Must be same-origin
|
|
99
|
+
* as the calling page. Defaults to `${origin}/`. Only used when `mode` is
|
|
100
|
+
* 'redirect'.
|
|
101
|
+
*/
|
|
102
|
+
redirectCallback?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Delivery mode for the Sign in with Signet method. See `SignetDeliveryMode`.
|
|
105
|
+
* Default: 'relay'.
|
|
106
|
+
*
|
|
107
|
+
* In 'redirect' mode `Signet.login()` navigates the current tab away and
|
|
108
|
+
* never resolves in this tab — the returned promise is abandoned. Wire up
|
|
109
|
+
* `Signet.handleCallback()` on boot to receive the session on return.
|
|
110
|
+
*/
|
|
111
|
+
mode?: SignetDeliveryMode;
|
|
112
|
+
/** Persist the session to localStorage. Default: true. */
|
|
113
|
+
persist?: boolean;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* State persisted to localStorage between starting a redirect and consuming
|
|
117
|
+
* the callback. Used by `consumeCallback()` to validate the round-trip and
|
|
118
|
+
* reconstruct the kind-21236 auth event.
|
|
119
|
+
*/
|
|
120
|
+
export interface PendingRedirect {
|
|
121
|
+
/** 64-hex challenge issued at login start — must match the auth event tag. */
|
|
122
|
+
challenge: string;
|
|
123
|
+
/** Origin that initiated the login — must match `window.location.origin` on return. */
|
|
124
|
+
origin: string;
|
|
125
|
+
/** App name — used to reconstruct the `app` tag on the auth event. */
|
|
126
|
+
appName: string;
|
|
127
|
+
/** Unix-ms when the redirect started. Used for the freshness window. */
|
|
128
|
+
createdAt: number;
|
|
129
|
+
}
|
|
130
|
+
/** Options for Signet.restoreSession(). */
|
|
131
|
+
export interface RestoreOptions {
|
|
132
|
+
/** Reconnect a stored bunker session if present. Default: true. */
|
|
133
|
+
reconnectBunker?: boolean;
|
|
134
|
+
/** Default relay for bunker reconnection if URI omits it. */
|
|
135
|
+
defaultRelay?: string;
|
|
136
|
+
}
|
|
137
|
+
/** Default values applied when the consumer omits an option. */
|
|
138
|
+
export declare const DEFAULTS: {
|
|
139
|
+
relayUrl: string;
|
|
140
|
+
signetAppOrigin: string;
|
|
141
|
+
timeout: number;
|
|
142
|
+
theme: "auto";
|
|
143
|
+
persist: boolean;
|
|
144
|
+
mode: SignetDeliveryMode;
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Pending redirect must be consumed within this window of starting it,
|
|
148
|
+
* otherwise the callback is treated as stale (likely a stray bookmark or
|
|
149
|
+
* tab restored after a long pause). Mirrors signet-app's URL freshness
|
|
150
|
+
* window (5 min) so callback consumers behave consistently with the issuer.
|
|
151
|
+
*/
|
|
152
|
+
export declare const PENDING_REDIRECT_TTL_MS: number;
|
|
153
|
+
/** Storage keys, namespaced under signet:login.* */
|
|
154
|
+
export declare const STORAGE_KEYS: {
|
|
155
|
+
pubkey: string;
|
|
156
|
+
method: string;
|
|
157
|
+
authEvent: string;
|
|
158
|
+
bunkerUri: string;
|
|
159
|
+
bunkerClientSk: string;
|
|
160
|
+
expiresAt: string;
|
|
161
|
+
displayName: string;
|
|
162
|
+
/** Session-storage key for in-flight redirect state. */
|
|
163
|
+
pendingRedirect: string;
|
|
164
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for signet-login.
|
|
3
|
+
*
|
|
4
|
+
* The SDK exposes a single SignetSigner interface that wraps three backends
|
|
5
|
+
* (NIP-07 extension, NIP-46 bunker, ephemeral redirect-only). Consumers code
|
|
6
|
+
* against the interface; the SDK picks the implementation based on user choice.
|
|
7
|
+
*/
|
|
8
|
+
/** Default values applied when the consumer omits an option. */
|
|
9
|
+
export const DEFAULTS = {
|
|
10
|
+
relayUrl: 'wss://relay.damus.io',
|
|
11
|
+
signetAppOrigin: 'https://mysignet.app',
|
|
12
|
+
timeout: 120000,
|
|
13
|
+
theme: 'auto',
|
|
14
|
+
persist: true,
|
|
15
|
+
mode: 'relay',
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Pending redirect must be consumed within this window of starting it,
|
|
19
|
+
* otherwise the callback is treated as stale (likely a stray bookmark or
|
|
20
|
+
* tab restored after a long pause). Mirrors signet-app's URL freshness
|
|
21
|
+
* window (5 min) so callback consumers behave consistently with the issuer.
|
|
22
|
+
*/
|
|
23
|
+
export const PENDING_REDIRECT_TTL_MS = 5 * 60 * 1000;
|
|
24
|
+
/** Storage keys, namespaced under signet:login.* */
|
|
25
|
+
export const STORAGE_KEYS = {
|
|
26
|
+
pubkey: 'signet:login.pubkey',
|
|
27
|
+
method: 'signet:login.method',
|
|
28
|
+
authEvent: 'signet:login.authEvent',
|
|
29
|
+
bunkerUri: 'signet:login.bunkerUri',
|
|
30
|
+
bunkerClientSk: 'signet:login.bunkerClientSk',
|
|
31
|
+
expiresAt: 'signet:login.expiresAt',
|
|
32
|
+
displayName: 'signet:login.displayName',
|
|
33
|
+
/** Session-storage key for in-flight redirect state. */
|
|
34
|
+
pendingRedirect: 'signet:login.pendingRedirect',
|
|
35
|
+
};
|