diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml
index 31605ef9..b4f059cd 100644
--- a/apps/indeedhub/manifest.yml
+++ b/apps/indeedhub/manifest.yml
@@ -73,6 +73,8 @@ app:
- exec: ["sh", "-c", "if ! grep -qE '#' /usr/share/nginx/html/index.html; fi"]
- exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"]
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"]
+ # Compose the outer app-proxy prefix for NIP-98 signed URL verification.
+ - exec: ["sed", "-i", "s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|", "/etc/nginx/conf.d/default.conf"]
- exec: ["nginx", "-s", "reload"]
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
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/components/MediaRegistrationConsent.vue b/neode-ui/src/components/MediaRegistrationConsent.vue
index 4f634c16..70e13b8e 100644
--- a/neode-ui/src/components/MediaRegistrationConsent.vue
+++ b/neode-ui/src/components/MediaRegistrationConsent.vue
@@ -41,6 +41,7 @@