From eef17eb79b728458fddc0c58c04cfc8d3621c35c Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 8 Oct 2026 13:07:56 -0400 Subject: [PATCH] fix(nostr): bind cached app sessions to selected identity Reuse only unexpired JWTs for the verified selected native public key. Invalidate stale session credentials and in-flight responses on account switching or logout, notify app state before authentication, and accept identity bootstrap only from the expected signer origin. Preserve saved profiles and private keys. Validation: 23 focused provider tests passed, including cached-key mismatch, expiry, late response, logout and repeated-selection regressions. --- neode-ui/public/nostr-provider.js | 121 ++++++--- .../__tests__/nostrProviderIdentity.test.ts | 229 +++++++++++++++--- 2 files changed, 293 insertions(+), 57 deletions(-) diff --git a/neode-ui/public/nostr-provider.js b/neode-ui/public/nostr-provider.js index fe2c9972..4e23f894 100644 --- a/neode-ui/public/nostr-provider.js +++ b/neode-ui/public/nostr-provider.js @@ -144,6 +144,7 @@ // call. Keeping this as an optional companion API preserves NIP-07 compatibility // while allowing users to change their node identity when they log in again. function selectIdentity() { + autoAuthSuspended = false; if (identitySelection) { identitySelection.reject(new Error('A node identity choice is already open')); clearTimeout(identitySelection.timer); @@ -164,21 +165,86 @@ }); } + // JWT claims are a reuse hint only; the API still verifies the signature. + // Never let an unrelated or expired app session override the chosen node key. + function tokenMatchesIdentity(token, pubkey) { + try { + if (typeof token !== 'string' || !pubkey) return false; + var parts = token.split('.'); + if (parts.length !== 3 || !parts.every(function (part) { return /^[A-Za-z0-9_-]+$/.test(part); })) return false; + var encoded = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + while (encoded.length % 4) encoded += '='; + var claims = JSON.parse(atob(encoded)); + return claims.sub === pubkey && typeof claims.exp === 'number' && + Number.isFinite(claims.exp) && claims.exp > Date.now() / 1000; + } catch (_) { return false; } + } + + var authGeneration = 0, authAttempt = null, autoAuthTimer = null, autoAuthSuspended = false; + function clearSession() { + authGeneration++; + authAttempt = null; + clearTimeout(autoAuthTimer); + autoAuthSuspended = true; + var previous = selectedIdentity && selectedIdentity.nostr_pubkey; + selectedIdentity = null; selectedPublicKey = null; + clearTimeout(selectedPublicKeyTimer); + window.dispatchEvent(new CustomEvent('archipelago:identity-changing', { detail: { nostr_pubkey: null, previous_nostr_pubkey: previous } })); + // These are app session credentials, not stored accounts, profiles or keys. + try { + ['nostr_token', 'nostr_pubkey', 'refresh_token'].forEach(function (key) { sessionStorage.removeItem(key); }); + } catch (_) {} + } + + function scheduleIdentityAuth(pubkey) { + if (!autoNip98 || autoAuthSuspended) return; + try { if (tokenMatchesIdentity(sessionStorage.getItem('nostr_token'), pubkey)) return; } catch (_) {} + clearTimeout(autoAuthTimer); + var generation = authGeneration; + autoAuthTimer = setTimeout(function () { + if (generation === authGeneration && selectedIdentity && selectedIdentity.nostr_pubkey === pubkey) doNip98Auth(pubkey, generation); + }, 1500); + } + function finishIdentitySelection(identity) { // The identity picker is itself an explicit choice to disclose this key. // Keep it briefly so the login library's immediately-following // getPublicKey() does not depend on another cross-origin WebView round trip. // This is deliberately one-shot and short-lived. - if (identity && typeof identity.nostr_pubkey === 'string' && identity.nostr_pubkey) { + if (identity && typeof identity.nostr_pubkey === 'string' && /^[0-9a-f]{64}$/.test(identity.nostr_pubkey)) { + var previous = selectedIdentity && selectedIdentity.nostr_pubkey; + var changed = previous !== identity.nostr_pubkey; + var storedToken = null; + try { storedToken = sessionStorage.getItem('nostr_token'); } catch (_) {} + var invalidSession = !!storedToken && !tokenMatchesIdentity(storedToken, identity.nostr_pubkey); selectedIdentity = { nostr_pubkey: identity.nostr_pubkey }; + var displayName = identity.display_name || identity.name; + if (typeof displayName === 'string' && displayName.trim()) selectedIdentity.display_name = displayName.trim().slice(0, 160); + if (changed || (invalidSession && !authAttempt)) { + authGeneration++; + authAttempt = null; + clearTimeout(autoAuthTimer); + window.dispatchEvent(new CustomEvent('archipelago:identity-changing', { detail: { + nostr_pubkey: identity.nostr_pubkey, previous_nostr_pubkey: previous, + } })); + } + try { + if (!tokenMatchesIdentity(sessionStorage.getItem('nostr_token'), identity.nostr_pubkey)) { + ['nostr_token', 'nostr_pubkey', 'refresh_token'].forEach(function (key) { sessionStorage.removeItem(key); }); + } else { + if (sessionStorage.getItem('nostr_pubkey') !== identity.nostr_pubkey) sessionStorage.removeItem('refresh_token'); + sessionStorage.setItem('nostr_pubkey', identity.nostr_pubkey); + } + } catch (_) {} selectedPublicKey = identity.nostr_pubkey; clearTimeout(selectedPublicKeyTimer); selectedPublicKeyTimer = setTimeout(function () { selectedPublicKey = null; selectedPublicKeyTimer = null; }, 15000); + scheduleIdentityAuth(identity.nostr_pubkey); identitySubscribers.slice().forEach(function (subscriber) { - try { subscriber(selectedIdentity); } catch (error) { + try { subscriber(getSelectedIdentity()); } catch (error) { console.error('[nostr-provider] identity listener failed:', error); } }); @@ -187,7 +253,8 @@ var selection = identitySelection; identitySelection = null; clearTimeout(selection.timer); - selection.resolve(identity); + if (!identity || !/^[0-9a-f]{64}$/.test(identity.nostr_pubkey)) selection.reject(new Error('Invalid native public key')); + else selection.resolve(getSelectedIdentity()); } function cancelIdentitySelection() { @@ -216,7 +283,7 @@ var restoringSession = false; try { var sessionHint = sessionStorage.getItem('nostr_token'); - restoringSession = !!sessionHint && sessionHint.indexOf('mock-') !== 0; + restoringSession = !!selectedIdentity && tokenMatchesIdentity(sessionHint, selectedIdentity.nostr_pubkey); } catch (_) {} // This is only a UI hint: the broker still verifies the node session and // signing permissions. A restored app token never authorizes a signature. @@ -237,7 +304,7 @@ } identitySubscribers.push(subscriber); if (selectedIdentity) { - try { subscriber(selectedIdentity); } catch (error) { + try { subscriber(getSelectedIdentity()); } catch (error) { console.error('[nostr-provider] identity listener failed:', error); } } @@ -249,12 +316,12 @@ } function getSelectedIdentity() { - return selectedIdentity && { nostr_pubkey: selectedIdentity.nostr_pubkey }; + return selectedIdentity && Object.assign({}, selectedIdentity); } window.addEventListener('message', function (e) { var validSource = embedded - ? e.source === window.parent + ? e.source === window.parent && e.origin === dashboardOrigin() : signerFrame && e.source === signerFrame.contentWindow && e.origin === dashboardOrigin(); if (!validSource || !e.data) return; @@ -275,7 +342,8 @@ finishIdentitySelection(e.data.identity); window.postMessage({ type: 'archipelago:identity', - nostr_pubkey: e.data.identity && e.data.identity.nostr_pubkey, + nostr_pubkey: selectedIdentity && selectedIdentity.nostr_pubkey, + display_name: selectedIdentity && selectedIdentity.display_name, }, window.location.origin); return; } @@ -391,16 +459,21 @@ selectIdentity: selectIdentity, onIdentitySelected: onIdentitySelected, getSelectedIdentity: getSelectedIdentity, + clearSession: clearSession, }; // Optional direct NIP-98 session bootstrap for apps that use it. Signing // itself is shown by the shared broker, so this deliberately adds no second // full-screen loader inside the app. - var authDone = false; - - function doNip98Auth(pubkey) { - if (authDone) return; - authDone = true; + function doNip98Auth(pubkey, generation) { + if (authAttempt || autoAuthSuspended) return; + var attempt = {}; + authAttempt = attempt; + function current() { + return authAttempt === attempt && generation === authGeneration && !autoAuthSuspended && + selectedIdentity && selectedIdentity.nostr_pubkey === pubkey; + } + function requireCurrent() { if (!current()) throw new Error('Identity choice changed'); } var healthUrl = window.location.origin + '/api/nostr-auth/health'; var sessionUrl = window.location.origin + '/api/auth/nostr/session'; var healthController = new AbortController(); @@ -408,6 +481,7 @@ fetch(healthUrl, { signal: healthController.signal }).then(function (response) { clearTimeout(healthTimeout); + requireCurrent(); if (!response.ok) throw new Error('Health ' + response.status); return window.nostr.signEvent({ kind: 27235, @@ -417,6 +491,8 @@ tags: [['u', sessionUrl], ['method', 'POST']], }); }).then(function (signed) { + requireCurrent(); + if (!signed || signed.pubkey !== pubkey) throw new Error('Signer identity differs from selected identity'); var controller = new AbortController(); setTimeout(function () { controller.abort(); }, 10000); return fetch(sessionUrl, { @@ -428,10 +504,12 @@ if (!response.ok) throw new Error('Auth failed: ' + response.status); return response.json(); }).then(function (data) { - if (!data.accessToken) throw new Error('Authentication returned no access token'); + requireCurrent(); + if (!tokenMatchesIdentity(data.accessToken, pubkey)) throw new Error('Authentication returned an expired or differently bound session'); sessionStorage.setItem('nostr_token', data.accessToken); sessionStorage.setItem('nostr_pubkey', pubkey); if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken); + else sessionStorage.removeItem('refresh_token'); return waitForSignerToHide().then(function () { // Give WebView one paint after the iframe is hidden before replacing // the document. The stored session is already durable at this point. @@ -441,6 +519,7 @@ }); }); }).then(function () { + requireCurrent(); if (window.ArchipelagoSurface && typeof window.ArchipelagoSurface.expectPageTransition === 'function') { window.ArchipelagoSurface.expectPageTransition(); @@ -448,25 +527,13 @@ window.location.reload(); }); }).catch(function (error) { - authDone = false; + if (authAttempt === attempt) authAttempt = null; var message = error && error.message ? error.message : String(error); if (message.toLowerCase().indexOf('abort') > -1) message = 'API timeout'; console.warn('[nostr-provider] NIP-98 skipped:', message); }); } - window.addEventListener('message', function (e) { - if (!e.data || e.data.type !== 'archipelago:identity' || !autoNip98) return; - if (e.source !== window && e.source !== window.parent) return; - var pubkey = e.data.nostr_pubkey; - if (!pubkey) return; - try { - var token = sessionStorage.getItem('nostr_token'); - if (token && token.indexOf('mock-') === -1) return; - } catch (_) {} - setTimeout(function () { doNip98Auth(pubkey); }, 1500); - }); - // Only identity-aware apps open the chooser eagerly. The provider is also // injected into several ordinary app proxies; those stay untouched unless // they actually invoke a NIP-07 method, which lazily creates the broker. diff --git a/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts b/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts index 81a239e1..813ecebf 100644 --- a/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts +++ b/neode-ui/src/views/appSession/__tests__/nostrProviderIdentity.test.ts @@ -14,6 +14,7 @@ type ProviderWindow = Window & { } nostr?: { getPublicKey: () => Promise } archipelagoNostr?: { + clearSession: () => void selectIdentity: () => Promise getSelectedIdentity: () => { nostr_pubkey: string } | null onIdentitySelected: ( @@ -57,6 +58,18 @@ describe('nostr-provider identity selection', () => { delete providerWindow.ArchipelagoSurface }) + function token(pubkey: string, exp = Math.floor(Date.now() / 1000) + 3600) { + return `e30.${btoa(JSON.stringify({ sub: pubkey, exp })).replace(/=+$/, '')}.signature` + } + + function choose(frame: HTMLIFrameElement, origin: string, pubkey: string, display_name?: string) { + const event = new MessageEvent('message', { + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: pubkey, display_name } }, origin, + }) + Object.defineProperty(event, 'source', { value: frame.contentWindow }) + window.dispatchEvent(event) + } + function loadProvider(userActivated: boolean) { Object.defineProperty(navigator, 'userActivation', { configurable: true, @@ -89,14 +102,14 @@ describe('nostr-provider identity selection', () => { ) const selected = new MessageEvent('message', { - data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'abc123' } }, + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090' } }, origin: signerOrigin, }) Object.defineProperty(selected, 'source', { value: frame.contentWindow }) window.dispatchEvent(selected) await Promise.resolve() - await expect(publicKey).resolves.toBe('abc123') + await expect(publicKey).resolves.toBe('6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090') expect(postMessage).not.toHaveBeenCalledWith( expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }), signerOrigin, @@ -120,14 +133,14 @@ describe('nostr-provider identity selection', () => { it('keeps an eager picker choice for the app login that follows', async () => { const { frame, postMessage, signerOrigin } = loadProvider(false) const selected = new MessageEvent('message', { - data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'fast-choice' } }, + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'd930382168af7367013ad9723e7ebc093918f788abcb32ce93db245031edff0c' } }, origin: signerOrigin, }) Object.defineProperty(selected, 'source', { value: frame.contentWindow }) window.dispatchEvent(selected) postMessage.mockClear() - await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('fast-choice') + await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('d930382168af7367013ad9723e7ebc093918f788abcb32ce93db245031edff0c') expect(postMessage).not.toHaveBeenCalledWith( expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }), signerOrigin, @@ -137,12 +150,12 @@ describe('nostr-provider identity selection', () => { it('does not reopen after a selected identity when activation survives account restoration', async () => { const { frame, postMessage, signerOrigin } = loadProvider(true) const selected = new MessageEvent('message', { - data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'selected-key' } }, + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e' } }, origin: signerOrigin, }) Object.defineProperty(selected, 'source', { value: frame.contentWindow }) window.dispatchEvent(selected) - await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('selected-key') + await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e') postMessage.mockClear() const next = providerWindow.nostr!.getPublicKey() expect(postMessage).not.toHaveBeenCalledWith( @@ -150,11 +163,11 @@ describe('nostr-provider identity selection', () => { ) const request = postMessage.mock.calls[0]![0] as { id: number } const response = new MessageEvent('message', { - data: { type: 'nostr-response', id: request.id, result: 'selected-key' }, origin: signerOrigin, + data: { type: 'nostr-response', id: request.id, result: '4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e' }, origin: signerOrigin, }) Object.defineProperty(response, 'source', { value: frame.contentWindow }) window.dispatchEvent(response) - await expect(next).resolves.toBe('selected-key') + await expect(next).resolves.toBe('4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e') // Explicit account switching remains available after a successful login. void providerWindow.archipelagoNostr!.selectIdentity() expect(postMessage).toHaveBeenCalledWith( @@ -162,15 +175,12 @@ describe('nostr-provider identity selection', () => { ) }) - it('does not mistake reload activation for a new login while restoring a session', () => { + it('does not let an unbound token suppress an explicit login choice', () => { sessionStorage.setItem('nostr_token', 'test-session-hint') const { postMessage, signerOrigin } = loadProvider(true) void providerWindow.nostr!.getPublicKey() expect(postMessage).toHaveBeenCalledWith( - expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }), signerOrigin, - ) - expect(postMessage).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'archipelago:signer-select-identity' }), expect.anything(), + expect.objectContaining({ type: 'archipelago:signer-select-identity' }), signerOrigin, ) }) @@ -219,18 +229,18 @@ describe('nostr-provider identity selection', () => { .find(message => message.type === 'nostr-request')! expect(request).toBeDefined() const response = new MessageEvent('message', { - data: { type: 'nostr-response', id: request.id, result: 'recreated-key' }, + data: { type: 'nostr-response', id: request.id, result: 'f25efa08a1e6551d15d41622efedc40faceacc4f87ec766221613c14aa07cffb' }, origin: signerOrigin, }) Object.defineProperty(response, 'source', { value: replacement.contentWindow }) window.dispatchEvent(response) - await expect(publicKey).resolves.toBe('recreated-key') + await expect(publicKey).resolves.toBe('f25efa08a1e6551d15d41622efedc40faceacc4f87ec766221613c14aa07cffb') }) it('delivers an eager identity to an app listener that mounts afterward', () => { const { frame, signerOrigin } = loadProvider(false) const selected = new MessageEvent('message', { - data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'late-listener' } }, + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' } }, origin: signerOrigin, }) Object.defineProperty(selected, 'source', { value: frame.contentWindow }) @@ -240,13 +250,13 @@ describe('nostr-provider identity selection', () => { const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener) expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'late-listener' }) + expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' }) expect(providerWindow.archipelagoNostr!.getSelectedIdentity()) - .toEqual({ nostr_pubkey: 'late-listener' }) + .toEqual({ nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' }) unsubscribe() const changed = new MessageEvent('message', { - data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'after-unsubscribe' } }, + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '7dbe01f62123a375cdfd5c92c78408db35005175e99fed1aa05d74b91df13f36' } }, origin: signerOrigin, }) Object.defineProperty(changed, 'source', { value: frame.contentWindow }) @@ -258,13 +268,13 @@ describe('nostr-provider identity selection', () => { vi.useFakeTimers() const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) - const { postMessage, signerOrigin } = loadProvider(false) + const { frame, postMessage, signerOrigin } = loadProvider(false) const identity = new MessageEvent('message', { - data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' }, - origin: window.location.origin, + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } }, + origin: signerOrigin, }) - Object.defineProperty(identity, 'source', { value: window }) + Object.defineProperty(identity, 'source', { value: frame.contentWindow }) window.dispatchEvent(identity) await vi.advanceTimersByTimeAsync(1500) @@ -276,7 +286,7 @@ describe('nostr-provider identity selection', () => { expect.objectContaining({ type: 'nostr-request', method: 'signEvent', - params: { event: expect.objectContaining({ kind: 27235, pubkey: 'indeedhub-key' }) }, + params: { event: expect.objectContaining({ kind: 27235, pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' }) }, }), signerOrigin, ) @@ -288,17 +298,17 @@ describe('nostr-provider identity selection', () => { .mockResolvedValueOnce({ ok: true }) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ accessToken: 'real-token', refreshToken: 'refresh' }), + json: () => Promise.resolve({ accessToken: token('0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047'), refreshToken: 'refresh' }), }) vi.stubGlobal('fetch', fetchMock) const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1) const { frame, postMessage, signerOrigin } = loadProvider(false) const identity = new MessageEvent('message', { - data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' }, - origin: window.location.origin, + data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } }, + origin: signerOrigin, }) - Object.defineProperty(identity, 'source', { value: window }) + Object.defineProperty(identity, 'source', { value: frame.contentWindow }) window.dispatchEvent(identity) await vi.advanceTimersByTimeAsync(1500) @@ -313,14 +323,14 @@ describe('nostr-provider identity selection', () => { window.dispatchEvent(show) const signed = new MessageEvent('message', { - data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event' } }, + data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event', pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } }, origin: signerOrigin, }) Object.defineProperty(signed, 'source', { value: frame.contentWindow }) window.dispatchEvent(signed) await vi.advanceTimersByTimeAsync(0) - expect(sessionStorage.getItem('nostr_token')).toBe('real-token') + expect(sessionStorage.getItem('nostr_token')).toBe(token('0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047')) expect(raf).not.toHaveBeenCalled() const hide = new MessageEvent('message', { @@ -363,4 +373,163 @@ describe('nostr-provider identity selection', () => { expect(postMessage.mock.calls.map(call => (call[0] as { type: string }).type)) .toEqual(['archipelago:signer-init', 'nostr-request']) }) + it.each(['expired', 'wrong-key', 'malformed'])('replaces a %s token without deleting stored accounts', async kind => { + const original = kind === 'expired' ? token('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', 1) : kind === 'wrong-key' ? token('d9298a10d1b0735837dc4bd85dac641b0f3cef27a47e5d53a54f2f3f5b2fcffa') : 'invalid' + sessionStorage.setItem('nostr_token', original) + sessionStorage.setItem('refresh_token', 'old-refresh') + localStorage.setItem('saved-account-fixture', 'private-key-and-profile-fixture') + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + const { frame, signerOrigin, postMessage } = loadProvider(false) + choose(frame, signerOrigin, '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4') + expect(sessionStorage.getItem('nostr_token')).toBeNull() + expect(sessionStorage.getItem('refresh_token')).toBeNull() + expect(localStorage.getItem('saved-account-fixture')).toBe('private-key-and-profile-fixture') + await vi.advanceTimersByTimeAsync(1500) + expect(fetchMock).toHaveBeenCalledOnce() + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ method: 'signEvent' }), signerOrigin) + localStorage.removeItem('saved-account-fixture') + }) + + it('reuses only an unexpired token matching the verified selected identity', async () => { + const saved = token('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4') + sessionStorage.setItem('nostr_token', saved) + sessionStorage.setItem('nostr_pubkey', 'stale-key') + sessionStorage.setItem('refresh_token', 'stale-refresh') + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const { frame, signerOrigin } = loadProvider(false) + choose(frame, signerOrigin, '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', 'My native profile') + await vi.advanceTimersByTimeAsync(1600) + expect(fetchMock).not.toHaveBeenCalled() + expect(sessionStorage.getItem('nostr_token')).toBe(saved) + expect(sessionStorage.getItem('nostr_pubkey')).toBe('7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4') + expect(sessionStorage.getItem('refresh_token')).toBeNull() + expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toEqual({ nostr_pubkey: '7be403e3cc82262248913b70f596a31b93f656e0da8ce5d1bf757c63ef7a8db4', display_name: 'My native profile' }) + }) + + it('ignores self-forged identity messages and signer messages from another origin', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const { frame } = loadProvider(false) + choose(frame, 'https://untrusted.example', 'ccdd35168ab474fa5764a526cfb83621351e23682c5075b2e18d56bddf96aa30') + const event = new MessageEvent('message', { data: { type: 'archipelago:identity', nostr_pubkey: 'ccdd35168ab474fa5764a526cfb83621351e23682c5075b2e18d56bddf96aa30' }, origin: location.origin }) + Object.defineProperty(event, 'source', { value: window }) + window.dispatchEvent(event) + await vi.advanceTimersByTimeAsync(1600) + expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each(['switch', 'logout'])('does not store an in-flight old session after %s', async action => { + let complete!: (response: unknown) => void + const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockImplementationOnce(() => new Promise(resolve => { complete = resolve })) + vi.stubGlobal('fetch', fetchMock) + const { frame, signerOrigin, postMessage } = loadProvider(false) + choose(frame, signerOrigin, 'cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4') + await vi.advanceTimersByTimeAsync(1500) + const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent') + const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: 'cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4' } }, origin: signerOrigin }) + Object.defineProperty(signed, 'source', { value: frame.contentWindow }) + window.dispatchEvent(signed) + await vi.advanceTimersByTimeAsync(0) + if (action === 'switch') choose(frame, signerOrigin, '11507a0e2f5e69d5dfa40a62a1bd7b6ee57e6bcd85c67c9b8431b36fff21c437') + else providerWindow.archipelagoNostr!.clearSession() + complete({ ok: true, json: async () => ({ accessToken: token('cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4'), refreshToken: 'old-refresh' }) }) + await vi.advanceTimersByTimeAsync(0) + expect(sessionStorage.getItem('nostr_token')).toBeNull() + expect(sessionStorage.getItem('refresh_token')).toBeNull() + }) + + it('announces a verified switch before clearing the old session and exposes only public metadata', () => { + const oldKey = 'a'.repeat(64), nextKey = 'b'.repeat(64) + sessionStorage.setItem('nostr_token', token(oldKey)) + const { frame, signerOrigin } = loadProvider(false) + const changes: unknown[] = [] + window.addEventListener('archipelago:identity-changing', event => { + changes.push({ detail: (event as CustomEvent).detail, token: sessionStorage.getItem('nostr_token'), selected: providerWindow.archipelagoNostr!.getSelectedIdentity() }) + }) + choose(frame, signerOrigin, nextKey, 'Chosen profile') + expect(changes).toEqual([{ detail: { nostr_pubkey: nextKey, previous_nostr_pubkey: null }, token: token(oldKey), selected: { nostr_pubkey: nextKey, display_name: 'Chosen profile' } }]) + expect(sessionStorage.getItem('nostr_token')).toBeNull() + }) + + it('rejects a malformed native identity without exposing it or starting authentication', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const { frame, signerOrigin } = loadProvider(false) + choose(frame, signerOrigin, 'not-a-native-public-key') + await vi.advanceTimersByTimeAsync(1600) + expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('clears a now-expired token on repeated selection of the same identity', () => { + const key = 'a'.repeat(64) + const { frame, signerOrigin } = loadProvider(false) + choose(frame, signerOrigin, key) + sessionStorage.setItem('nostr_token', token(key, 1)) + const notify = vi.fn() + window.addEventListener('archipelago:identity-changing', notify) + choose(frame, signerOrigin, key) + expect(notify).toHaveBeenCalledOnce() + expect(sessionStorage.getItem('nostr_token')).toBeNull() + }) + + it('keeps logout quiet and permits a subsequent explicit identity choice', async () => { + const key = 'a'.repeat(64) + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + const { frame, signerOrigin, postMessage } = loadProvider(false) + choose(frame, signerOrigin, key) + providerWindow.archipelagoNostr!.clearSession() + await vi.advanceTimersByTimeAsync(1600) + expect(fetchMock).not.toHaveBeenCalled() + expect(providerWindow.archipelagoNostr!.getSelectedIdentity()).toBeNull() + const selection = providerWindow.archipelagoNostr!.selectIdentity() + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }), signerOrigin) + choose(frame, signerOrigin, key) + await expect(selection).resolves.toEqual({ nostr_pubkey: key }) + await vi.advanceTimersByTimeAsync(1500) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('rejects a session response bound to another key after valid signing', async () => { + const key = 'a'.repeat(64) + const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true, json: async () => ({ accessToken: token('b'.repeat(64)) }) }) + vi.stubGlobal('fetch', fetchMock) + const { frame, signerOrigin, postMessage } = loadProvider(false) + choose(frame, signerOrigin, key) + await vi.advanceTimersByTimeAsync(1500) + const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent') + const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: key } }, origin: signerOrigin }) + Object.defineProperty(signed, 'source', { value: frame.contentWindow }) + window.dispatchEvent(signed) + await vi.advanceTimersByTimeAsync(0) + expect(sessionStorage.getItem('nostr_token')).toBeNull() + }) + + it('does not reset an in-flight auth when the same identity is announced again', async () => { + const key = 'a'.repeat(64) + let complete!: (response: unknown) => void + const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true }).mockImplementationOnce(() => new Promise(resolve => { complete = resolve })) + vi.stubGlobal('fetch', fetchMock) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1) + const { frame, signerOrigin, postMessage } = loadProvider(false) + choose(frame, signerOrigin, key) + await vi.advanceTimersByTimeAsync(1500) + const request = postMessage.mock.calls.map(c => c[0]).find(v => v.method === 'signEvent') + const signed = new MessageEvent('message', { data: { type: 'nostr-response', id: request.id, result: { pubkey: key } }, origin: signerOrigin }) + Object.defineProperty(signed, 'source', { value: frame.contentWindow }) + window.dispatchEvent(signed) + await vi.advanceTimersByTimeAsync(0) + const notify = vi.fn() + window.addEventListener('archipelago:identity-changing', notify) + choose(frame, signerOrigin, key) + expect(notify).not.toHaveBeenCalled() + complete({ ok: true, json: async () => ({ accessToken: token(key) }) }) + await vi.advanceTimersByTimeAsync(0) + expect(sessionStorage.getItem('nostr_token')).toBe(token(key)) + }) + })