Make the NIP-55 signer callback robust against Amber URL rewriting

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.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-11 18:43:59 +02:00
parent 07169dfee6
commit 4aba1514e9
5 changed files with 141 additions and 8 deletions
+66 -6
View File
@@ -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);
}
}
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('Login bestätigt') }} Einundzwanzig</title>
<style>
body { margin: 0; min-height: 100dvh; display: flex; align-items: center; justify-content: center;
background: #09090b; color: #fafafa; font-family: ui-sans-serif, system-ui, sans-serif; }
.card { text-align: center; padding: 2rem; max-width: 22rem; }
.check { font-size: 3rem; }
h1 { font-size: 1.25rem; margin: 1rem 0 .5rem; }
p { color: #a1a1aa; line-height: 1.5; }
a.button { display: block; margin-top: 1.5rem; padding: .875rem 1.25rem; border-radius: .75rem;
background: #f7931a; color: #09090b; font-weight: 600; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<div class="check"></div>
<h1>{{ __('Login bestätigt') }}</h1>
<p>{{ __('Du wirst jetzt zurück in die Einundzwanzig-App geleitet. Falls nichts passiert, tippe auf den Button.') }}</p>
<a class="button" href="{{ $deepLink }}">{{ __('Zurück zur App') }}</a>
</div>
<script>
setTimeout(function () { window.location.href = @js($deepLink); }, 400);
</script>
</body>
</html>
@@ -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))) {
+7
View File
@@ -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');
+35
View File
@@ -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));