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
This commit is contained in:
HolgerHatGarKeineNode
2026-06-11 18:01:50 +02:00
parent f5cf85b438
commit 07169dfee6
8 changed files with 710 additions and 73 deletions
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
#[Group(name: 'Profil', weight: 8)]
class UserController extends Controller
{
/**
* Eigenes Profil
*
* Liefert das Profil des authentifizierten Nutzers (Token-Inhaber).
* Wird von der Mobile App direkt nach dem Login aufgerufen.
*/
public function __invoke(Request $request): JsonResponse
{
$user = $request->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,
]);
}
}
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Jobs\FetchNostrProfileJob;
use App\Models\LoginKey;
use App\Models\User;
use App\Support\NostrLogin;
use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
* Auth flow for the Einundzwanzig mobile app.
*
* The app opens /auth/mobile in an in-app browser. After a successful
* Lightning (LNURL) or Nostr (NIP-55/Amber) login the flow issues a
* Sanctum personal access token and hands it back to the app via the
* einundzwanzig:// deep link.
*/
final class MobileAuthController extends Controller
{
/** @var list<string> */
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));
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\User;
use Illuminate\Validation\ValidationException;
use swentel\nostr\Event\Event as NostrEvent;
use swentel\nostr\Key\Key as NostrKey;
/**
* Shared verification and user resolution for Nostr-based logins.
*
* Used by the interactive login component (challenge from the session,
* signed via window.nostr) and by the mobile auth flow (challenge from
* the k1 URL parameter, signed via a NIP-55 Android signer like Amber).
*/
final class NostrLogin
{
public const CHALLENGE_TTL_SECONDS = 300;
/**
* Verify a NIP-42-style signed login event against an expected challenge
* and return the signer's npub.
*
* Throws ValidationException on any invalid input never trust client data.
*/
public static function verifyEvent(mixed $signedEvent, string $expectedChallenge): 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')]);
}
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,
],
]);
}
}