From c30f1932e4e153e8b78bb421f1519251ba38b319 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:47:02 +0200 Subject: [PATCH] Use window.nostr (NIP-46/Amber bunker) on the mobile login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fragile NIP-55 intent/callback round-trip with the same mechanism the desktop login uses: openNostrLogin signs the session challenge via window.nostr — provided by an extension or by window.nostr.js over a persistent NIP-46 connection (Amber pairing with permissions). The listener stores a LoginKey for the page's k1 and navigates to the completion route, which issues the token and redirects into the app via the verified App Link handoff. --- .../livewire/auth/mobile-login.blade.php | 138 ++++++++++++------ tests/Feature/Auth/MobileAuthTest.php | 21 +++ 2 files changed, 118 insertions(+), 41 deletions(-) diff --git a/resources/views/livewire/auth/mobile-login.blade.php b/resources/views/livewire/auth/mobile-login.blade.php index c954ec1..803f0b0 100644 --- a/resources/views/livewire/auth/mobile-login.blade.php +++ b/resources/views/livewire/auth/mobile-login.blade.php @@ -1,11 +1,16 @@ query('redirect_uri', MobileAuthController::ALLOWED_REDIRECT_URIS[0]); @@ -45,8 +51,8 @@ class extends Component { ->value(); // The completion/confirm controller reads the flow state from the - // session — the wallet/signer callback arrives outside this session, - // so it can't carry the redirect target itself. + // session — the wallet callback arrives outside this session, so it + // can't carry the redirect target itself. session([ 'mobile_auth' => [ 'redirect_uri' => $this->redirectUri, @@ -58,14 +64,15 @@ class extends Component { return; } + $this->issueNostrChallenge(); $this->initChallenge(); } /** - * Generate a fresh k1 challenge shared by both login methods: the - * Lightning wallet signs it via LNURL-auth, the Nostr signer puts it - * into the challenge tag of a kind-22242 event. Whichever callback - * verifies first stores the LoginKey row that checkAuth polls for. + * Generate a fresh k1 challenge for the LNURL flow. The Lightning + * wallet signs it via LNURL-auth; the resulting LoginKey row is picked + * up by checkAuth() below. The Nostr flow reuses the same row keyed by + * the same k1 (see loginListener). */ protected function initChallenge(): void { @@ -79,11 +86,6 @@ class extends Component { $this->lnurl = lnurl\encodeUrl($url); - // NIP-55 signers append the signed event JSON to the callback URL. - // Amber strips query strings when rebuilding the URL, so the k1 - // travels in the path and the event lands after the trailing slash. - $this->signerCallbackUrl = url('/auth/mobile/signed').'/'.$this->k1.'/'; - $image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg'; if (! file_exists(base_path($image))) { $image = 'public/img/domains/de-DE.jpg'; @@ -96,6 +98,65 @@ class extends Component { ->generate($this->lnurl)); } + /** + * Session-bound challenge for the window.nostr (NIP-07/NIP-46) login — + * identical mechanism to the regular login component. + */ + 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(NostrLogin::CHALLENGE_TTL_SECONDS)->timestamp); + + return $challenge; + } + + public function requestNostrChallenge(): string + { + return $this->issueNostrChallenge(); + } + + /** + * window.nostr signed the kind-22242 challenge (via extension or a + * NIP-46 connection to e.g. Amber). Verify it, store the LoginKey and + * hand the navigation to the completion route, which issues the token + * and redirects into the app via the verified App Link. + */ + #[On('nostrLoggedIn')] + public function loginListener($signedEvent = null): void + { + $npub = $this->verifyNostrLoginEvent($signedEvent); + + $user = NostrLogin::findOrCreateUser($npub); + + FetchNostrProfileJob::dispatch($user); + + LoginKey::query()->updateOrCreate( + ['k1' => $this->k1], + ['user_id' => $user->id], + ); + + $this->redirect(route('auth.mobile.complete', ['k1' => $this->k1])); + } + + protected function verifyNostrLoginEvent(mixed $signedEvent): string + { + $expectedChallenge = Session::get('nostr_login_challenge'); + $expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0); + + if (! is_string($expectedChallenge) || $expectedChallenge === '' || $expiresAt < now()->timestamp) { + Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']); + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $npub = NostrLogin::verifyEvent($signedEvent, $expectedChallenge); + + Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']); + + return $npub; + } + public function checkAuth(): void { $loginKey = LoginKey::query() @@ -118,6 +179,7 @@ class extends Component { public function resetAuth(): void { + $this->issueNostrChallenge(); $this->initChallenge(); } @@ -136,8 +198,9 @@ class extends Component { ?>
+ x-data="nostrLogin" + data-nostr-challenge="{{ $nostrChallenge ?? '' }}" + @mobile-login-ready.window="lightningLoginInProgress = true; window.location.href = $event.detail.url">
@@ -169,28 +232,21 @@ class extends Component { @else {{ __('Anmelden für die App') }} - -
- - {{ __('Log in mit Nostr (Amber)') }} - -
+ {{-- Nostr via window.nostr: extension or NIP-46 remote signer + (Amber via bunker/nostrconnect — the window.nostr.js + widget handles pairing and persists the connection). --}} + + {{ __('Log in mit Nostr') }} + + +
{{ __('Login with lightning ⚡') }} @@ -217,13 +273,13 @@ class extends Component {
- {{-- Poll for the LoginKey row written by either callback. - Paused while the completion navigation is in flight. --}} -