Merge pull request #1870 from PR0M3TH3AN/refactor-auth-providers-v2-4587525965814839162

Refactor Auth Providers to NostrClient with Security Validators
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-28 09:06:46 -05:00
commit dc7257855a
5 files changed
+330 -187

No files matched your search

+208 -1
View File
@@ -19,7 +19,6 @@
import { isDevMode } from "../config.js";
import { FEATURE_PUBLISH_NIP71 } from "../constants.js";
import { accessControl } from "../accessControl.js";
import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js";
import { infoHashFromMagnet } from "../magnets.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
@@ -1900,6 +1899,157 @@ export class NostrClient {
* 4. **Authorize**: Handles any `auth_url` challenges if the signer requires out-of-band approval.
* 5. **Session**: Stores the session metadata locally (if `remember: true`) to allow auto-reconnect.
*/
async loginWithExtension(options = {}) {
let extension = null;
let attempts = 0;
const maxAttempts = 3;
// Optimistic check: if window.nostr is already there, use it immediately.
if (typeof window !== "undefined" && window.nostr) {
extension = window.nostr;
} else {
while (!extension && attempts < maxAttempts) {
try {
// Fast wait for the first attempt, slightly longer for retries
const timeout = attempts === 0 ? 1500 : 3000;
await waitForNip07Extension(timeout);
extension = window.nostr;
} catch (waitError) {
devLogger.log(
`Timed out waiting for extension injection (attempt ${
attempts + 1
}/${maxAttempts}):`,
waitError,
);
}
if (extension) {
break;
}
attempts++;
if (attempts < maxAttempts) {
devLogger.log("Retrying NIP-07 detection...");
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (!extension) {
devLogger.log("No Nostr extension found");
throw new Error("Please install a Nostr extension (Alby, nos2x, etc.).");
}
const { allowAccountSelection = false, expectPubkey } =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension.",
);
}
const permissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult.ok) {
const denialMessage =
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
if (
allowAccountSelection &&
typeof extension.selectAccounts === "function"
) {
try {
const selection = await runNip07WithRetry(
() =>
extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
{ label: "extension.selectAccounts" },
);
const didCancelSelection =
selection === undefined ||
selection === null ||
selection === false ||
(Array.isArray(selection) && selection.length === 0);
if (didCancelSelection) {
throw new Error("Account selection was cancelled.");
}
} catch (selectionErr) {
const message =
selectionErr && typeof selectionErr.message === "string"
? selectionErr.message
: "Account selection was cancelled.";
throw new Error(message);
}
}
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
label: "extension.getPublicKey",
});
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again.",
);
}
if (
normalizedExpectedPubkey &&
pubkey.toLowerCase() !== normalizedExpectedPubkey
) {
throw new Error(
"The selected account doesn't match the expected profile. Please try again.",
);
}
if (typeof options.validator === "function") {
try {
const isValid = await options.validator(pubkey);
if (!isValid) {
throw new Error("Access denied.");
}
} catch (validationError) {
const message =
validationError instanceof Error
? validationError.message
: "Access denied.";
const error = new Error(message);
error.code = "access-denied";
if (validationError instanceof Error) {
error.cause = validationError;
}
throw error;
}
}
const adapter = await createNip07Adapter(extension);
adapter.pubkey = pubkey;
setActiveSigner(adapter);
this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS).catch(
(err) => {
userLogger.warn(
"[nostr] Extension permissions were not fully granted after login:",
err,
);
},
);
return { pubkey, signer: adapter };
}
async connectRemoteSigner({
connectionString,
remember = true,
@@ -2201,6 +2351,25 @@ export class NostrClient {
remotePubkey: summarizeHexForLog(remotePubkey),
userPubkey: summarizeHexForLog(userPubkey),
});
if (typeof options?.validator === "function") {
try {
const isValid = await options.validator(userPubkey);
if (!isValid) {
throw new Error("Access denied.");
}
} catch (validationError) {
const message =
validationError instanceof Error
? validationError.message
: "Access denied.";
const error = new Error(message);
error.code = "access-denied";
client.destroy(); // Cleanup client since we won't use it
throw error;
}
}
client.metadata = metadata;
const signer = await this.installNip46Client(client, { userPubkey });
@@ -2296,6 +2465,7 @@ export class NostrClient {
options && typeof options === "object" ? options : {};
const silent = normalizedOptions.silent === true;
const forgetOnError = normalizedOptions.forgetOnError === true;
const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null;
const stored = readStoredNip46Session();
if (!stored) {
@@ -2344,6 +2514,25 @@ export class NostrClient {
await client.ensureSubscription();
await client.connect({ permissions: stored.permissions });
const userPubkey = await client.getUserPubkey();
if (validator) {
try {
const isValid = await validator(userPubkey);
if (!isValid) {
throw new Error("Access denied.");
}
} catch (validationError) {
const message =
validationError instanceof Error
? validationError.message
: "Access denied.";
const error = new Error(message);
error.code = "access-denied";
client.destroy();
throw error;
}
}
client.metadata = stored.metadata;
const signer = await this.installNip46Client(client, { userPubkey });
@@ -3122,6 +3311,7 @@ export class NostrClient {
pubkey,
persist = false,
passphrase,
validator,
} = {}) {
const normalizedPrivateKey =
typeof privateKey === "string" && HEX64_REGEX.test(privateKey)
@@ -3158,6 +3348,23 @@ export class NostrClient {
throw new Error("Unable to resolve the public key for this private key.");
}
if (typeof validator === "function") {
try {
const isValid = await validator(normalizedPubkey);
if (!isValid) {
throw new Error("Access denied.");
}
} catch (validationError) {
const message =
validationError instanceof Error
? validationError.message
: "Access denied.";
const error = new Error(message);
error.code = "access-denied";
throw error;
}
}
this.sessionActor = {
pubkey: normalizedPubkey,
privateKey: normalizedPrivateKey,
+17 -185
View File
@@ -1,14 +1,4 @@
import {
waitForNip07Extension,
runNip07WithRetry,
requestEnablePermissions,
DEFAULT_NIP07_PERMISSION_METHODS,
} from "../../nostr/nip07Permissions.js";
import { createNip07Adapter } from "../../nostr/adapters/nip07Adapter.js";
import { setActiveSigner } from "../../nostrClientRegistry.js";
import { ensureNostrTools } from "../../nostr/toolkit.js";
import { accessControl } from "../../accessControl.js";
import { devLogger, userLogger } from "../../utils/logger.js";
const PROVIDER_ID = "nip07";
const PROVIDER_LABEL = "extension (nip-07)";
@@ -55,180 +45,6 @@ function normalizePubkey(result) {
return "";
}
async function performNip07Login(options = {}, nostrClient = null) {
let extension = null;
let attempts = 0;
const maxAttempts = 3;
// Optimistic check: if window.nostr is already there, use it immediately.
if (typeof window !== "undefined" && window.nostr) {
extension = window.nostr;
} else {
while (!extension && attempts < maxAttempts) {
try {
// Fast wait for the first attempt, slightly longer for retries
const timeout = attempts === 0 ? 1500 : 3000;
await waitForNip07Extension(timeout);
extension = window.nostr;
} catch (waitError) {
devLogger.log(
`Timed out waiting for extension injection (attempt ${
attempts + 1
}/${maxAttempts}):`,
waitError,
);
}
if (extension) {
break;
}
attempts++;
if (attempts < maxAttempts) {
devLogger.log("Retrying NIP-07 detection...");
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (!extension) {
devLogger.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
const { allowAccountSelection = false, expectPubkey } =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
);
}
// Use the client instance if available to ensure permission cache is updated
if (nostrClient && typeof nostrClient.ensureExtensionPermissions === "function") {
const permissionResult = await nostrClient.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult.ok) {
const denialMessage =
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
} else {
const permissionResult = await requestEnablePermissions(
extension,
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult.ok) {
const denialMessage =
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
}
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
try {
const selection = await runNip07WithRetry(
() => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
{ label: "extension.selectAccounts" }
);
const didCancelSelection =
selection === undefined ||
selection === null ||
selection === false ||
(Array.isArray(selection) && selection.length === 0);
if (didCancelSelection) {
throw new Error("Account selection was cancelled.");
}
} catch (selectionErr) {
const message =
selectionErr && typeof selectionErr.message === "string"
? selectionErr.message
: "Account selection was cancelled.";
throw new Error(message);
}
}
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
label: "extension.getPublicKey",
});
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again."
);
}
if (
normalizedExpectedPubkey &&
pubkey.toLowerCase() !== normalizedExpectedPubkey
) {
throw new Error(
"The selected account doesn't match the expected profile. Please try again."
);
}
const nip19Tools = await ensureNostrTools();
const npubEncode = nip19Tools?.nip19?.npubEncode;
if (typeof npubEncode !== "function") {
throw new Error("NostrTools nip19 encoder is unavailable.");
}
const npub = npubEncode(pubkey);
devLogger.log("Got pubkey:", pubkey);
devLogger.log("Converted to npub:", npub);
// Access control
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform.");
} else {
throw new Error("Access restricted to admins and moderators users only.");
}
}
const adapter = await createNip07Adapter(extension);
adapter.pubkey = pubkey;
setActiveSigner(adapter);
// Ensure full permissions are requested post-login (e.g. encryption)
// This is non-blocking to avoid stalling if the user already approved
if (nostrClient && typeof nostrClient.ensureExtensionPermissions === "function") {
nostrClient.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS).catch((err) => {
userLogger.warn(
"[nostr] Extension permissions were not fully granted after login:",
err
);
});
} else {
requestEnablePermissions(extension, DEFAULT_NIP07_PERMISSION_METHODS).catch((err) => {
userLogger.warn(
"[nostr] Extension permissions were not fully granted after login:",
err
);
});
}
return { pubkey, signer: adapter };
}
export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
@@ -241,7 +57,23 @@ export default {
messages: PROVIDER_MESSAGES,
errorMessage: PROVIDER_MESSAGES.error,
async login({ nostrClient, options } = {}) {
const result = await performNip07Login(options, nostrClient);
if (!nostrClient || typeof nostrClient.loginWithExtension !== "function") {
const error = new Error("Extension login is not available in this environment.");
error.code = "provider-unavailable";
throw error;
}
const validator = (pubkey) => {
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
return true;
};
const result = await nostrClient.loginWithExtension({ ...options, validator });
const pubkey = normalizePubkey(result);
const signer =
result && typeof result === "object" && result.signer
+22 -1
View File
@@ -1,4 +1,5 @@
import { devLogger } from "../../utils/logger.js";
import { accessControl } from "../../accessControl.js";
function summarizeHexForLog(value) {
if (typeof value !== "string" || !value.trim()) {
@@ -154,6 +155,16 @@ export default {
});
let result = null;
const validator = (pubkey) => {
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
return true;
};
if (normalized.reuseStored) {
if (typeof nostrClient.useStoredRemoteSigner !== "function") {
const error = new Error("No stored remote signer is available on this device.");
@@ -162,7 +173,15 @@ export default {
}
devLogger.debug("[nip46] Reusing stored remote signer session");
result = await nostrClient.useStoredRemoteSigner();
// useStoredRemoteSigner should ideally accept a validator too, but if it restores a session,
// it might not expose a validator hook easily if not updated.
// Let's check NostrClient.useStoredRemoteSigner in client.js.
// It DOES NOT take a validator in my previous update (I missed it).
// I should update client.js to add validator to useStoredRemoteSigner too.
// For now, I will assume I will fix client.js later or accept that stored sessions are trusted?
// No, I must fix useStoredRemoteSigner too.
result = await nostrClient.useStoredRemoteSigner({ validator });
devLogger.debug("[nip46] Stored remote signer returned", {
pubkey: summarizeHexForLog(normalizePubkey(result)),
});
@@ -184,6 +203,7 @@ export default {
remember: normalized.remember,
onAuthUrl: normalized.onAuthUrl,
onStatus: normalized.onStatus,
validator,
});
devLogger.debug("[nip46] Manual connect completed", {
pubkey: summarizeHexForLog(normalizePubkey(result)),
@@ -243,6 +263,7 @@ export default {
onAuthUrl: normalized.onAuthUrl,
onStatus: normalized.onStatus,
handshakeTimeoutMs: normalized.handshakeTimeoutMs,
validator,
});
if (result && typeof result === "object") {
+13
View File
@@ -1,3 +1,5 @@
import { accessControl } from "../../accessControl.js";
const PROVIDER_ID = "nsec";
const PROVIDER_LABEL = "nsec or seed (direct private key)";
const PROVIDER_DESCRIPTION =
@@ -131,11 +133,22 @@ export default {
throw error;
}
const validator = (pubkey) => {
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
return true;
};
const registrationResult = await nostrClient.registerPrivateKeySigner({
privateKey,
pubkey,
persist: shouldPersist,
passphrase: shouldPersist ? passphrase : undefined,
validator,
});
const registeredPubkey = normalizePubkey(registrationResult) || pubkey;
+70
View File
@@ -0,0 +1,70 @@
import './setup-test-env.js';
import { NostrClient, getActiveSigner } from '../../js/nostr/client.js';
// import { userLogger, devLogger } from '../../js/utils/logger.js';
// Polyfill window.nostr
const mockPubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const mockNostr = {
getPublicKey: async () => mockPubkey,
signEvent: async (e) => {
e.sig = "mock-sig";
e.id = "mock-id";
return e;
},
nip04: {
encrypt: async () => "mock-ciphertext",
decrypt: async () => "mock-plaintext"
}
};
global.window.nostr = mockNostr;
async function run() {
console.log("Starting NIP-07 Refactor Verification...");
const client = new NostrClient();
// Skip client.init() to avoid network/relay connection attempts in this isolated test
// await client.init();
console.log("Testing loginWithExtension...");
try {
const result = await client.loginWithExtension();
console.log("Login returned:", { pubkey: result.pubkey, hasSigner: !!result.signer });
if (result.pubkey !== mockPubkey) {
throw new Error(`Pubkey mismatch: expected ${mockPubkey}, got ${result.pubkey}`);
}
const activeSigner = getActiveSigner();
if (!activeSigner) {
throw new Error("Active signer was not set!");
}
if (activeSigner.pubkey !== mockPubkey) {
throw new Error(`Active signer pubkey mismatch: ${activeSigner.pubkey}`);
}
console.log("Testing validator failure...");
try {
await client.loginWithExtension({
validator: async () => false // should fail
});
console.error("Validator failure test FAILED: Should have thrown error");
process.exit(1);
} catch (error) {
if (error.message.includes("Access denied")) {
console.log("Validator failure test SUCCESS!");
} else {
console.error("Validator failure test FAILED: Wrong error", error);
process.exit(1);
}
}
console.log("Verification SUCCESS!");
} catch (error) {
console.error("Verification FAILED:", error);
process.exit(1);
}
}
run();