Files
einundzwanzig-app/tests/Feature/Auth/MobileAuthTest.php
T
HolgerHatGarKeineNode 07169dfee6 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
2026-06-11 18:01:50 +02:00

152 lines
5.3 KiB
PHP

<?php
use App\Jobs\FetchNostrProfileJob;
use App\Models\LoginKey;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
use swentel\nostr\Event\Event as NostrEvent;
use swentel\nostr\Key\Key as NostrKey;
use swentel\nostr\Sign\Sign as NostrSign;
/**
* Build a NIP-55-style signed login event for a given k1 challenge, as an
* Android signer (e.g. Amber) would return it to the callback URL.
*
* @return array{0: array<string, mixed>, 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();
});