From 07169dfee6c7c3ab3d02e724bf937683affc1ced Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:01:50 +0200 Subject: [PATCH] Add mobile app auth flow with Sanctum token handoff via deep link The Einundzwanzig mobile app opens /auth/mobile in an in-app browser. After a Lightning (LNURL) or Nostr login the flow issues a personal access token and hands it back via the einundzwanzig://auth deep link. - New auth.mobile-login Livewire view: Lightning QR (shared k1) plus Nostr signing via NIP-55 Android signers (Amber) with server callback, and a confirmation screen for already authenticated sessions - MobileAuthController: NIP-55 callback verification, completion route issuing the token (replacing same-device tokens), redirect whitelist - Nostr login event verification and npub user resolution extracted to App\Support\NostrLogin, now shared with the interactive login - GET /api/user (auth:sanctum) returns the token owner's profile --- app/Http/Controllers/Api/UserController.php | 33 +++ app/Http/Controllers/MobileAuthController.php | 145 +++++++++++ app/Support/NostrLogin.php | 116 +++++++++ resources/views/livewire/auth/login.blade.php | 79 +----- .../livewire/auth/mobile-login.blade.php | 231 ++++++++++++++++++ routes/api.php | 9 + routes/auth.php | 19 ++ tests/Feature/Auth/MobileAuthTest.php | 151 ++++++++++++ 8 files changed, 710 insertions(+), 73 deletions(-) create mode 100644 app/Http/Controllers/Api/UserController.php create mode 100644 app/Http/Controllers/MobileAuthController.php create mode 100644 app/Support/NostrLogin.php create mode 100644 resources/views/livewire/auth/mobile-login.blade.php create mode 100644 tests/Feature/Auth/MobileAuthTest.php diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php new file mode 100644 index 0000000..db0c0ef --- /dev/null +++ b/app/Http/Controllers/Api/UserController.php @@ -0,0 +1,33 @@ +user(); + + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'nostr' => $user->nostr, + 'is_lecturer' => (bool) $user->is_lecturer, + 'is_leader' => (bool) $user->is_leader, + 'avatar' => $user->profile_photo_url, + ]); + } +} diff --git a/app/Http/Controllers/MobileAuthController.php b/app/Http/Controllers/MobileAuthController.php new file mode 100644 index 0000000..a1e69e2 --- /dev/null +++ b/app/Http/Controllers/MobileAuthController.php @@ -0,0 +1,145 @@ + */ + 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']); + } + + /** + * 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); + } + + /** + * 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 + { + $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 redirect()->away($redirectUri.'?token='.urlencode($token->plainTextToken)); + } +} diff --git a/app/Support/NostrLogin.php b/app/Support/NostrLogin.php new file mode 100644 index 0000000..d400253 --- /dev/null +++ b/app/Support/NostrLogin.php @@ -0,0 +1,116 @@ + __('auth.failed')]); + } + + $required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']; + foreach ($required as $key) { + if (! array_key_exists($key, $signedEvent)) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + } + + if ((int) $signedEvent['kind'] !== 22242) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + if ($expectedChallenge === '') { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $challengeFromEvent = null; + foreach ($signedEvent['tags'] as $tag) { + if (is_array($tag) && ($tag[0] ?? null) === 'challenge') { + $challengeFromEvent = (string) ($tag[1] ?? ''); + break; + } + } + + if ($challengeFromEvent === null || ! hash_equals($expectedChallenge, $challengeFromEvent)) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $createdAt = (int) $signedEvent['created_at']; + if (abs(now()->timestamp - $createdAt) > self::CHALLENGE_TTL_SECONDS) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $eventJson = json_encode([ + 'id' => (string) $signedEvent['id'], + 'pubkey' => (string) $signedEvent['pubkey'], + 'created_at' => $createdAt, + 'kind' => 22242, + 'tags' => $signedEvent['tags'], + 'content' => (string) $signedEvent['content'], + 'sig' => (string) $signedEvent['sig'], + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $isValid = false; + try { + $isValid = (new NostrEvent)->verify($eventJson); + } catch (\Throwable) { + $isValid = false; + } + + if (! $isValid) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + return (new NostrKey)->convertPublicKeyToBech32((string) $signedEvent['pubkey']); + } + + /** + * Find an existing user by npub or create a fresh account for it, + * mirroring the LNURL auto-registration behaviour. + */ + public static function findOrCreateUser(string $npub): User + { + $user = User::query()->where('nostr', $npub)->first(); + + if ($user) { + return $user; + } + + return User::create([ + 'public_key' => null, + 'is_lecturer' => true, + 'name' => str()->random(10), + 'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space', + 'nostr' => $npub, + 'lnbits' => [ + 'read_key' => null, + 'url' => null, + 'wallet_id' => null, + ], + ]); + } +} diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 697fae9..c7487aa 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -3,7 +3,7 @@ use App\Attributes\SeoDataAttribute; use App\Jobs\FetchNostrProfileJob; use App\Models\LoginKey; -use App\Models\User; +use App\Support\NostrLogin; use App\Traits\SeoTrait; use eza\lnurl; use Illuminate\Auth\Events\Lockout; @@ -18,8 +18,6 @@ use Livewire\Attributes\On; use Livewire\Attributes\Validate; use Livewire\Component; use SimpleSoftwareIO\QrCode\Facades\QrCode; -use swentel\nostr\Event\Event as NostrEvent; -use swentel\nostr\Key\Key as NostrKey; new #[Layout('components.layouts.auth')] class extends Component { @@ -48,7 +46,7 @@ class extends Component { #[Locked] public ?string $nostrChallenge = null; - private const NOSTR_CHALLENGE_TTL_SECONDS = 300; + private const NOSTR_CHALLENGE_TTL_SECONDS = NostrLogin::CHALLENGE_TTL_SECONDS; /** * Handle authError property type conversion. @@ -120,22 +118,8 @@ class extends Component { { $npub = $this->verifyNostrLoginEvent($signedEvent); - $user = User::query()->where('nostr', $npub)->first(); - if (!$user) { - $fakeName = str()->random(10); - $user = User::create([ - 'public_key' => null, - 'is_lecturer' => true, - 'name' => $fakeName, - 'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space', - 'nostr' => $npub, - 'lnbits' => [ - 'read_key' => null, - 'url' => null, - 'wallet_id' => null, - ], - ]); - } + $user = NostrLogin::findOrCreateUser($npub); + FetchNostrProfileJob::dispatch($user); // Auth::loginUsingId() already regenerates the session id (see // SessionGuard::updateSession), so an explicit Session::regenerate() @@ -187,21 +171,6 @@ class extends Component { */ protected function verifyNostrLoginEvent(mixed $signedEvent): string { - if (!is_array($signedEvent)) { - throw ValidationException::withMessages(['email' => __('auth.failed')]); - } - - $required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']; - foreach ($required as $key) { - if (!array_key_exists($key, $signedEvent)) { - throw ValidationException::withMessages(['email' => __('auth.failed')]); - } - } - - if ((int) $signedEvent['kind'] !== 22242) { - throw ValidationException::withMessages(['email' => __('auth.failed')]); - } - $expectedChallenge = Session::get('nostr_login_challenge'); $expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0); @@ -210,47 +179,11 @@ class extends Component { throw ValidationException::withMessages(['email' => __('auth.failed')]); } - $challengeFromEvent = null; - foreach ($signedEvent['tags'] as $tag) { - if (is_array($tag) && ($tag[0] ?? null) === 'challenge') { - $challengeFromEvent = (string) ($tag[1] ?? ''); - break; - } - } - - if ($challengeFromEvent === null || !hash_equals($expectedChallenge, $challengeFromEvent)) { - throw ValidationException::withMessages(['email' => __('auth.failed')]); - } - - $createdAt = (int) $signedEvent['created_at']; - if (abs(now()->timestamp - $createdAt) > self::NOSTR_CHALLENGE_TTL_SECONDS) { - throw ValidationException::withMessages(['email' => __('auth.failed')]); - } - - $eventJson = json_encode([ - 'id' => (string) $signedEvent['id'], - 'pubkey' => (string) $signedEvent['pubkey'], - 'created_at' => $createdAt, - 'kind' => 22242, - 'tags' => $signedEvent['tags'], - 'content' => (string) $signedEvent['content'], - 'sig' => (string) $signedEvent['sig'], - ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - - $isValid = false; - try { - $isValid = (new NostrEvent())->verify($eventJson); - } catch (\Throwable $e) { - $isValid = false; - } - - if (!$isValid) { - throw ValidationException::withMessages(['email' => __('auth.failed')]); - } + $npub = NostrLogin::verifyEvent($signedEvent, $expectedChallenge); Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']); - return (new NostrKey())->convertPublicKeyToBech32((string) $signedEvent['pubkey']); + return $npub; } /** diff --git a/resources/views/livewire/auth/mobile-login.blade.php b/resources/views/livewire/auth/mobile-login.blade.php new file mode 100644 index 0000000..ca3ee93 --- /dev/null +++ b/resources/views/livewire/auth/mobile-login.blade.php @@ -0,0 +1,231 @@ +query('redirect_uri', MobileAuthController::ALLOWED_REDIRECT_URIS[0]); + + abort_unless( + in_array($redirectUri, MobileAuthController::ALLOWED_REDIRECT_URIS, true), + 403, + 'Invalid redirect_uri', + ); + + $this->redirectUri = $redirectUri; + $this->deviceName = str((string) request()->query('device_name', MobileAuthController::DEFAULT_DEVICE_NAME)) + ->limit(64, '') + ->whenEmpty(fn () => str(MobileAuthController::DEFAULT_DEVICE_NAME)) + ->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([ + 'mobile_auth' => [ + 'redirect_uri' => $this->redirectUri, + 'device_name' => $this->deviceName, + ], + ]); + + if (auth()->check()) { + return; + } + + $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. + */ + protected function initChallenge(): void + { + $this->k1 = bin2hex(str()->random(32)); + + if (app()->environment('local')) { + $url = 'https://mmy4dp8eab.sharedwithexpose.com/api/lnurl-auth-callback?tag=login&k1='.$this->k1.'&action=login'; + } else { + $url = url('/api/lnurl-auth-callback?tag=login&k1='.$this->k1.'&action=login'); + } + + $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='; + + $image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg'; + if (! file_exists(base_path($image))) { + $image = 'public/img/domains/de-DE.jpg'; + } + + $this->qrCode = base64_encode(QrCode::format('png') + ->size(300) + ->merge('/'.$image, .3) + ->errorCorrection('H') + ->generate($this->lnurl)); + } + + public function checkAuth(): void + { + $loginKey = LoginKey::query() + ->where('k1', $this->k1) + ->where('created_at', '>=', now()->subMinutes(5)) + ->first(); + + if (! $loginKey) { + return; + } + + // Same handoff pattern as the Lightning login: navigate via the + // client instead of redirecting from inside wire:poll, so a stray + // poll tick can't race the completion request. + $this->dispatch( + 'mobile-login-ready', + url: route('auth.mobile.complete', ['k1' => $this->k1]), + ); + } + + public function resetAuth(): void + { + $this->initChallenge(); + } + + public function switchAccount(): void + { + auth()->guard('web')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + $this->redirect(route('auth.mobile', [ + 'redirect_uri' => $this->redirectUri, + 'device_name' => $this->deviceName, + ])); + } +}; +?> + +