From 4aba1514e916d59fb3bff137cdcb2fdf1cdbdfb9 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:43:59 +0200 Subject: [PATCH] Make the NIP-55 signer callback robust against Amber URL rewriting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amber drops the query string when it rebuilds the callback URL and appends the signed event directly to the path. The mobile login page now hands out path-based callback URLs (/auth/mobile/signed/{k1}/) so the event arrives as the remainder of the path. The new callback runs in the web middleware group: the signer opens it in the system browser, which shares cookies with the in-app browser session, so the flow completes immediately — a bridge page issues the token and fires the einundzwanzig:// deep link. The LoginKey row is still written as a fallback for the polling login page. --- app/Http/Controllers/MobileAuthController.php | 72 +++++++++++++++++-- resources/views/auth/mobile-bridge.blade.php | 29 ++++++++ .../livewire/auth/mobile-login.blade.php | 6 +- routes/auth.php | 7 ++ tests/Feature/Auth/MobileAuthTest.php | 35 +++++++++ 5 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 resources/views/auth/mobile-bridge.blade.php diff --git a/app/Http/Controllers/MobileAuthController.php b/app/Http/Controllers/MobileAuthController.php index a1e69e2..64d0ceb 100644 --- a/app/Http/Controllers/MobileAuthController.php +++ b/app/Http/Controllers/MobileAuthController.php @@ -76,6 +76,60 @@ final class MobileAuthController extends Controller return response()->json(['status' => 'OK']); } + /** + * Handle the NIP-55 signer callback in its path-based form. + * + * Amber rebuilds the callback URL and drops its query string, so the + * mobile login page hands out callback URLs of the form + * /auth/mobile/signed/{k1}/ — the signer then appends the URL-encoded + * signed event, which arrives here as the rest of the path. Runs in the + * web middleware group: the signer opens the URL in the system browser, + * which shares cookies with the in-app browser session, so the flow + * state (device name, redirect target) is usually available and the + * login can complete right here via the deep link bridge page. + */ + public function signedCallback(Request $request, string $payload) + { + $k1 = substr($payload, 0, 64); + + if (strlen($payload) <= 64 || ! ctype_xdigit($k1)) { + return redirect()->route('auth.mobile'); + } + + $signedEvent = json_decode(ltrim(substr($payload, 64), '/'), true); + + try { + $npub = NostrLogin::verifyEvent($signedEvent, $k1); + } catch (ValidationException) { + Log::warning('Mobile Nostr auth verification failed (path callback)', [ + 'k1' => $k1, + 'ip' => $request->ip(), + ]); + + return redirect()->route('auth.mobile'); + } + + $user = NostrLogin::findOrCreateUser($npub); + FetchNostrProfileJob::dispatch($user); + + // Fallback for the polling login page in the in-app browser: if the + // deep link below doesn't fire (cookie isolation, blocked scheme), + // the original page still completes via checkAuth(). + LoginKey::query()->updateOrCreate( + ['k1' => $k1], + ['user_id' => $user->id], + ); + + Log::info('Mobile Nostr auth successful (path callback)', [ + 'user_id' => $user->id, + 'ip' => $request->ip(), + ]); + + return view('auth.mobile-bridge', [ + 'deepLink' => $this->issueToken($user, $request), + ]); + } + /** * Complete a mobile login after the wallet/signer callback has stored a * matching LoginKey row. Called as a full-page GET from the mobile login @@ -111,12 +165,18 @@ final class MobileAuthController extends Controller return $this->issueTokenAndRedirect($request->user(), $request); } - /** - * Issue a personal access token named after the device and hand it to - * the app via the whitelisted deep link. Existing tokens with the same - * device name are replaced so repeated logins don't accumulate tokens. - */ private function issueTokenAndRedirect(User $user, Request $request): RedirectResponse + { + return redirect()->away($this->issueToken($user, $request)); + } + + /** + * Issue a personal access token named after the device and build the + * whitelisted deep link that hands it to the app. Existing tokens with + * the same device name are replaced so repeated logins don't accumulate + * tokens. + */ + private function issueToken(User $user, Request $request): string { $mobileAuth = (array) $request->session()->pull('mobile_auth', []); @@ -140,6 +200,6 @@ final class MobileAuthController extends Controller 'device_name' => $deviceName, ]); - return redirect()->away($redirectUri.'?token='.urlencode($token->plainTextToken)); + return $redirectUri.'?token='.urlencode($token->plainTextToken); } } diff --git a/resources/views/auth/mobile-bridge.blade.php b/resources/views/auth/mobile-bridge.blade.php new file mode 100644 index 0000000..64c9096 --- /dev/null +++ b/resources/views/auth/mobile-bridge.blade.php @@ -0,0 +1,29 @@ + + + + + + {{ __('Login bestätigt') }} — Einundzwanzig + + + +
+
+

{{ __('Login bestätigt') }}

+

{{ __('Du wirst jetzt zurück in die Einundzwanzig-App geleitet. Falls nichts passiert, tippe auf den Button.') }}

+ {{ __('Zurück zur App') }} +
+ + + diff --git a/resources/views/livewire/auth/mobile-login.blade.php b/resources/views/livewire/auth/mobile-login.blade.php index ca3ee93..c954ec1 100644 --- a/resources/views/livewire/auth/mobile-login.blade.php +++ b/resources/views/livewire/auth/mobile-login.blade.php @@ -79,8 +79,10 @@ class extends Component { $this->lnurl = lnurl\encodeUrl($url); - // NIP-55 signers append the signed event JSON directly after "event=". - $this->signerCallbackUrl = url('/api/nostr-login-callback').'?k1='.$this->k1.'&event='; + // 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))) { diff --git a/routes/auth.php b/routes/auth.php index 954183a..b5329b3 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -49,6 +49,13 @@ Route::get('/auth/mobile/complete/{k1}', [MobileAuthController::class, 'complete ->middleware('throttle:30,1') ->name('auth.mobile.complete'); +// NIP-55 signer callback (Amber): k1 in the path, the signer appends the +// URL-encoded signed event after the trailing slash. +Route::get('/auth/mobile/signed/{payload}', [MobileAuthController::class, 'signedCallback']) + ->where('payload', '.*') + ->middleware('throttle:30,1') + ->name('auth.mobile.signed'); + Route::post('/auth/mobile/confirm', [MobileAuthController::class, 'confirm']) ->middleware(['auth', 'throttle:30,1']) ->name('auth.mobile.confirm'); diff --git a/tests/Feature/Auth/MobileAuthTest.php b/tests/Feature/Auth/MobileAuthTest.php index f0ddbce..aa1b105 100644 --- a/tests/Feature/Auth/MobileAuthTest.php +++ b/tests/Feature/Auth/MobileAuthTest.php @@ -86,6 +86,41 @@ it('rejects a nostr signer callback whose event is bound to a different challeng expect(LoginKey::query()->where('k1', $k1)->exists())->toBeFalse(); }); +it('completes the login via the path-based signer callback and shows the bridge page', function () { + Queue::fake(); + + $k1 = bin2hex(random_bytes(32)); + [$signedEvent, $npub] = makeSignedMobileNostrEvent($k1); + + // Amber appends the URL-encoded signed event after the trailing slash + // and drops any query string from the callback URL. + $response = $this + ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']]) + ->get('/auth/mobile/signed/'.$k1.'/'.rawurlencode(json_encode($signedEvent))); + + $response->assertOk()->assertSee('einundzwanzig://auth?token=', false); + + $user = User::query()->where('nostr', $npub)->first(); + expect($user)->not->toBeNull(); + + $token = $user->tokens()->first(); + expect($token)->not->toBeNull() + ->and($token->name)->toBe('Pixel 10'); + + expect(LoginKey::query()->where('k1', $k1)->exists())->toBeTrue(); + Queue::assertPushed(FetchNostrProfileJob::class); +}); + +it('rejects a path-based signer callback with a tampered challenge', function () { + $k1 = bin2hex(random_bytes(32)); + [$signedEvent] = makeSignedMobileNostrEvent(bin2hex(random_bytes(32))); + + $this->get('/auth/mobile/signed/'.$k1.'/'.rawurlencode(json_encode($signedEvent))) + ->assertRedirect(route('auth.mobile')); + + expect(LoginKey::query()->where('k1', $k1)->exists())->toBeFalse(); +}); + it('issues a token and redirects into the app when completing a verified login', function () { $user = User::factory()->create(); $k1 = bin2hex(random_bytes(32));