mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-17 16:40:31 +00:00
76787a1bee
Chrome follows a server 302 internally and never dispatches the /app/auth App Link, so the handoff page stayed in the browser and the token never reached the app. The signed callback (and complete/confirm) now render the handoff page directly with the einundzwanzig:// deep-link button — the signer opens the callback in the browser, the user lands on the handoff page and taps once to return to the app, which stores the token.
329 lines
12 KiB
PHP
329 lines
12 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\Contracts\View\View;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Laravel\Sanctum\NewAccessToken;
|
|
|
|
/**
|
|
* 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']);
|
|
}
|
|
|
|
/**
|
|
* Exchange a NIP-55-signed login event for a personal access token.
|
|
*
|
|
* Used by the mobile app when the signer callback opens the app
|
|
* directly via a verified Android App Link: the app receives the
|
|
* signed event in the deep-link path and trades it in here.
|
|
*/
|
|
public function token(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'k1' => ['required', 'string', 'size:64'],
|
|
'event' => ['required', 'array'],
|
|
'device_name' => ['nullable', 'string', 'max:64'],
|
|
]);
|
|
|
|
if (! ctype_xdigit($validated['k1'])) {
|
|
return response()->json(['status' => 'ERROR', 'reason' => 'Invalid k1'], 400);
|
|
}
|
|
|
|
try {
|
|
$npub = NostrLogin::verifyEvent($validated['event'], $validated['k1']);
|
|
} catch (ValidationException) {
|
|
Log::warning('Mobile token exchange verification failed', [
|
|
'k1' => $validated['k1'],
|
|
'ip' => $request->ip(),
|
|
]);
|
|
|
|
return response()->json(['status' => 'ERROR', 'reason' => 'Signature was NOT VERIFIED'], 400);
|
|
}
|
|
|
|
$user = NostrLogin::findOrCreateUser($npub);
|
|
FetchNostrProfileJob::dispatch($user);
|
|
|
|
$token = $this->createDeviceToken($user, $validated['device_name'] ?? self::DEFAULT_DEVICE_NAME);
|
|
|
|
Log::info('Mobile app token issued via token exchange', [
|
|
'user_id' => $user->id,
|
|
]);
|
|
|
|
return response()->json([
|
|
'token' => $token->plainTextToken,
|
|
'user' => [
|
|
'id' => $user->id,
|
|
'name' => $user->name,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Headless Nostr launcher for the mobile app.
|
|
*
|
|
* The app opens this page in an in-app browser (Chrome Custom Tab). It
|
|
* immediately launches the NIP-55 signer (e.g. Amber) via window.location
|
|
* so the intent carries category.BROWSABLE — which routes Amber into its
|
|
* web-signing flow (a direct ACTION_VIEW intent without that category
|
|
* lands in Amber's app-to-app path and is rejected as malformed).
|
|
*
|
|
* The signer signs the kind-22242 challenge locally and opens the
|
|
* /auth/mobile/signed callback, which issues the token and hands it back
|
|
* to the app via the verified App Link. No relay, no visible login UI.
|
|
*/
|
|
public function nostrLauncher(Request $request)
|
|
{
|
|
$deviceName = str((string) $request->query('device_name', self::DEFAULT_DEVICE_NAME))
|
|
->limit(64, '')
|
|
->whenEmpty(fn () => str(self::DEFAULT_DEVICE_NAME))
|
|
->value();
|
|
|
|
// The signed callback issues the token and reads the device name
|
|
// from this session (the callback shares the Custom Tab's cookies).
|
|
$request->session()->put('mobile_auth', [
|
|
'redirect_uri' => self::ALLOWED_REDIRECT_URIS[0],
|
|
'device_name' => $deviceName,
|
|
]);
|
|
|
|
$k1 = bin2hex(random_bytes(32));
|
|
|
|
// The signer URI is assembled in the browser (see the view) with
|
|
// encodeURIComponent(JSON.stringify(event)) — the exact encoding
|
|
// Amber accepts. Building it server-side produced subtly different
|
|
// percent-encoding that Amber rejected as malformed.
|
|
return view('auth.mobile-nostr-launch', [
|
|
'k1' => $k1,
|
|
'callbackUrl' => url('/auth/mobile/signed/'.$k1.'/'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Browser fallback for the app handoff URL (/app/auth?token=…).
|
|
*
|
|
* When the Android App Link is verified, navigation to this URL opens
|
|
* the app directly and this route never renders. Without verification
|
|
* (e.g. sideloaded build) the page offers the einundzwanzig:// deep
|
|
* link behind a button — a user gesture, so Chrome opens the app
|
|
* without its confirmation prompt.
|
|
*/
|
|
public function handoff(Request $request)
|
|
{
|
|
$token = (string) $request->query('token', '');
|
|
|
|
if ($token === '') {
|
|
return redirect()->route('auth.mobile');
|
|
}
|
|
|
|
return view('auth.app-handoff', [
|
|
'deepLink' => self::ALLOWED_REDIRECT_URIS[0].'?token='.urlencode($token),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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 $this->handoffResponse($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 (Lightning flow).
|
|
*/
|
|
public function complete(Request $request, string $k1): mixed
|
|
{
|
|
$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->handoffResponse($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): mixed
|
|
{
|
|
return $this->handoffResponse($request->user(), $request);
|
|
}
|
|
|
|
/**
|
|
* Issue the token and render the handoff page, whose "back to app"
|
|
* button carries the einundzwanzig:// deep link. The page is rendered
|
|
* directly (rather than 302-redirecting to the /app/auth App Link)
|
|
* because Chrome follows a server redirect internally and never
|
|
* dispatches the App Link intent — the signed callback opens in the
|
|
* browser, so the user lands here and taps once to return to the app.
|
|
*/
|
|
private function handoffResponse(User $user, Request $request): View
|
|
{
|
|
return view('auth.app-handoff', [
|
|
'deepLink' => $this->issueToken($user, $request),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Issue a personal access token for the session's device and build the
|
|
* einundzwanzig:// deep link that hands it to the app.
|
|
*/
|
|
private function issueToken(User $user, Request $request): string
|
|
{
|
|
$mobileAuth = (array) $request->session()->pull('mobile_auth', []);
|
|
|
|
$deviceName = (string) ($mobileAuth['device_name'] ?? self::DEFAULT_DEVICE_NAME);
|
|
|
|
$token = $this->createDeviceToken($user, $deviceName);
|
|
|
|
return self::ALLOWED_REDIRECT_URIS[0].'?token='.urlencode($token->plainTextToken);
|
|
}
|
|
|
|
/**
|
|
* Create a personal access token named after the device. Existing
|
|
* tokens with the same device name are replaced so repeated logins
|
|
* don't accumulate tokens.
|
|
*/
|
|
private function createDeviceToken(User $user, string $deviceName): NewAccessToken
|
|
{
|
|
$deviceName = str($deviceName)
|
|
->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 $token;
|
|
}
|
|
}
|