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:
archipelago
2026-10-08 13:07:56 -04:00
parent 5828df5658
commit eef17eb79b
2 changed files with 293 additions and 57 deletions
+94 -27
View File
@@ -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.