diff --git a/js/services/authService.js b/js/services/authService.js index 9ba8994c..76cba80e 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -34,62 +34,15 @@ import { safeDecodeNpub, normalizeHexPubkey, } from "../utils/nostrHelpers.js"; - -class SimpleEventEmitter { - constructor(logger = null) { - this.logger = typeof logger === "function" ? logger : null; - this.listeners = new Map(); - } - - on(eventName, handler) { - if (typeof handler !== "function") { - return () => {}; - } - if (!this.listeners.has(eventName)) { - this.listeners.set(eventName, new Set()); - } - const handlers = this.listeners.get(eventName); - handlers.add(handler); - return () => { - handlers.delete(handler); - if (!handlers.size) { - this.listeners.delete(eventName); - } - }; - } - - emit(eventName, detail) { - const handlers = this.listeners.get(eventName); - if (!handlers || !handlers.size) { - return; - } - - for (const handler of Array.from(handlers)) { - try { - handler(detail); - } catch (error) { - if (this.logger) { - this.logger(`AuthService listener for "${eventName}" threw`, error); - } - } - } - } -} - - -const FALLBACK_PROFILE = { - name: "Unknown", - picture: "assets/svg/default-profile.svg", - about: "", - website: "", - banner: "", - lud16: "", - lud06: "", -}; - -const FAST_PROFILE_RELAY_LIMIT = 3; -const FAST_PROFILE_TIMEOUT_MS = 2500; -const BACKGROUND_PROFILE_TIMEOUT_MS = 6000; +import { SimpleEventEmitter } from "../utils/simpleEventEmitter.js"; +import { + FALLBACK_PROFILE, + FAST_PROFILE_RELAY_LIMIT, + FAST_PROFILE_TIMEOUT_MS, + BACKGROUND_PROFILE_TIMEOUT_MS, + normalizeProviderId, + normalizeAuthType, +} from "./authUtils.js"; export default class AuthService { constructor({ @@ -128,7 +81,7 @@ export default class AuthService { if (typeof getAuthProvider === "function") { this.resolveAuthProvider = (providerId) => { - const normalizedId = this.normalizeProviderId(providerId); + const normalizedId = normalizeProviderId(providerId); const provider = getAuthProvider(normalizedId); if (!provider || typeof provider.login !== "function") { const error = new Error(`Unknown auth provider: ${normalizedId}`); @@ -139,7 +92,7 @@ export default class AuthService { }; } else { this.resolveAuthProvider = (providerId) => { - const normalizedId = this.normalizeProviderId(providerId); + const normalizedId = normalizeProviderId(providerId); const registry = this.authProviders || defaultAuthProviders; const provider = registry && typeof registry === "object" @@ -155,45 +108,6 @@ export default class AuthService { } } - normalizeProviderId(providerId) { - return typeof providerId === "string" && providerId.trim() - ? providerId.trim() - : "nip07"; - } - - normalizeAuthType(authTypeCandidate, providerId, providerResult) { - const candidates = []; - - if (typeof authTypeCandidate === "string") { - candidates.push(authTypeCandidate); - } - - if (providerResult && typeof providerResult === "object") { - const resultAuthType = providerResult.authType; - if (typeof resultAuthType === "string") { - candidates.push(resultAuthType); - } - - const resultProviderId = providerResult.providerId; - if (typeof resultProviderId === "string") { - candidates.push(resultProviderId); - } - } - - if (typeof providerId === "string") { - candidates.push(providerId); - } - - for (const candidate of candidates) { - const trimmed = candidate.trim(); - if (trimmed) { - return trimmed; - } - } - - return "nip07"; - } - log(...args) { try { this.logger(...args); @@ -293,7 +207,7 @@ export default class AuthService { ...providerOptions } = normalizedOptions; - const providerId = this.normalizeProviderId(requestedProviderId); + const providerId = normalizeProviderId(requestedProviderId); const provider = this.resolveAuthProvider(providerId); const providerResult = await provider.login({ nostrClient: this.nostrClient, @@ -315,7 +229,7 @@ export default class AuthService { providerResult && typeof providerResult === "object" ? providerResult.authType : null; - const authType = this.normalizeAuthType( + const authType = normalizeAuthType( authTypeCandidate, providerId, providerResult, @@ -405,11 +319,62 @@ export default class AuthService { } async login(pubkey, options = {}) { + const { + normalizedOptions, + providerId, + authType, + signer, + providerResult, + nextPubkey, + normalizedPubkey, + trimmedPubkey, + previousPubkey, + persistActive, + } = this._validateAndNormalizeLoginInputs(pubkey, options); + + const { candidateNpub } = this._checkAccessControl( + nextPubkey, + trimmedPubkey, + ); + + const identityChanged = previousPubkey !== nextPubkey; + + this._updateGlobalState( + normalizedPubkey, + nextPubkey, + trimmedPubkey, + candidateNpub, + ); + + const { savedProfilesMutated, entryAuthType } = this._updateSavedProfiles({ + normalizedPubkey, + nextPubkey, + candidateNpub, + authType, + providerId, + providerResult, + persistActive, + }); + + return this._initiatePostLogin({ + activeLoginPubkey: getPubkey(), + previousPubkey, + identityChanged, + savedProfiles: this.cloneSavedProfiles(), + activeProfilePubkey: getActiveProfilePubkey(), + providerId, + entryAuthType, + signer, + providerResult, + }); + } + + _validateAndNormalizeLoginInputs(pubkey, options) { const normalizedOptions = options && typeof options === "object" ? { ...options } : {}; const persistActive = normalizedOptions.persistActive === false ? false : true; - const providerId = this.normalizeProviderId(normalizedOptions.providerId); + const providerId = normalizeProviderId(normalizedOptions.providerId); const providerResult = Object.prototype.hasOwnProperty.call( normalizedOptions, "providerResult", @@ -420,28 +385,46 @@ export default class AuthService { typeof normalizedOptions.authType === "string" ? normalizedOptions.authType : null; - const authType = this.normalizeAuthType( + const authType = normalizeAuthType( authTypeCandidate, providerId, providerResult, ); - const signer = Object.prototype.hasOwnProperty.call(normalizedOptions, "signer") + const signer = Object.prototype.hasOwnProperty.call( + normalizedOptions, + "signer", + ) ? normalizedOptions.signer ?? null : null; const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey(); - const normalized = this.normalizeHexPubkey(pubkey); - const trimmed = typeof pubkey === "string" ? pubkey.trim() : ""; - const nextPubkey = normalized || trimmed || null; + const normalizedPubkey = this.normalizeHexPubkey(pubkey); + const trimmedPubkey = typeof pubkey === "string" ? pubkey.trim() : ""; + const nextPubkey = normalizedPubkey || trimmedPubkey || null; + if (!nextPubkey) { throw new Error("A valid pubkey is required for login."); } - const identityChanged = previousPubkey !== nextPubkey; + return { + normalizedOptions, + providerId, + authType, + signer, + providerResult, + nextPubkey, + normalizedPubkey, + trimmedPubkey, + previousPubkey, + persistActive, + }; + } + _checkAccessControl(nextPubkey, trimmedPubkey) { const control = this.accessControl; const candidateNpub = this.safeEncodeNpub(nextPubkey) || null; let lockdownActive = false; + if (control && typeof control.isLockdownActive === "function") { try { lockdownActive = control.isLockdownActive(); @@ -454,7 +437,10 @@ export default class AuthService { if (lockdownActive) { let isAdminCandidate = false; const adminCheckValue = - candidateNpub || (typeof trimmed === "string" && trimmed ? trimmed : null); + candidateNpub || + (typeof trimmedPubkey === "string" && trimmedPubkey + ? trimmedPubkey + : null); if (adminCheckValue && typeof control?.isAdminEditor === "function") { try { isAdminCandidate = !!control.isAdminEditor(adminCheckValue); @@ -503,73 +489,101 @@ export default class AuthService { } } - if (normalized) { - setPubkey(normalized); + return { candidateNpub }; + } + + _updateGlobalState( + normalizedPubkey, + nextPubkey, + trimmedPubkey, + candidateNpub, + ) { + if (normalizedPubkey) { + setPubkey(normalizedPubkey); if (this.nostrClient && typeof this.nostrClient === "object") { - this.nostrClient.pubkey = normalized; + this.nostrClient.pubkey = normalizedPubkey; } - profileCache.setActiveProfile(normalized); + profileCache.setActiveProfile(normalizedPubkey); } else { setPubkey(nextPubkey); if (this.nostrClient && typeof this.nostrClient === "object") { - this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : ""; + this.nostrClient.pubkey = trimmedPubkey + ? trimmedPubkey.toLowerCase() + : ""; } - profileCache.setActiveProfile(trimmed ? trimmed.toLowerCase() : null); + profileCache.setActiveProfile( + trimmedPubkey ? trimmedPubkey.toLowerCase() : null, + ); } - const npub = candidateNpub; - setCurrentUserNpub(npub); + setCurrentUserNpub(candidateNpub); + } + _updateSavedProfiles({ + normalizedPubkey, + nextPubkey, + candidateNpub, + authType, + providerId, + providerResult, + persistActive, + }) { let savedProfilesMutated = false; - const entryAuthType = this.normalizeAuthType( + const entryAuthType = normalizeAuthType( authType, providerId, providerResult, ); - const entryProviderId = this.normalizeProviderId(providerId) || entryAuthType; + const entryProviderId = + normalizeProviderId(providerId) || entryAuthType; - mutateSavedProfiles((profiles) => { - const draft = Array.isArray(profiles) ? profiles.slice() : []; - const existingIndex = draft.findIndex((entry) => { - const normalizedEntry = this.normalizeHexPubkey(entry?.pubkey); - return normalizedEntry && normalizedEntry === normalized; - }); + mutateSavedProfiles( + (profiles) => { + const draft = Array.isArray(profiles) ? profiles.slice() : []; + const existingIndex = draft.findIndex((entry) => { + const normalizedEntry = this.normalizeHexPubkey(entry?.pubkey); + return normalizedEntry && normalizedEntry === normalizedPubkey; + }); - const cacheEntry = this.getProfileCacheEntry(normalized || nextPubkey); - const cachedProfile = cacheEntry?.profile || {}; + const cacheEntry = this.getProfileCacheEntry( + normalizedPubkey || nextPubkey, + ); + const cachedProfile = cacheEntry?.profile || {}; - const nextEntry = { - pubkey: normalized || nextPubkey, - npub: npub || (cachedProfile.npub ?? null), - name: cachedProfile.name || draft[existingIndex]?.name || "", - picture: cachedProfile.picture || draft[existingIndex]?.picture || "", - authType: entryAuthType, - providerId: entryProviderId, - }; + const nextEntry = { + pubkey: normalizedPubkey || nextPubkey, + npub: candidateNpub || (cachedProfile.npub ?? null), + name: cachedProfile.name || draft[existingIndex]?.name || "", + picture: cachedProfile.picture || draft[existingIndex]?.picture || "", + authType: entryAuthType, + providerId: entryProviderId, + }; - if (existingIndex >= 0) { - const currentEntry = draft[existingIndex] || {}; - const changed = - currentEntry.npub !== nextEntry.npub || - currentEntry.name !== nextEntry.name || - currentEntry.picture !== nextEntry.picture || - currentEntry.authType !== nextEntry.authType || - currentEntry.providerId !== nextEntry.providerId; - if (changed) { - draft[existingIndex] = nextEntry; + if (existingIndex >= 0) { + const currentEntry = draft[existingIndex] || {}; + const changed = + currentEntry.npub !== nextEntry.npub || + currentEntry.name !== nextEntry.name || + currentEntry.picture !== nextEntry.picture || + currentEntry.authType !== nextEntry.authType || + currentEntry.providerId !== nextEntry.providerId; + if (changed) { + draft[existingIndex] = nextEntry; + savedProfilesMutated = true; + } + } else { + draft.push(nextEntry); savedProfilesMutated = true; } - } else { - draft.push(nextEntry); - savedProfilesMutated = true; - } - return draft; - }, { persist: false, persistActive: false }); + return draft; + }, + { persist: false, persistActive: false }, + ); if (persistActive) { - setActiveProfilePubkey(normalized || nextPubkey, { persist: true }); + setActiveProfilePubkey(normalizedPubkey || nextPubkey, { persist: true }); } if (!persistActive && savedProfilesMutated) { @@ -582,7 +596,20 @@ export default class AuthService { this.emitProfileList("login"); } - const activeLoginPubkey = getPubkey(); + return { savedProfilesMutated, entryAuthType }; + } + + _initiatePostLogin({ + activeLoginPubkey, + previousPubkey, + identityChanged, + savedProfiles, + activeProfilePubkey, + providerId, + entryAuthType, + signer, + providerResult, + }) { const normalizedLoginPubkey = this.normalizeHexPubkey(activeLoginPubkey) || activeLoginPubkey || null; @@ -591,8 +618,8 @@ export default class AuthService { npub: getCurrentUserNpub(), previousPubkey: previousPubkey || null, identityChanged, - savedProfiles: this.cloneSavedProfiles(), - activeProfilePubkey: getActiveProfilePubkey(), + savedProfiles, + activeProfilePubkey, providerId, authType: entryAuthType, signer, @@ -607,7 +634,11 @@ export default class AuthService { postLoginError: null, }; - const { detail: postLoginDetail, completionPromise, relaysReadyPromise } = this.applyPostLoginState({ + const { + detail: postLoginDetail, + completionPromise, + relaysReadyPromise, + } = this.applyPostLoginState({ deferBlocks: true, }); detail.postLogin = postLoginDetail; @@ -621,7 +652,10 @@ export default class AuthService { try { this.emitProfileList("post-login"); } catch (error) { - this.log("[AuthService] Failed to emit profile list after login", error); + this.log( + "[AuthService] Failed to emit profile list after login", + error, + ); } this.emit("auth:post-login", { @@ -916,7 +950,7 @@ export default class AuthService { const normalizedOptions = options && typeof options === "object" ? { ...options } : {}; - const providerId = this.normalizeProviderId(normalizedOptions.providerId); + const providerId = normalizeProviderId(normalizedOptions.providerId); const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey()); if (normalizedActive && normalizedActive === normalizedTarget) { diff --git a/js/services/authUtils.js b/js/services/authUtils.js new file mode 100644 index 00000000..ef537fcb --- /dev/null +++ b/js/services/authUtils.js @@ -0,0 +1,53 @@ + +export const FALLBACK_PROFILE = { + name: "Unknown", + picture: "assets/svg/default-profile.svg", + about: "", + website: "", + banner: "", + lud16: "", + lud06: "", +}; + +export const FAST_PROFILE_RELAY_LIMIT = 3; +export const FAST_PROFILE_TIMEOUT_MS = 2500; +export const BACKGROUND_PROFILE_TIMEOUT_MS = 6000; + +export function normalizeProviderId(providerId) { + return typeof providerId === "string" && providerId.trim() + ? providerId.trim() + : "nip07"; +} + +export function normalizeAuthType(authTypeCandidate, providerId, providerResult) { + const candidates = []; + + if (typeof authTypeCandidate === "string") { + candidates.push(authTypeCandidate); + } + + if (providerResult && typeof providerResult === "object") { + const resultAuthType = providerResult.authType; + if (typeof resultAuthType === "string") { + candidates.push(resultAuthType); + } + + const resultProviderId = providerResult.providerId; + if (typeof resultProviderId === "string") { + candidates.push(resultProviderId); + } + } + + if (typeof providerId === "string") { + candidates.push(providerId); + } + + for (const candidate of candidates) { + const trimmed = candidate.trim(); + if (trimmed) { + return trimmed; + } + } + + return "nip07"; +} diff --git a/js/utils/simpleEventEmitter.js b/js/utils/simpleEventEmitter.js new file mode 100644 index 00000000..d7ef18d1 --- /dev/null +++ b/js/utils/simpleEventEmitter.js @@ -0,0 +1,40 @@ +export class SimpleEventEmitter { + constructor(logger = null) { + this.logger = typeof logger === "function" ? logger : null; + this.listeners = new Map(); + } + + on(eventName, handler) { + if (typeof handler !== "function") { + return () => {}; + } + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + const handlers = this.listeners.get(eventName); + handlers.add(handler); + return () => { + handlers.delete(handler); + if (!handlers.size) { + this.listeners.delete(eventName); + } + }; + } + + emit(eventName, detail) { + const handlers = this.listeners.get(eventName); + if (!handlers || !handlers.size) { + return; + } + + for (const handler of Array.from(handlers)) { + try { + handler(detail); + } catch (error) { + if (this.logger) { + this.logger(`Listener for "${eventName}" threw`, error); + } + } + } + } +}