Files
einundzwanzig-app/app/Http/Controllers/MobileAuthController.php
T
HolgerHatGarKeineNode 4aba1514e9 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.
2026-06-11 18:43:59 +02:00

206 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Jobs\FetchNostrProfileJob;
use App\Models\LoginKey;
use App\Models\User;
use App\Support\NostrLogin;
use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
* Auth flow for the Einundzwanzig mobile app.
*
* The app opens /auth/mobile in an in-app browser. After a successful
* Lightning (LNURL) or Nostr (NIP-55/Amber) login the flow issues a
* Sanctum personal access token and hands it back to the app via the
* einundzwanzig:// deep link.
*/
final class MobileAuthController extends Controller
{
/** @var list<string> */
public const ALLOWED_REDIRECT_URIS = ['einundzwanzig://auth'];
public const DEFAULT_DEVICE_NAME = 'Einundzwanzig Mobile App';
/**
* Handle the NIP-55 signer callback (e.g. Amber on Android).
*
* The mobile login page opens a nostrsigner: URL containing an unsigned
* kind-22242 event whose challenge tag carries the page's k1. The signer
* appends the signed event JSON to this callback URL. On success a
* LoginKey row is stored so the polling login page can complete the flow.
*/
#[ExcludeRouteFromDocs]
public function nostrCallback(Request $request): JsonResponse
{
$k1 = (string) $request->query('k1', '');
if (! ctype_xdigit($k1) || strlen($k1) !== 64) {
return response()->json(['status' => 'ERROR', 'reason' => 'Invalid k1'], 400);
}
$signedEvent = json_decode((string) $request->query('event', ''), true);
try {
$npub = NostrLogin::verifyEvent($signedEvent, $k1);
} catch (ValidationException) {
Log::warning('Mobile Nostr auth verification failed', [
'k1' => $k1,
'ip' => $request->ip(),
]);
return response()->json(['status' => 'ERROR', 'reason' => 'Signature was NOT VERIFIED'], 400);
}
$user = NostrLogin::findOrCreateUser($npub);
FetchNostrProfileJob::dispatch($user);
LoginKey::query()->updateOrCreate(
['k1' => $k1],
['user_id' => $user->id],
);
Log::info('Mobile Nostr auth successful', [
'user_id' => $user->id,
'ip' => $request->ip(),
]);
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
* component once wire:poll detects readiness. Issues the token and
* redirects into the app via the deep link.
*/
public function complete(Request $request, string $k1): RedirectResponse
{
$loginKey = LoginKey::query()
->where('k1', $k1)
->where('created_at', '>=', now()->subSeconds(NostrLogin::CHALLENGE_TTL_SECONDS))
->first();
if (! $loginKey) {
return redirect()->route('auth.mobile');
}
$user = User::find($loginKey->user_id);
if (! $user) {
return redirect()->route('auth.mobile');
}
return $this->issueTokenAndRedirect($user, $request);
}
/**
* Connect the app for a user who is already authenticated in the
* in-app browser session (confirmation button on /auth/mobile).
*/
public function confirm(Request $request): RedirectResponse
{
return $this->issueTokenAndRedirect($request->user(), $request);
}
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', []);
$redirectUri = $mobileAuth['redirect_uri'] ?? self::ALLOWED_REDIRECT_URIS[0];
if (! in_array($redirectUri, self::ALLOWED_REDIRECT_URIS, true)) {
$redirectUri = self::ALLOWED_REDIRECT_URIS[0];
}
$deviceName = str((string) ($mobileAuth['device_name'] ?? self::DEFAULT_DEVICE_NAME))
->limit(64, '')
->whenEmpty(fn () => str(self::DEFAULT_DEVICE_NAME))
->value();
$user->tokens()->where('name', $deviceName)->delete();
$token = $user->createToken($deviceName);
Log::info('Mobile app token issued', [
'user_id' => $user->id,
'device_name' => $deviceName,
]);
return $redirectUri.'?token='.urlencode($token->plainTextToken);
}
}