diff --git a/app/Http/Controllers/MobileAuthController.php b/app/Http/Controllers/MobileAuthController.php index cf6f020..6ca30f8 100644 --- a/app/Http/Controllers/MobileAuthController.php +++ b/app/Http/Controllers/MobileAuthController.php @@ -9,8 +9,8 @@ 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\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; @@ -235,16 +235,15 @@ final class MobileAuthController extends Controller 'ip' => $request->ip(), ]); - return redirect()->to($this->issueToken($user, $request)); + 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. Issues the token and - * redirects into the app via the deep link. + * component once wire:poll detects readiness (Lightning flow). */ - public function complete(Request $request, string $k1): RedirectResponse + public function complete(Request $request, string $k1): mixed { $loginKey = LoginKey::query() ->where('k1', $k1) @@ -261,28 +260,36 @@ final class MobileAuthController extends Controller return redirect()->route('auth.mobile'); } - return $this->issueTokenAndRedirect($user, $request); + 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): RedirectResponse + public function confirm(Request $request): mixed { - return $this->issueTokenAndRedirect($request->user(), $request); + return $this->handoffResponse($request->user(), $request); } - private function issueTokenAndRedirect(User $user, Request $request): RedirectResponse + /** + * 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 redirect()->away($this->issueToken($user, $request)); + return view('auth.app-handoff', [ + 'deepLink' => $this->issueToken($user, $request), + ]); } /** * 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. + * einundzwanzig:// deep link that hands it to the app. */ private function issueToken(User $user, Request $request): string { @@ -292,7 +299,7 @@ final class MobileAuthController extends Controller $token = $this->createDeviceToken($user, $deviceName); - return route('auth.mobile.handoff').'?token='.urlencode($token->plainTextToken); + return self::ALLOWED_REDIRECT_URIS[0].'?token='.urlencode($token->plainTextToken); } /** diff --git a/tests/Feature/Auth/MobileAuthTest.php b/tests/Feature/Auth/MobileAuthTest.php index 13de07d..3b81635 100644 --- a/tests/Feature/Auth/MobileAuthTest.php +++ b/tests/Feature/Auth/MobileAuthTest.php @@ -86,7 +86,7 @@ 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 redirects to the app handoff', function () { +it('completes the login via the path-based signer callback and renders the app handoff', function () { Queue::fake(); $k1 = bin2hex(random_bytes(32)); @@ -98,8 +98,9 @@ it('completes the login via the path-based signer callback and redirects to the ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']]) ->get('/auth/mobile/signed/'.$k1.'/'.rawurlencode(json_encode($signedEvent))); - $location = $response->headers->get('Location'); - expect($location)->toStartWith(route('auth.mobile.handoff').'?token='); + // The signed callback renders the handoff page directly (no 302) so the + // user can tap the einundzwanzig:// deep-link button to return to the app. + $response->assertOk()->assertSee('einundzwanzig://auth?token=', false); $user = User::query()->where('nostr', $npub)->first(); expect($user)->not->toBeNull(); @@ -111,10 +112,11 @@ it('completes the login via the path-based signer callback and redirects to the expect(LoginKey::query()->where('k1', $k1)->exists())->toBeTrue(); Queue::assertPushed(FetchNostrProfileJob::class); - // The handoff fallback page offers the deep link behind a button. - $this->get($location) + // The deep-link token must authenticate API requests. + $plainTextToken = urldecode(str($response->getContent())->after('einundzwanzig://auth?token=')->before('"')->value()); + $this->getJson('/api/user', ['Authorization' => 'Bearer '.$plainTextToken]) ->assertOk() - ->assertSee('einundzwanzig://auth?token=', false); + ->assertJsonPath('id', $user->id); }); it('exchanges a signed event for a token via the mobile token endpoint', function () { @@ -160,7 +162,7 @@ it('rejects a path-based signer callback with a tampered challenge', function () expect(LoginKey::query()->where('k1', $k1)->exists())->toBeFalse(); }); -it('issues a token and redirects into the app when completing a verified login', function () { +it('issues a token and renders the handoff when completing a verified login', function () { $user = User::factory()->create(); $k1 = bin2hex(random_bytes(32)); LoginKey::query()->create(['k1' => $k1, 'user_id' => $user->id]); @@ -169,15 +171,14 @@ it('issues a token and redirects into the app when completing a verified login', ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']]) ->get('/auth/mobile/complete/'.$k1); - $location = $response->headers->get('Location'); - expect($location)->toStartWith(route('auth.mobile.handoff').'?token='); + $response->assertOk()->assertSee('einundzwanzig://auth?token=', false); $token = $user->tokens()->first(); expect($token)->not->toBeNull() ->and($token->name)->toBe('Pixel 10'); // The plain-text token handed to the app must authenticate API requests. - $plainTextToken = urldecode(str($location)->after('?token=')->value()); + $plainTextToken = urldecode(str($response->getContent())->after('einundzwanzig://auth?token=')->before('"')->value()); $this->getJson('/api/user', ['Authorization' => 'Bearer '.$plainTextToken]) ->assertOk() ->assertJsonPath('id', $user->id); @@ -197,7 +198,7 @@ it('lets an already authenticated user connect the app and replaces tokens of th ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']]) ->post('/auth/mobile/confirm'); - expect($response->headers->get('Location'))->toStartWith(route('auth.mobile.handoff').'?token='); + $response->assertOk()->assertSee('einundzwanzig://auth?token=', false); expect($user->tokens()->where('name', 'Pixel 10')->count())->toBe(1); });