From 2efc88a7f804759c5d4aba225173c2105bebd5ff Mon Sep 17 00:00:00 2001 From: BT Date: Sun, 3 May 2026 23:53:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20**Nostr=20Login:**=20Added=20server?= =?UTF-8?q?-side=20fallback=20for=20fresh=20challenges=20and=20improved=20?= =?UTF-8?q?client-side=20challenge=20resolution.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🔄 `requestNostrChallenge` now issues a new challenge when needed. - 🛡️ Enhanced fallback logic in `nostrLogin.js` to ensure robust challenge retrieval. - ✅ Added test coverage for fresh challenge issuance. --- resources/js/nostrLogin.js | 40 +++++++++++++++---- resources/views/livewire/auth/login.blade.php | 33 +++++++++++++-- tests/Feature/Auth/NostrLoginTest.php | 17 ++++++++ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/resources/js/nostrLogin.js b/resources/js/nostrLogin.js index 9a55352..88d7563 100644 --- a/resources/js/nostrLogin.js +++ b/resources/js/nostrLogin.js @@ -10,15 +10,39 @@ export default () => ({ this.startTime = Date.now(); }, - async openNostrLogin() { - const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire; - const rawChallenge = livewireComponent?.$wire?.nostrChallenge; + async resolveChallenge() { + // 1) Prefer the data-attribute rendered straight from Blade. This avoids + // Livewire's $wire proxy entirely and survives any reactive snapshot + // quirks that have surfaced behind HTTP caches on production. + const fromDataset = this.$root?.dataset?.nostrChallenge; + if (typeof fromDataset === 'string' && fromDataset !== '') { + return fromDataset; + } - // Livewire's $wire proxy returns a function (server-action fallback) when - // a property is missing from the snapshot. Only accept a non-empty string. - const challenge = typeof rawChallenge === 'string' && rawChallenge !== '' - ? rawChallenge - : null; + // 2) Fallback to the Livewire snapshot via $wire. + const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire; + const fromWire = livewireComponent?.$wire?.nostrChallenge; + if (typeof fromWire === 'string' && fromWire !== '') { + return fromWire; + } + + // 3) Last resort: ask the server for a freshly issued challenge. + if (livewireComponent?.$wire?.requestNostrChallenge) { + try { + const refreshed = await livewireComponent.$wire.requestNostrChallenge(); + if (typeof refreshed === 'string' && refreshed !== '') { + return refreshed; + } + } catch (error) { + console.error('requestNostrChallenge failed:', error); + } + } + + return null; + }, + + async openNostrLogin() { + const challenge = await this.resolveChallenge(); if (!challenge) { this.showAuthError('Login challenge missing. Please reload and try again.'); diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index d2148ee..0c3aece 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -61,13 +61,37 @@ class extends Component { } } + /** + * Generate a fresh Nostr login challenge, persist it to the session, and + * return the value. Used both during mount() and as a JS-driven fallback + * (see requestNostrChallenge()) when the rendered challenge is missing + * on the client — e.g. behind an HTTP cache that stripped the snapshot. + */ + protected function issueNostrChallenge(): string + { + $challenge = bin2hex(random_bytes(32)); + $this->nostrChallenge = $challenge; + Session::put('nostr_login_challenge', $challenge); + Session::put('nostr_login_challenge_expires_at', now()->addSeconds(self::NOSTR_CHALLENGE_TTL_SECONDS)->timestamp); + + return $challenge; + } + + /** + * Server-side fallback for the JS layer: returns a fresh challenge. + * Always issues a new one so a stale rendered snapshot can be recovered + * without forcing the user to reload the page. + */ + public function requestNostrChallenge(): string + { + return $this->issueNostrChallenge(); + } + public function mount(): void { $this->currentLangCountry = session('lang_country') ?? 'de-DE'; - $this->nostrChallenge = bin2hex(random_bytes(32)); - Session::put('nostr_login_challenge', $this->nostrChallenge); - Session::put('nostr_login_challenge_expires_at', now()->addSeconds(self::NOSTR_CHALLENGE_TTL_SECONDS)->timestamp); + $this->issueNostrChallenge(); // Nur beim ersten Mount initialisieren if ($this->k1 === null) { @@ -306,7 +330,8 @@ class extends Component { ?>
+ x-init="initErrorPolling" + data-nostr-challenge="{{ $nostrChallenge ?? '' }}">
diff --git a/tests/Feature/Auth/NostrLoginTest.php b/tests/Feature/Auth/NostrLoginTest.php index 9269d74..ab3169d 100644 --- a/tests/Feature/Auth/NostrLoginTest.php +++ b/tests/Feature/Auth/NostrLoginTest.php @@ -66,6 +66,23 @@ it('creates a new user and dispatches FetchNostrProfileJob when an unknown pubke expect(auth()->id())->toBe($user->id); }); +it('issues a fresh challenge when requestNostrChallenge is called and the same value is verifiable', function () { + $component = Livewire::test('auth.login'); + + $initial = Session::get('nostr_login_challenge'); + expect($initial)->toBeString()->not->toBe(''); + + $component->call('requestNostrChallenge'); + + $refreshed = Session::get('nostr_login_challenge'); + expect($refreshed) + ->toBeString() + ->not->toBe('') + ->not->toBe($initial); + + $component->assertSet('nostrChallenge', $refreshed); +}); + it('logs in an existing user without creating a duplicate when their pubkey is already known', function () { Queue::fake();