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, + ])); + } +}; +?> + +
+
+
+
+
+ +
+
+ + @auth + {{ __('Mit der App verbinden') }} + +
+ + + {{ __('Du bist als :name angemeldet. Möchtest du dieses Gerät (:device) mit deinem Konto verbinden?', ['name' => auth()->user()->name, 'device' => $deviceName]) }} + + +
+ @csrf + + {{ __('Verbinden') }} + +
+ + + {{ __('Mit anderem Konto anmelden') }} + +
+ @else + {{ __('Anmelden für die App') }} + + +
+ + {{ __('Log in mit Nostr (Amber)') }} + +
+ +
+ {{ __('Login with lightning ⚡') }} +
+ + + +
+ + {{ __('Mit Lightning-Wallet öffnen') }} + + + + {{ __('Neuen Code erzeugen') }} + +
+ + {{-- Poll for the LoginKey row written by either callback. + Paused while the completion navigation is in flight. --}} + + +
+
+ @endauth +
+
+
diff --git a/routes/api.php b/routes/api.php index 4aba8f0..99b2501 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,8 +10,10 @@ use App\Http\Controllers\Api\MeetupController; use App\Http\Controllers\Api\MeetupEventController; use App\Http\Controllers\Api\MeetupMapController; use App\Http\Controllers\Api\NostrPlebController; +use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\VenueController; use App\Http\Controllers\LnurlAuthController; +use App\Http\Controllers\MobileAuthController; use Illuminate\Support\Facades\Route; Route::middleware(['throttle:60,1']) @@ -39,6 +41,8 @@ Route::middleware(['throttle:60,1']) Route::middleware('auth:sanctum') ->as('api.') ->group(function () { + Route::get('user', UserController::class)->name('user'); + Route::post('courses', [CourseController::class, 'store']) ->name('courses.store'); Route::patch('courses/{course}', [CourseController::class, 'update']) @@ -80,5 +84,10 @@ Route::middleware('auth:sanctum') Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback']) ->name('auth.ln.callback'); +// NIP-55 signer callback (e.g. Amber) for the mobile auth flow. +Route::get('/nostr-login-callback', [MobileAuthController::class, 'nostrCallback']) + ->middleware('throttle:30,1') + ->name('auth.nostr.callback'); + Route::post('/check-auth-error', [LnurlAuthController::class, 'checkError']) ->name('auth.check-error'); diff --git a/routes/auth.php b/routes/auth.php index cd1a1fb..954183a 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Auth\VerifyEmailController; use App\Http\Controllers\LnurlAuthController; +use App\Http\Controllers\MobileAuthController; use App\Livewire\Actions\Logout; use Illuminate\Support\Facades\Route; @@ -34,5 +35,23 @@ Route::middleware('auth') ->name('password.confirm'); }); +/* + * Mobile app auth flow: works for guests (login via Lightning/Nostr) and + * for already authenticated users (confirmation screen), so it lives + * outside the guest group. + */ +Route::livewire('/auth/mobile', 'auth.mobile-login') + ->middleware('throttle:30,1') + ->name('auth.mobile'); + +Route::get('/auth/mobile/complete/{k1}', [MobileAuthController::class, 'complete']) + ->where('k1', '[a-f0-9]{64}') + ->middleware('throttle:30,1') + ->name('auth.mobile.complete'); + +Route::post('/auth/mobile/confirm', [MobileAuthController::class, 'confirm']) + ->middleware(['auth', 'throttle:30,1']) + ->name('auth.mobile.confirm'); + Route::post('logout', Logout::class) ->name('logout'); diff --git a/tests/Feature/Auth/MobileAuthTest.php b/tests/Feature/Auth/MobileAuthTest.php new file mode 100644 index 0000000..f0ddbce --- /dev/null +++ b/tests/Feature/Auth/MobileAuthTest.php @@ -0,0 +1,151 @@ +, 1: string} + */ +function makeSignedMobileNostrEvent(string $challenge): array +{ + $keyGen = new NostrKey; + $privateKey = $keyGen->generatePrivateKey(); + $publicKey = $keyGen->getPublicKey($privateKey); + + $event = new NostrEvent; + $event->setKind(22242) + ->setCreatedAt(time()) + ->setContent('') + ->setTags([['challenge', $challenge]]); + + (new NostrSign)->signEvent($event, $privateKey); + + $signed = [ + 'id' => $event->getId(), + 'pubkey' => $event->getPublicKey(), + 'created_at' => $event->getCreatedAt(), + 'kind' => $event->getKind(), + 'tags' => $event->getTags(), + 'content' => $event->getContent(), + 'sig' => $event->getSignature(), + ]; + + return [$signed, $keyGen->convertPublicKeyToBech32($publicKey)]; +} + +it('renders the mobile login page for guests and stores the flow state in the session', function () { + $response = $this->get('/auth/mobile?redirect_uri=einundzwanzig%3A%2F%2Fauth&device_name=Pixel%2010'); + + $response->assertOk(); + + expect(session('mobile_auth.redirect_uri'))->toBe('einundzwanzig://auth') + ->and(session('mobile_auth.device_name'))->toBe('Pixel 10'); +}); + +it('rejects redirect uris that are not whitelisted', function () { + $this->get('/auth/mobile?redirect_uri=https%3A%2F%2Fevil.example%2Fphish') + ->assertForbidden(); +}); + +it('stores a login key and creates a user when the nostr signer callback verifies', function () { + Queue::fake(); + + $k1 = bin2hex(random_bytes(32)); + [$signedEvent, $npub] = makeSignedMobileNostrEvent($k1); + + $response = $this->get('/api/nostr-login-callback?k1='.$k1.'&event='.urlencode(json_encode($signedEvent))); + + $response->assertOk()->assertJson(['status' => 'OK']); + + $user = User::query()->where('nostr', $npub)->first(); + expect($user)->not->toBeNull(); + + $loginKey = LoginKey::query()->where('k1', $k1)->first(); + expect($loginKey)->not->toBeNull() + ->and($loginKey->user_id)->toBe($user->id); + + Queue::assertPushed(FetchNostrProfileJob::class); +}); + +it('rejects a nostr signer callback whose event is bound to a different challenge', function () { + $k1 = bin2hex(random_bytes(32)); + [$signedEvent] = makeSignedMobileNostrEvent(bin2hex(random_bytes(32))); + + $response = $this->get('/api/nostr-login-callback?k1='.$k1.'&event='.urlencode(json_encode($signedEvent))); + + $response->assertBadRequest(); + expect(LoginKey::query()->where('k1', $k1)->exists())->toBeFalse(); +}); + +it('issues a token and redirects into the app when completing a verified login', function () { + $user = User::factory()->create(); + $k1 = bin2hex(random_bytes(32)); + LoginKey::query()->create(['k1' => $k1, 'user_id' => $user->id]); + + $response = $this + ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']]) + ->get('/auth/mobile/complete/'.$k1); + + $location = $response->headers->get('Location'); + expect($location)->toStartWith('einundzwanzig://auth?token='); + + $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()); + $this->getJson('/api/user', ['Authorization' => 'Bearer '.$plainTextToken]) + ->assertOk() + ->assertJsonPath('id', $user->id); +}); + +it('redirects back to the mobile login when the k1 is unknown or expired', function () { + $this->get('/auth/mobile/complete/'.bin2hex(random_bytes(32))) + ->assertRedirect(route('auth.mobile')); +}); + +it('lets an already authenticated user connect the app and replaces tokens of the same device', function () { + $user = User::factory()->create(); + $user->createToken('Pixel 10'); + + $response = $this + ->actingAs($user) + ->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($user->tokens()->where('name', 'Pixel 10')->count())->toBe(1); +}); + +it('shows the confirmation screen instead of the login methods for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/auth/mobile') + ->assertOk() + ->assertSee(route('auth.mobile.confirm')); +}); + +it('returns the token owner profile on /api/user', function () { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $this->getJson('/api/user') + ->assertOk() + ->assertJsonPath('id', $user->id) + ->assertJsonPath('name', $user->name); +}); + +it('denies /api/user without a token', function () { + $this->getJson('/api/user')->assertUnauthorized(); +});