mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-17 04:30:31 +00:00
Add verified App Link handoff and mobile token exchange endpoint
Replaces the custom-scheme auto-redirect (which triggers Chrome's confirmation prompt) with a verified Android App Link handoff: - public/.well-known/assetlinks.json for space.einundzwanzig.mobile (debug cert fingerprint; add the release cert before store builds) - GET /app/auth handoff: opens the app directly when the App Link is verified; renders a button-based fallback page otherwise - POST /api/mobile/token: trades a NIP-55-signed login event for a Sanctum token — used when Amber's callback opens the app directly - complete/confirm/signedCallback now redirect to the handoff URL
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
+5
-4
@@ -16,14 +16,15 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{-- 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. --}}
|
||||
<div class="card">
|
||||
<div class="check">✅</div>
|
||||
<h1>{{ __('Login bestätigt') }}</h1>
|
||||
<p>{{ __('Du wirst jetzt zurück in die Einundzwanzig-App geleitet. Falls nichts passiert, tippe auf den Button.') }}</p>
|
||||
<p>{{ __('Tippe auf den Button, um zurück zur Einundzwanzig-App zu gelangen.') }}</p>
|
||||
<a class="button" href="{{ $deepLink }}">{{ __('Zurück zur App') }}</a>
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(function () { window.location.href = @js($deepLink); }, 400);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
|
||||
+8
-1
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user