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. --}}

{{ __('Login bestätigt') }}

-

{{ __('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') }}
- diff --git a/routes/api.php b/routes/api.php index 99b2501..814255b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -89,5 +89,12 @@ Route::get('/nostr-login-callback', [MobileAuthController::class, 'nostrCallback ->middleware('throttle:30,1') ->name('auth.nostr.callback'); +// Token exchange for the mobile app: trades a NIP-55-signed login event +// for a Sanctum personal access token (used when the signer callback +// opens the app directly via a verified App Link). +Route::post('/mobile/token', [MobileAuthController::class, 'token']) + ->middleware('throttle:30,1') + ->name('auth.mobile.token'); + Route::post('/check-auth-error', [LnurlAuthController::class, 'checkError']) ->name('auth.check-error'); diff --git a/routes/auth.php b/routes/auth.php index b5329b3..c2e91e2 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -50,12 +50,19 @@ Route::get('/auth/mobile/complete/{k1}', [MobileAuthController::class, 'complete ->name('auth.mobile.complete'); // NIP-55 signer callback (Amber): k1 in the path, the signer appends the -// URL-encoded signed event after the trailing slash. +// URL-encoded signed event after the trailing slash. With verified App +// Links this URL opens the app directly; this web route is the fallback. Route::get('/auth/mobile/signed/{payload}', [MobileAuthController::class, 'signedCallback']) ->where('payload', '.*') ->middleware('throttle:30,1') ->name('auth.mobile.signed'); +// App handoff: verified Android App Link — opens the app with the token. +// In the browser (unverified install) it renders a button-based fallback. +Route::get('/app/auth', [MobileAuthController::class, 'handoff']) + ->middleware('throttle:30,1') + ->name('auth.mobile.handoff'); + Route::post('/auth/mobile/confirm', [MobileAuthController::class, 'confirm']) ->middleware(['auth', 'throttle:30,1']) ->name('auth.mobile.confirm'); diff --git a/tests/Feature/Auth/MobileAuthTest.php b/tests/Feature/Auth/MobileAuthTest.php index aa1b105..13de07d 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 shows the bridge page', function () { +it('completes the login via the path-based signer callback and redirects to the app handoff', function () { Queue::fake(); $k1 = bin2hex(random_bytes(32)); @@ -98,7 +98,8 @@ it('completes the login via the path-based signer callback and shows the bridge ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']]) ->get('/auth/mobile/signed/'.$k1.'/'.rawurlencode(json_encode($signedEvent))); - $response->assertOk()->assertSee('einundzwanzig://auth?token=', false); + $location = $response->headers->get('Location'); + expect($location)->toStartWith(route('auth.mobile.handoff').'?token='); $user = User::query()->where('nostr', $npub)->first(); expect($user)->not->toBeNull(); @@ -109,6 +110,44 @@ it('completes the login via the path-based signer callback and shows the bridge 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) + ->assertOk() + ->assertSee('einundzwanzig://auth?token=', false); +}); + +it('exchanges a signed event for a token via the mobile token endpoint', function () { + Queue::fake(); + + $k1 = bin2hex(random_bytes(32)); + [$signedEvent, $npub] = makeSignedMobileNostrEvent($k1); + + $response = $this->postJson('/api/mobile/token', [ + 'k1' => $k1, + 'event' => $signedEvent, + 'device_name' => 'Pixel 10', + ]); + + $response->assertOk()->assertJsonStructure(['token', 'user' => ['id', 'name']]); + + $user = User::query()->where('nostr', $npub)->first(); + expect($user)->not->toBeNull() + ->and($user->tokens()->first()->name)->toBe('Pixel 10'); + + $this->getJson('/api/user', ['Authorization' => 'Bearer '.$response->json('token')]) + ->assertOk() + ->assertJsonPath('id', $user->id); +}); + +it('rejects a token exchange with a mismatched challenge', function () { + $k1 = bin2hex(random_bytes(32)); + [$signedEvent] = makeSignedMobileNostrEvent(bin2hex(random_bytes(32))); + + $this->postJson('/api/mobile/token', [ + 'k1' => $k1, + 'event' => $signedEvent, + ])->assertBadRequest(); }); it('rejects a path-based signer callback with a tampered challenge', function () { @@ -131,7 +170,7 @@ it('issues a token and redirects into the app when completing a verified login', ->get('/auth/mobile/complete/'.$k1); $location = $response->headers->get('Location'); - expect($location)->toStartWith('einundzwanzig://auth?token='); + expect($location)->toStartWith(route('auth.mobile.handoff').'?token='); $token = $user->tokens()->first(); expect($token)->not->toBeNull() @@ -158,7 +197,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('einundzwanzig://auth?token='); + expect($response->headers->get('Location'))->toStartWith(route('auth.mobile.handoff').'?token='); expect($user->tokens()->where('name', 'Pixel 10')->count())->toBe(1); });