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.
This commit is contained in:
@@ -144,6 +144,7 @@
|
|||||||
// call. Keeping this as an optional companion API preserves NIP-07 compatibility
|
// 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.
|
// while allowing users to change their node identity when they log in again.
|
||||||
function selectIdentity() {
|
function selectIdentity() {
|
||||||
|
autoAuthSuspended = false;
|
||||||
if (identitySelection) {
|
if (identitySelection) {
|
||||||
identitySelection.reject(new Error('A node identity choice is already open'));
|
identitySelection.reject(new Error('A node identity choice is already open'));
|
||||||
clearTimeout(identitySelection.timer);
|
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) {
|
function finishIdentitySelection(identity) {
|
||||||
// The identity picker is itself an explicit choice to disclose this key.
|
// The identity picker is itself an explicit choice to disclose this key.
|
||||||
// Keep it briefly so the login library's immediately-following
|
// Keep it briefly so the login library's immediately-following
|
||||||
// getPublicKey() does not depend on another cross-origin WebView round trip.
|
// getPublicKey() does not depend on another cross-origin WebView round trip.
|
||||||
// This is deliberately one-shot and short-lived.
|
// 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 };
|
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;
|
selectedPublicKey = identity.nostr_pubkey;
|
||||||
clearTimeout(selectedPublicKeyTimer);
|
clearTimeout(selectedPublicKeyTimer);
|
||||||
selectedPublicKeyTimer = setTimeout(function () {
|
selectedPublicKeyTimer = setTimeout(function () {
|
||||||
selectedPublicKey = null;
|
selectedPublicKey = null;
|
||||||
selectedPublicKeyTimer = null;
|
selectedPublicKeyTimer = null;
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
scheduleIdentityAuth(identity.nostr_pubkey);
|
||||||
identitySubscribers.slice().forEach(function (subscriber) {
|
identitySubscribers.slice().forEach(function (subscriber) {
|
||||||
try { subscriber(selectedIdentity); } catch (error) {
|
try { subscriber(getSelectedIdentity()); } catch (error) {
|
||||||
console.error('[nostr-provider] identity listener failed:', error);
|
console.error('[nostr-provider] identity listener failed:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -187,7 +253,8 @@
|
|||||||
var selection = identitySelection;
|
var selection = identitySelection;
|
||||||
identitySelection = null;
|
identitySelection = null;
|
||||||
clearTimeout(selection.timer);
|
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() {
|
function cancelIdentitySelection() {
|
||||||
@@ -216,7 +283,7 @@
|
|||||||
var restoringSession = false;
|
var restoringSession = false;
|
||||||
try {
|
try {
|
||||||
var sessionHint = sessionStorage.getItem('nostr_token');
|
var sessionHint = sessionStorage.getItem('nostr_token');
|
||||||
restoringSession = !!sessionHint && sessionHint.indexOf('mock-') !== 0;
|
restoringSession = !!selectedIdentity && tokenMatchesIdentity(sessionHint, selectedIdentity.nostr_pubkey);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
// This is only a UI hint: the broker still verifies the node session and
|
// This is only a UI hint: the broker still verifies the node session and
|
||||||
// signing permissions. A restored app token never authorizes a signature.
|
// signing permissions. A restored app token never authorizes a signature.
|
||||||
@@ -237,7 +304,7 @@
|
|||||||
}
|
}
|
||||||
identitySubscribers.push(subscriber);
|
identitySubscribers.push(subscriber);
|
||||||
if (selectedIdentity) {
|
if (selectedIdentity) {
|
||||||
try { subscriber(selectedIdentity); } catch (error) {
|
try { subscriber(getSelectedIdentity()); } catch (error) {
|
||||||
console.error('[nostr-provider] identity listener failed:', error);
|
console.error('[nostr-provider] identity listener failed:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,12 +316,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedIdentity() {
|
function getSelectedIdentity() {
|
||||||
return selectedIdentity && { nostr_pubkey: selectedIdentity.nostr_pubkey };
|
return selectedIdentity && Object.assign({}, selectedIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('message', function (e) {
|
window.addEventListener('message', function (e) {
|
||||||
var validSource = embedded
|
var validSource = embedded
|
||||||
? e.source === window.parent
|
? e.source === window.parent && e.origin === dashboardOrigin()
|
||||||
: signerFrame && e.source === signerFrame.contentWindow && e.origin === dashboardOrigin();
|
: signerFrame && e.source === signerFrame.contentWindow && e.origin === dashboardOrigin();
|
||||||
if (!validSource || !e.data) return;
|
if (!validSource || !e.data) return;
|
||||||
|
|
||||||
@@ -275,7 +342,8 @@
|
|||||||
finishIdentitySelection(e.data.identity);
|
finishIdentitySelection(e.data.identity);
|
||||||
window.postMessage({
|
window.postMessage({
|
||||||
type: 'archipelago:identity',
|
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);
|
}, window.location.origin);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -391,16 +459,21 @@
|
|||||||
selectIdentity: selectIdentity,
|
selectIdentity: selectIdentity,
|
||||||
onIdentitySelected: onIdentitySelected,
|
onIdentitySelected: onIdentitySelected,
|
||||||
getSelectedIdentity: getSelectedIdentity,
|
getSelectedIdentity: getSelectedIdentity,
|
||||||
|
clearSession: clearSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional direct NIP-98 session bootstrap for apps that use it. Signing
|
// 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
|
// itself is shown by the shared broker, so this deliberately adds no second
|
||||||
// full-screen loader inside the app.
|
// full-screen loader inside the app.
|
||||||
var authDone = false;
|
function doNip98Auth(pubkey, generation) {
|
||||||
|
if (authAttempt || autoAuthSuspended) return;
|
||||||
function doNip98Auth(pubkey) {
|
var attempt = {};
|
||||||
if (authDone) return;
|
authAttempt = attempt;
|
||||||
authDone = true;
|
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 healthUrl = window.location.origin + '/api/nostr-auth/health';
|
||||||
var sessionUrl = window.location.origin + '/api/auth/nostr/session';
|
var sessionUrl = window.location.origin + '/api/auth/nostr/session';
|
||||||
var healthController = new AbortController();
|
var healthController = new AbortController();
|
||||||
@@ -408,6 +481,7 @@
|
|||||||
|
|
||||||
fetch(healthUrl, { signal: healthController.signal }).then(function (response) {
|
fetch(healthUrl, { signal: healthController.signal }).then(function (response) {
|
||||||
clearTimeout(healthTimeout);
|
clearTimeout(healthTimeout);
|
||||||
|
requireCurrent();
|
||||||
if (!response.ok) throw new Error('Health ' + response.status);
|
if (!response.ok) throw new Error('Health ' + response.status);
|
||||||
return window.nostr.signEvent({
|
return window.nostr.signEvent({
|
||||||
kind: 27235,
|
kind: 27235,
|
||||||
@@ -417,6 +491,8 @@
|
|||||||
tags: [['u', sessionUrl], ['method', 'POST']],
|
tags: [['u', sessionUrl], ['method', 'POST']],
|
||||||
});
|
});
|
||||||
}).then(function (signed) {
|
}).then(function (signed) {
|
||||||
|
requireCurrent();
|
||||||
|
if (!signed || signed.pubkey !== pubkey) throw new Error('Signer identity differs from selected identity');
|
||||||
var controller = new AbortController();
|
var controller = new AbortController();
|
||||||
setTimeout(function () { controller.abort(); }, 10000);
|
setTimeout(function () { controller.abort(); }, 10000);
|
||||||
return fetch(sessionUrl, {
|
return fetch(sessionUrl, {
|
||||||
@@ -428,10 +504,12 @@
|
|||||||
if (!response.ok) throw new Error('Auth failed: ' + response.status);
|
if (!response.ok) throw new Error('Auth failed: ' + response.status);
|
||||||
return response.json();
|
return response.json();
|
||||||
}).then(function (data) {
|
}).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_token', data.accessToken);
|
||||||
sessionStorage.setItem('nostr_pubkey', pubkey);
|
sessionStorage.setItem('nostr_pubkey', pubkey);
|
||||||
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
|
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
|
||||||
|
else sessionStorage.removeItem('refresh_token');
|
||||||
return waitForSignerToHide().then(function () {
|
return waitForSignerToHide().then(function () {
|
||||||
// Give WebView one paint after the iframe is hidden before replacing
|
// Give WebView one paint after the iframe is hidden before replacing
|
||||||
// the document. The stored session is already durable at this point.
|
// the document. The stored session is already durable at this point.
|
||||||
@@ -441,6 +519,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
|
requireCurrent();
|
||||||
if (window.ArchipelagoSurface &&
|
if (window.ArchipelagoSurface &&
|
||||||
typeof window.ArchipelagoSurface.expectPageTransition === 'function') {
|
typeof window.ArchipelagoSurface.expectPageTransition === 'function') {
|
||||||
window.ArchipelagoSurface.expectPageTransition();
|
window.ArchipelagoSurface.expectPageTransition();
|
||||||
@@ -448,25 +527,13 @@
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
authDone = false;
|
if (authAttempt === attempt) authAttempt = null;
|
||||||
var message = error && error.message ? error.message : String(error);
|
var message = error && error.message ? error.message : String(error);
|
||||||
if (message.toLowerCase().indexOf('abort') > -1) message = 'API timeout';
|
if (message.toLowerCase().indexOf('abort') > -1) message = 'API timeout';
|
||||||
console.warn('[nostr-provider] NIP-98 skipped:', message);
|
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
|
// Only identity-aware apps open the chooser eagerly. The provider is also
|
||||||
// injected into several ordinary app proxies; those stay untouched unless
|
// injected into several ordinary app proxies; those stay untouched unless
|
||||||
// they actually invoke a NIP-07 method, which lazily creates the broker.
|
// they actually invoke a NIP-07 method, which lazily creates the broker.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type ProviderWindow = Window & {
|
|||||||
}
|
}
|
||||||
nostr?: { getPublicKey: () => Promise<string> }
|
nostr?: { getPublicKey: () => Promise<string> }
|
||||||
archipelagoNostr?: {
|
archipelagoNostr?: {
|
||||||
|
clearSession: () => void
|
||||||
selectIdentity: () => Promise<unknown>
|
selectIdentity: () => Promise<unknown>
|
||||||
getSelectedIdentity: () => { nostr_pubkey: string } | null
|
getSelectedIdentity: () => { nostr_pubkey: string } | null
|
||||||
onIdentitySelected: (
|
onIdentitySelected: (
|
||||||
@@ -57,6 +58,18 @@ describe('nostr-provider identity selection', () => {
|
|||||||
delete providerWindow.ArchipelagoSurface
|
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) {
|
function loadProvider(userActivated: boolean) {
|
||||||
Object.defineProperty(navigator, 'userActivation', {
|
Object.defineProperty(navigator, 'userActivation', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -89,14 +102,14 @@ describe('nostr-provider identity selection', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const selected = new MessageEvent('message', {
|
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,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
||||||
window.dispatchEvent(selected)
|
window.dispatchEvent(selected)
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
|
|
||||||
await expect(publicKey).resolves.toBe('abc123')
|
await expect(publicKey).resolves.toBe('6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090')
|
||||||
expect(postMessage).not.toHaveBeenCalledWith(
|
expect(postMessage).not.toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
|
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
|
||||||
signerOrigin,
|
signerOrigin,
|
||||||
@@ -120,14 +133,14 @@ describe('nostr-provider identity selection', () => {
|
|||||||
it('keeps an eager picker choice for the app login that follows', async () => {
|
it('keeps an eager picker choice for the app login that follows', async () => {
|
||||||
const { frame, postMessage, signerOrigin } = loadProvider(false)
|
const { frame, postMessage, signerOrigin } = loadProvider(false)
|
||||||
const selected = new MessageEvent('message', {
|
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,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
||||||
window.dispatchEvent(selected)
|
window.dispatchEvent(selected)
|
||||||
postMessage.mockClear()
|
postMessage.mockClear()
|
||||||
|
|
||||||
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('fast-choice')
|
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('d930382168af7367013ad9723e7ebc093918f788abcb32ce93db245031edff0c')
|
||||||
expect(postMessage).not.toHaveBeenCalledWith(
|
expect(postMessage).not.toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
|
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
|
||||||
signerOrigin,
|
signerOrigin,
|
||||||
@@ -137,12 +150,12 @@ describe('nostr-provider identity selection', () => {
|
|||||||
it('does not reopen after a selected identity when activation survives account restoration', async () => {
|
it('does not reopen after a selected identity when activation survives account restoration', async () => {
|
||||||
const { frame, postMessage, signerOrigin } = loadProvider(true)
|
const { frame, postMessage, signerOrigin } = loadProvider(true)
|
||||||
const selected = new MessageEvent('message', {
|
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,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
||||||
window.dispatchEvent(selected)
|
window.dispatchEvent(selected)
|
||||||
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('selected-key')
|
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('4af900489429183b065f3cd1e46c59666642b54bed7c2a5a0c0566970992536e')
|
||||||
postMessage.mockClear()
|
postMessage.mockClear()
|
||||||
const next = providerWindow.nostr!.getPublicKey()
|
const next = providerWindow.nostr!.getPublicKey()
|
||||||
expect(postMessage).not.toHaveBeenCalledWith(
|
expect(postMessage).not.toHaveBeenCalledWith(
|
||||||
@@ -150,11 +163,11 @@ describe('nostr-provider identity selection', () => {
|
|||||||
)
|
)
|
||||||
const request = postMessage.mock.calls[0]![0] as { id: number }
|
const request = postMessage.mock.calls[0]![0] as { id: number }
|
||||||
const response = new MessageEvent('message', {
|
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 })
|
Object.defineProperty(response, 'source', { value: frame.contentWindow })
|
||||||
window.dispatchEvent(response)
|
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.
|
// Explicit account switching remains available after a successful login.
|
||||||
void providerWindow.archipelagoNostr!.selectIdentity()
|
void providerWindow.archipelagoNostr!.selectIdentity()
|
||||||
expect(postMessage).toHaveBeenCalledWith(
|
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')
|
sessionStorage.setItem('nostr_token', 'test-session-hint')
|
||||||
const { postMessage, signerOrigin } = loadProvider(true)
|
const { postMessage, signerOrigin } = loadProvider(true)
|
||||||
void providerWindow.nostr!.getPublicKey()
|
void providerWindow.nostr!.getPublicKey()
|
||||||
expect(postMessage).toHaveBeenCalledWith(
|
expect(postMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }), signerOrigin,
|
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), signerOrigin,
|
||||||
)
|
|
||||||
expect(postMessage).not.toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), expect.anything(),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,18 +229,18 @@ describe('nostr-provider identity selection', () => {
|
|||||||
.find(message => message.type === 'nostr-request')!
|
.find(message => message.type === 'nostr-request')!
|
||||||
expect(request).toBeDefined()
|
expect(request).toBeDefined()
|
||||||
const response = new MessageEvent('message', {
|
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,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(response, 'source', { value: replacement.contentWindow })
|
Object.defineProperty(response, 'source', { value: replacement.contentWindow })
|
||||||
window.dispatchEvent(response)
|
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', () => {
|
it('delivers an eager identity to an app listener that mounts afterward', () => {
|
||||||
const { frame, signerOrigin } = loadProvider(false)
|
const { frame, signerOrigin } = loadProvider(false)
|
||||||
const selected = new MessageEvent('message', {
|
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,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
||||||
@@ -240,13 +250,13 @@ describe('nostr-provider identity selection', () => {
|
|||||||
const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener)
|
const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener)
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledOnce()
|
expect(listener).toHaveBeenCalledOnce()
|
||||||
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'late-listener' })
|
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' })
|
||||||
expect(providerWindow.archipelagoNostr!.getSelectedIdentity())
|
expect(providerWindow.archipelagoNostr!.getSelectedIdentity())
|
||||||
.toEqual({ nostr_pubkey: 'late-listener' })
|
.toEqual({ nostr_pubkey: 'c23f5e74ca130353248c66f8fdeff29bcd77515f19536c5f82c51da223f9b410' })
|
||||||
|
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
const changed = new MessageEvent('message', {
|
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,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(changed, 'source', { value: frame.contentWindow })
|
Object.defineProperty(changed, 'source', { value: frame.contentWindow })
|
||||||
@@ -258,13 +268,13 @@ describe('nostr-provider identity selection', () => {
|
|||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
const { postMessage, signerOrigin } = loadProvider(false)
|
const { frame, postMessage, signerOrigin } = loadProvider(false)
|
||||||
|
|
||||||
const identity = new MessageEvent('message', {
|
const identity = new MessageEvent('message', {
|
||||||
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
|
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } },
|
||||||
origin: window.location.origin,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(identity, 'source', { value: window })
|
Object.defineProperty(identity, 'source', { value: frame.contentWindow })
|
||||||
window.dispatchEvent(identity)
|
window.dispatchEvent(identity)
|
||||||
await vi.advanceTimersByTimeAsync(1500)
|
await vi.advanceTimersByTimeAsync(1500)
|
||||||
|
|
||||||
@@ -276,7 +286,7 @@ describe('nostr-provider identity selection', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'nostr-request',
|
type: 'nostr-request',
|
||||||
method: 'signEvent',
|
method: 'signEvent',
|
||||||
params: { event: expect.objectContaining({ kind: 27235, pubkey: 'indeedhub-key' }) },
|
params: { event: expect.objectContaining({ kind: 27235, pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' }) },
|
||||||
}),
|
}),
|
||||||
signerOrigin,
|
signerOrigin,
|
||||||
)
|
)
|
||||||
@@ -288,17 +298,17 @@ describe('nostr-provider identity selection', () => {
|
|||||||
.mockResolvedValueOnce({ ok: true })
|
.mockResolvedValueOnce({ ok: true })
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ accessToken: 'real-token', refreshToken: 'refresh' }),
|
json: () => Promise.resolve({ accessToken: token('0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047'), refreshToken: 'refresh' }),
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||||
const { frame, postMessage, signerOrigin } = loadProvider(false)
|
const { frame, postMessage, signerOrigin } = loadProvider(false)
|
||||||
|
|
||||||
const identity = new MessageEvent('message', {
|
const identity = new MessageEvent('message', {
|
||||||
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
|
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: '0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047' } },
|
||||||
origin: window.location.origin,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(identity, 'source', { value: window })
|
Object.defineProperty(identity, 'source', { value: frame.contentWindow })
|
||||||
window.dispatchEvent(identity)
|
window.dispatchEvent(identity)
|
||||||
await vi.advanceTimersByTimeAsync(1500)
|
await vi.advanceTimersByTimeAsync(1500)
|
||||||
|
|
||||||
@@ -313,14 +323,14 @@ describe('nostr-provider identity selection', () => {
|
|||||||
window.dispatchEvent(show)
|
window.dispatchEvent(show)
|
||||||
|
|
||||||
const signed = new MessageEvent('message', {
|
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,
|
origin: signerOrigin,
|
||||||
})
|
})
|
||||||
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
|
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
|
||||||
window.dispatchEvent(signed)
|
window.dispatchEvent(signed)
|
||||||
await vi.advanceTimersByTimeAsync(0)
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
|
||||||
expect(sessionStorage.getItem('nostr_token')).toBe('real-token')
|
expect(sessionStorage.getItem('nostr_token')).toBe(token('0f431dd8b163f611777915a6479a81f48f1d3f2e2133e6916c91290c9b352047'))
|
||||||
expect(raf).not.toHaveBeenCalled()
|
expect(raf).not.toHaveBeenCalled()
|
||||||
|
|
||||||
const hide = new MessageEvent('message', {
|
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))
|
expect(postMessage.mock.calls.map(call => (call[0] as { type: string }).type))
|
||||||
.toEqual(['archipelago:signer-init', 'nostr-request'])
|
.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))
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user