mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-17 04:30:31 +00:00
Use window.nostr (NIP-46/Amber bunker) on the mobile login page
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.
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\MobileAuthController;
|
||||
use App\Jobs\FetchNostrProfileJob;
|
||||
use App\Models\LoginKey;
|
||||
use App\Support\NostrLogin;
|
||||
use App\Traits\SeoTrait;
|
||||
use eza\lnurl;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
@@ -22,12 +27,13 @@ class extends Component {
|
||||
#[Locked]
|
||||
public ?string $deviceName = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $nostrChallenge = null;
|
||||
|
||||
public ?string $lnurl = null;
|
||||
|
||||
public ?string $qrCode = null;
|
||||
|
||||
public ?string $signerCallbackUrl = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$redirectUri = (string) request()->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 {
|
||||
?>
|
||||
|
||||
<div class="flex min-h-screen justify-center"
|
||||
x-data="{ loginInProgress: false }"
|
||||
@mobile-login-ready.window="loginInProgress = true; window.location.href = $event.detail.url">
|
||||
x-data="nostrLogin"
|
||||
data-nostr-challenge="{{ $nostrChallenge ?? '' }}"
|
||||
@mobile-login-ready.window="lightningLoginInProgress = true; window.location.href = $event.detail.url">
|
||||
<div class="flex justify-center items-center px-4 py-8">
|
||||
<div class="w-80 max-w-80 space-y-6">
|
||||
<div class="flex justify-center">
|
||||
@@ -169,28 +232,21 @@ class extends Component {
|
||||
@else
|
||||
<flux:heading class="text-center" size="xl">{{ __('Anmelden für die App') }}</flux:heading>
|
||||
|
||||
<!-- Nostr via NIP-55 signer (e.g. Amber) -->
|
||||
<div x-data="{
|
||||
signWithSigner() {
|
||||
const event = {
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [['challenge', '{{ $k1 }}']],
|
||||
};
|
||||
window.location.href = 'nostrsigner:' + encodeURIComponent(JSON.stringify(event))
|
||||
+ '?compressionType=none&returnType=event&type=sign_event&appName=Einundzwanzig'
|
||||
+ '&callbackUrl=' + encodeURIComponent('{{ $signerCallbackUrl }}');
|
||||
}
|
||||
}">
|
||||
<flux:button variant="primary"
|
||||
@click="signWithSigner"
|
||||
icon="cursor-arrow-ripple"
|
||||
x-bind:disabled="loginInProgress"
|
||||
class="w-full cursor-pointer">
|
||||
{{ __('Log in mit Nostr (Amber)') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
{{-- 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). --}}
|
||||
<flux:button variant="primary"
|
||||
@click="openNostrLogin"
|
||||
icon="cursor-arrow-ripple"
|
||||
x-bind:disabled="nostrLoginInProgress"
|
||||
x-bind:aria-busy="nostrLoginInProgress"
|
||||
class="w-full cursor-pointer">
|
||||
<span x-show="!nostrLoginInProgress">{{ __('Log in mit Nostr') }}</span>
|
||||
<span x-show="nostrLoginInProgress" x-cloak class="inline-flex items-center gap-2">
|
||||
<flux:icon.arrow-path class="animate-spin size-4" aria-hidden="true"/>
|
||||
{{ __('Signiere…') }}
|
||||
</span>
|
||||
</flux:button>
|
||||
|
||||
<div class="text-center text-2xl text-gray-80 dark:text-gray-2000 mt-2">
|
||||
{{ __('Login with lightning ⚡') }}
|
||||
@@ -217,13 +273,13 @@ class extends Component {
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Poll for the LoginKey row written by either callback.
|
||||
Paused while the completion navigation is in flight. --}}
|
||||
<template x-if="!loginInProgress">
|
||||
{{-- Poll for the LoginKey row written by the LNURL callback.
|
||||
Paused while a login round-trip or navigation is in flight. --}}
|
||||
<template x-if="!nostrLoginInProgress && !lightningLoginInProgress">
|
||||
<div wire:poll.4s="checkAuth" wire:key="checkAuth"></div>
|
||||
</template>
|
||||
|
||||
<div x-show="loginInProgress" x-cloak class="flex items-center justify-center gap-2">
|
||||
<div x-show="lightningLoginInProgress" x-cloak class="flex items-center justify-center gap-2">
|
||||
<flux:icon.arrow-path class="animate-spin size-4" aria-hidden="true"/>
|
||||
<flux:text>{{ __('Anmeldung wird abgeschlossen…') }}</flux:text>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +201,27 @@ it('lets an already authenticated user connect the app and replaces tokens of th
|
||||
expect($user->tokens()->where('name', 'Pixel 10')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('completes a window.nostr login on the mobile page via LoginKey and the completion route', function () {
|
||||
Queue::fake();
|
||||
|
||||
$component = Livewire\Livewire::withQueryParams(['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10'])
|
||||
->test('auth.mobile-login');
|
||||
|
||||
// Helper from NostrLoginTest: signs the challenge stored in the session.
|
||||
[$signedEvent, $npub] = makeSignedNostrLoginEvent();
|
||||
|
||||
$k1 = $component->get('k1');
|
||||
|
||||
$component
|
||||
->dispatch('nostrLoggedIn', signedEvent: $signedEvent)
|
||||
->assertRedirect(route('auth.mobile.complete', ['k1' => $k1]));
|
||||
|
||||
$user = User::query()->where('nostr', $npub)->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect(LoginKey::query()->where('k1', $k1)->value('user_id'))->toBe($user->id);
|
||||
Queue::assertPushed(FetchNostrProfileJob::class);
|
||||
});
|
||||
|
||||
it('shows the confirmation screen instead of the login methods for authenticated users', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user