From 7531f28f224f4ed0f34445a72be47c98531eeaa2 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:51:14 +0200 Subject: [PATCH] Add verified App Link handoff and mobile token exchange endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the custom-scheme auto-redirect (which triggers Chrome's confirmation prompt) with a verified Android App Link handoff: - public/.well-known/assetlinks.json for space.einundzwanzig.mobile (debug cert fingerprint; add the release cert before store builds) - GET /app/auth handoff: opens the app directly when the App Link is verified; renders a button-based fallback page otherwise - POST /api/mobile/token: trades a NIP-55-signed login event for a Sanctum token — used when Amber's callback opens the app directly - complete/confirm/signedCallback now redirect to the handoff URL --- app/Http/Controllers/MobileAuthController.php | 103 +++++++++++++++--- public/.well-known/assetlinks.json | 12 ++ ...bridge.blade.php => app-handoff.blade.php} | 9 +- routes/api.php | 7 ++ routes/auth.php | 9 +- tests/Feature/Auth/MobileAuthTest.php | 47 +++++++- 6 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 public/.well-known/assetlinks.json rename resources/views/auth/{mobile-bridge.blade.php => app-handoff.blade.php} (73%) diff --git a/app/Http/Controllers/MobileAuthController.php b/app/Http/Controllers/MobileAuthController.php index 64d0ceb..46ee9bb 100644 --- a/app/Http/Controllers/MobileAuthController.php +++ b/app/Http/Controllers/MobileAuthController.php @@ -14,6 +14,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; +use Laravel\Sanctum\NewAccessToken; /** * Auth flow for the Einundzwanzig mobile app. @@ -76,6 +77,76 @@ final class MobileAuthController extends Controller 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, + ], + ]); + } + + /** + * 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. * @@ -125,9 +196,7 @@ final class MobileAuthController extends Controller 'ip' => $request->ip(), ]); - return view('auth.mobile-bridge', [ - 'deepLink' => $this->issueToken($user, $request), - ]); + return redirect()->to($this->issueToken($user, $request)); } /** @@ -171,22 +240,30 @@ final class MobileAuthController extends Controller } /** - * 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. + * Issue a personal access token for the session's device and build the + * app handoff URL. The handoff URL is a verified Android App Link, so + * navigating to it opens the app directly; its browser fallback page + * offers the einundzwanzig:// deep link behind a button. */ private function issueToken(User $user, Request $request): string { $mobileAuth = (array) $request->session()->pull('mobile_auth', []); - $redirectUri = $mobileAuth['redirect_uri'] ?? self::ALLOWED_REDIRECT_URIS[0]; + $deviceName = (string) ($mobileAuth['device_name'] ?? self::DEFAULT_DEVICE_NAME); - if (! in_array($redirectUri, self::ALLOWED_REDIRECT_URIS, true)) { - $redirectUri = self::ALLOWED_REDIRECT_URIS[0]; - } + $token = $this->createDeviceToken($user, $deviceName); - $deviceName = str((string) ($mobileAuth['device_name'] ?? self::DEFAULT_DEVICE_NAME)) + return route('auth.mobile.handoff').'?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(); @@ -200,6 +277,6 @@ final class MobileAuthController extends Controller 'device_name' => $deviceName, ]); - return $redirectUri.'?token='.urlencode($token->plainTextToken); + return $token; } } diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json new file mode 100644 index 0000000..746e73c --- /dev/null +++ b/public/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "space.einundzwanzig.mobile", + "sha256_cert_fingerprints": [ + "74:25:57:3B:24:69:97:97:45:8E:27:CC:1E:26:D7:A2:82:73:EC:BB:0D:B9:47:78:2A:18:B5:94:54:B0:79:ED" + ] + } + } +] diff --git a/resources/views/auth/mobile-bridge.blade.php b/resources/views/auth/app-handoff.blade.php similarity index 73% rename from resources/views/auth/mobile-bridge.blade.php rename to resources/views/auth/app-handoff.blade.php index 64c9096..0f392b9 100644 --- a/resources/views/auth/mobile-bridge.blade.php +++ b/resources/views/auth/app-handoff.blade.php @@ -16,14 +16,15 @@
+ {{-- No auto-redirect here: a JS navigation to a custom scheme triggers + Chrome's confirmation prompt. The button tap is a user gesture and + opens the app directly. With verified App Links this page never + renders — Android opens the app before the request is made. --}}{{ __('Du wirst jetzt zurück in die Einundzwanzig-App geleitet. Falls nichts passiert, tippe auf den Button.') }}
+{{ __('Tippe auf den Button, um zurück zur Einundzwanzig-App zu gelangen.') }}
{{ __('Zurück zur App') }}