diff --git a/app/Contracts/BankingProviderInterface.php b/app/Contracts/BankingProviderInterface.php index 8b45eb21..1e2cc1ca 100644 --- a/app/Contracts/BankingProviderInterface.php +++ b/app/Contracts/BankingProviderInterface.php @@ -14,9 +14,12 @@ interface BankingProviderInterface /** * Start a user authorization flow. * + * The $state value is echoed back by the provider on the callback and is used to + * correlate the callback to its connection without relying on a logged-in session. + * * @return array{url: string, authorization_id: string} */ - public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl): array; + public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl, string $state): array; /** * Exchange a callback code for a session with accounts. diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index ab8ddc81..6549b26a 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -14,6 +14,25 @@ use Inertia\Response; class OnboardingController extends Controller { + /** + * Steps a deep link may land on directly via ?step=. + * + * @var list + */ + private const VALID_STEPS = [ + 'welcome', + 'account-types', + 'create-account', + 'import-transactions', + 'import-balances', + 'category-types', + 'customize-categories', + 'smart-rules', + 'syncing', + 'categorize-transactions', + 'complete', + ]; + public function index(Request $request): Response { $user = $request->user(); @@ -40,11 +59,17 @@ class OnboardingController extends Controller ->orderBy('id', 'desc') ->get(); + $step = $request->query('step'); + $initialStep = is_string($step) && in_array($step, self::VALID_STEPS, true) + ? $step + : null; + return Inertia::render('onboarding/index', [ 'banks' => $banks, 'accounts' => $accounts, 'categories' => $categories, 'transactions' => $transactions, + 'initialStep' => $initialStep, ]); } diff --git a/app/Http/Controllers/OpenBanking/AuthorizationController.php b/app/Http/Controllers/OpenBanking/AuthorizationController.php index ffdcd613..a9420bf3 100644 --- a/app/Http/Controllers/OpenBanking/AuthorizationController.php +++ b/app/Http/Controllers/OpenBanking/AuthorizationController.php @@ -15,7 +15,11 @@ use App\Services\AccountUserCurrencyService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use Inertia\Inertia; +use Inertia\Response; class AuthorizationController extends Controller { @@ -36,16 +40,19 @@ class AuthorizationController extends Controller $validated = $request->validated(); $redirectUrl = config('services.enablebanking.redirect_url'); + $stateToken = Str::random(40); $result = $provider->startAuthorization( $validated['aspsp_name'], $validated['country'], $redirectUrl, + $stateToken, ); $connection = $user->bankingConnections()->create([ 'provider' => 'enablebanking', 'authorization_id' => $result['authorization_id'], + 'state_token' => $stateToken, 'aspsp_name' => $validated['aspsp_name'], 'aspsp_country' => $validated['country'], 'aspsp_logo' => $validated['logo'] ?? null, @@ -120,15 +127,18 @@ class AuthorizationController extends Controller } $redirectUrl = config('services.enablebanking.redirect_url'); + $stateToken = Str::random(40); $result = $provider->startAuthorization( $connection->aspsp_name, $connection->aspsp_country, $redirectUrl, + $stateToken, ); $connection->update([ 'authorization_id' => $result['authorization_id'], + 'state_token' => $stateToken, 'status' => BankingConnectionStatus::Pending, 'error_message' => null, ]); @@ -138,10 +148,22 @@ class AuthorizationController extends Controller /** * Handle the callback from bank authorization. + * + * This route is intentionally unauthenticated. iOS PWAs hand the bank redirect + * back to the system browser (Safari), where the app session does not exist, so + * the connection is resolved from the signed state token EnableBanking echoes + * back rather than from the logged-in session. */ - public function callback(Request $request, BankingProviderInterface $provider, AccountUserCurrencyService $accountUserCurrencyService): RedirectResponse + public function callback(Request $request, BankingProviderInterface $provider, AccountUserCurrencyService $accountUserCurrencyService): RedirectResponse|Response { - $user = auth()->user(); + $connection = $this->resolveConnectionFromState($request); + $user = $connection ? $connection->user : auth()->user(); + + if (! $user) { + return redirect()->route('login') + ->with('error', __('Please log back in to finish connecting your bank account.')); + } + $errorRedirectRoute = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding'; $errorRedirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account']; @@ -156,7 +178,7 @@ class AuthorizationController extends Controller 'description' => $errorDescription, ]); - $pendingConnection = $user->bankingConnections() + $pendingConnection = $connection ?? $user->bankingConnections() ->where('status', BankingConnectionStatus::Pending) ->latest() ->first(); @@ -166,21 +188,20 @@ class AuthorizationController extends Controller $pendingConnection->update([ 'status' => BankingConnectionStatus::Error, 'error_message' => $errorMessage, + 'state_token' => null, ]); } else { $pendingConnection->delete(); } } - return redirect()->route($errorRedirectRoute, $errorRedirectParams) - ->with('error', $errorMessage); + return $this->finishRedirect($errorRedirectRoute, $errorRedirectParams, 'error', $errorMessage); } $code = $request->query('code'); if (! $code) { - return redirect()->route($errorRedirectRoute, $errorRedirectParams) - ->with('error', 'No authorization code received.'); + return $this->finishRedirect($errorRedirectRoute, $errorRedirectParams, 'error', 'No authorization code received.'); } try { @@ -188,15 +209,13 @@ class AuthorizationController extends Controller } catch (\Throwable $e) { Log::error('EnableBanking session creation failed', ['error' => $e->getMessage()]); - return redirect()->route($errorRedirectRoute, $errorRedirectParams) - ->with('error', 'Failed to connect to your bank. Please try again.'); + return $this->finishRedirect($errorRedirectRoute, $errorRedirectParams, 'error', 'Failed to connect to your bank. Please try again.'); } - $connection = $this->findPendingConnectionForSession($user, $sessionData); + $connection ??= $this->findPendingConnectionForSession($user, $sessionData); if (! $connection) { - return redirect()->route($errorRedirectRoute, $errorRedirectParams) - ->with('error', 'No pending connection found.'); + return $this->finishRedirect($errorRedirectRoute, $errorRedirectParams, 'error', 'No pending connection found.'); } $isReconnect = $connection->accounts()->exists(); @@ -207,14 +226,14 @@ class AuthorizationController extends Controller 'status' => BankingConnectionStatus::Active, 'valid_until' => $sessionData['access']['valid_until'] ?? null, 'error_message' => null, + 'state_token' => null, ]); $this->refreshAccountIds($connection, $sessionData['accounts']); SyncBankingConnectionJob::dispatch($connection); - return redirect()->route('settings.connections.index') - ->with('success', __('Bank account reconnected successfully.')); + return $this->finishRedirect('settings.connections.index', [], 'success', __('Bank account reconnected successfully.')); } $connection->update([ @@ -222,17 +241,62 @@ class AuthorizationController extends Controller 'status' => BankingConnectionStatus::AwaitingMapping, 'valid_until' => $sessionData['access']['valid_until'] ?? null, 'pending_accounts_data' => $sessionData['accounts'], + 'state_token' => null, ]); if (! $user->isOnboarded()) { $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); SyncBankingConnectionJob::dispatch($connection); - return redirect()->route('onboarding', ['step' => 'create-account']) - ->with('success', 'Bank account connected successfully.'); + return $this->finishRedirect('onboarding', ['step' => 'create-account'], 'success', 'Bank account connected successfully.'); } - return redirect()->route('open-banking.map-accounts', $connection); + return $this->finishRedirect('open-banking.map-accounts', ['connection' => $connection]); + } + + /** + * Resolve the connection a callback belongs to from the state token EnableBanking + * echoes back. This works without a logged-in session. + */ + private function resolveConnectionFromState(Request $request): ?BankingConnection + { + $stateToken = $request->query('state'); + + if (! is_string($stateToken) || $stateToken === '') { + return null; + } + + return BankingConnection::query() + ->where('state_token', $stateToken) + ->first(); + } + + /** + * Finish a callback, accounting for the unauthenticated PWA case. + * + * When the callback runs without a session (Safari), the destination routes are + * behind auth middleware and the user is not logged in on this browser. The + * connection has already been finalized server-side, so render a standalone page + * telling the user to return to the app rather than bouncing them to login. + * + * @param array $params + */ + private function finishRedirect(string $route, array $params, ?string $flashKey = null, ?string $flashMessage = null): RedirectResponse|Response + { + if (! Auth::check()) { + return Inertia::render('open-banking/connection-complete', [ + 'status' => $flashKey === 'error' ? 'error' : 'success', + 'message' => $flashMessage ?? __('Your bank account is connected.'), + ]); + } + + $redirect = redirect()->route($route, $params); + + if ($flashKey !== null && $flashMessage !== null) { + $redirect->with($flashKey, $flashMessage); + } + + return $redirect; } /** diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index 7d841c08..b31bd6ef 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -32,6 +32,7 @@ class BankingConnection extends Model 'user_id', 'provider', 'authorization_id', + 'state_token', 'session_id', 'aspsp_name', 'aspsp_country', @@ -53,6 +54,7 @@ class BankingConnection extends Model 'api_secret', 'pending_accounts_data', 'authorization_id', + 'state_token', 'session_id', ]; diff --git a/app/Services/Banking/EnableBankingProvider.php b/app/Services/Banking/EnableBankingProvider.php index 35eeecd7..d9a473a8 100644 --- a/app/Services/Banking/EnableBankingProvider.php +++ b/app/Services/Banking/EnableBankingProvider.php @@ -39,7 +39,7 @@ class EnableBankingProvider implements BankingProviderInterface ->all(); } - public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl): array + public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl, string $state): array { $response = $this->client()->post('/auth', [ 'access' => [ @@ -51,7 +51,7 @@ class EnableBankingProvider implements BankingProviderInterface 'name' => $aspspName, 'country' => $countryCode, ], - 'state' => csrf_token(), + 'state' => $state, 'redirect_url' => $redirectUrl, 'psu_type' => 'personal', ]); diff --git a/database/migrations/2026_06_05_185212_add_state_token_to_banking_connections_table.php b/database/migrations/2026_06_05_185212_add_state_token_to_banking_connections_table.php new file mode 100644 index 00000000..dc6bc1e1 --- /dev/null +++ b/database/migrations/2026_06_05_185212_add_state_token_to_banking_connections_table.php @@ -0,0 +1,29 @@ +string('state_token')->nullable()->unique()->after('authorization_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('banking_connections', function (Blueprint $table) { + $table->dropUnique(['state_token']); + $table->dropColumn('state_token'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index ad9510f7..bcc994c6 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1,4 +1,8 @@ { + "Bank account connected": "Cuenta bancaria conectada", + "Connection unsuccessful": "Conexión fallida", + "You can close this window and go back to the app to continue.": "Puedes cerrar esta ventana y volver a la app para continuar.", + "Your bank account is connected.": "Tu cuenta bancaria está conectada.", "A filter with that name already exists": "Ya existe un filtro con ese nombre", "Delete saved filter": "Eliminar filtro guardado", "Failed to delete the saved filter": "No se pudo eliminar el filtro guardado", diff --git a/resources/js/pages/onboarding/index.tsx b/resources/js/pages/onboarding/index.tsx index 020998dc..462ad12d 100644 --- a/resources/js/pages/onboarding/index.tsx +++ b/resources/js/pages/onboarding/index.tsx @@ -20,7 +20,7 @@ import { type Account, type Bank } from '@/types/account'; import { type Category } from '@/types/category'; import { type Transaction } from '@/types/transaction'; import { __ } from '@/utils/i18n'; -import { Head } from '@inertiajs/react'; +import { Head, usePoll } from '@inertiajs/react'; import { useEffect, useMemo, useRef } from 'react'; interface ExistingAccount { @@ -44,6 +44,7 @@ interface OnboardingProps { accounts: ExistingAccount[]; categories: Category[]; transactions: Transaction[]; + initialStep?: OnboardingStep | null; } const VALID_STEPS: OnboardingStep[] = [ @@ -65,19 +66,24 @@ export default function Onboarding({ accounts, categories, transactions, + initialStep: initialStepProp, }: OnboardingProps) { const { sync } = useSyncContext(); const hasSyncedRef = useRef(false); - // Read ?step= from URL to allow deep-linking into a specific step + // Prefer the server-validated step; fall back to ?step= from the URL so + // client-side deep links keep working. const initialStep = useMemo((): OnboardingStep | undefined => { + if (initialStepProp && VALID_STEPS.includes(initialStepProp)) { + return initialStepProp; + } if (typeof window === 'undefined') { return undefined; } const params = new URLSearchParams(window.location.search); const step = params.get('step') as OnboardingStep | null; return step && VALID_STEPS.includes(step) ? step : undefined; - }, []); + }, [initialStepProp]); // Sync banks on mount to ensure IndexedDB has the latest data useEffect(() => { @@ -110,6 +116,23 @@ export default function Onboarding({ hasConnectedAccount, }); + // While on the connections step, poll for connections finalized elsewhere + // (e.g. an iOS PWA hands the bank redirect to Safari, which completes the + // connection server-side in a different browser without a session here). + const { start, stop } = usePoll( + 4000, + { only: ['accounts'] }, + { autoStart: false }, + ); + + useEffect(() => { + if (currentStep === 'create-account') { + start(); + } else { + stop(); + } + }, [currentStep, start, stop]); + const handleAccountCreated = async (account: CreatedAccount) => { // Connected accounts already exist server-side (in existingAccounts prop); // don't add them to createdAccounts — they'll show via filteredExistingAccounts. diff --git a/resources/js/pages/open-banking/connection-complete.tsx b/resources/js/pages/open-banking/connection-complete.tsx new file mode 100644 index 00000000..ffa6dab2 --- /dev/null +++ b/resources/js/pages/open-banking/connection-complete.tsx @@ -0,0 +1,54 @@ +import { __ } from '@/utils/i18n'; +import { Head } from '@inertiajs/react'; +import { CheckCircle2, XCircle } from 'lucide-react'; + +interface ConnectionCompleteProps { + status: 'success' | 'error'; + message: string; +} + +export default function ConnectionComplete({ + status, + message, +}: ConnectionCompleteProps) { + const isSuccess = status === 'success'; + const title = isSuccess + ? __('Bank account connected') + : __('Connection unsuccessful'); + + return ( + <> + + +
+
+
+ {isSuccess ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+

+ {title} +

+

+ {message} +

+

+ {__( + 'You can close this window and go back to the app to continue.', + )} +

+
+
+
+
+ + ); +} diff --git a/routes/web.php b/routes/web.php index ac367dc5..281c88ef 100644 --- a/routes/web.php +++ b/routes/web.php @@ -127,6 +127,11 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi Route::post('transactions/{transaction}/re-evaluate-rules', [ReEvaluateTransactionRulesController::class, 'single'])->name('transactions.re-evaluate-rules.single'); }); +// The bank authorization callback is intentionally unauthenticated: iOS PWAs hand the +// redirect back to Safari where the app session does not exist. The connection is +// resolved from the signed state token EnableBanking echoes back instead. +Route::get('open-banking/callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback'); + // Open-banking routes are accessible without the onboarded/subscribed middleware // so that users can connect their bank during the onboarding flow. Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function () { @@ -134,7 +139,6 @@ Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize'); Route::post('connections/{connection}/reauthorize', [AuthorizationController::class, 'reauthorize'])->name('open-banking.reauthorize'); Route::get('connections/{connection}/reconnect', [AuthorizationController::class, 'reconnect'])->name('open-banking.reconnect'); - Route::get('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback'); Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts'); Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store'); Route::post('indexa-capital/connect', [IndexaCapitalController::class, 'store'])->name('open-banking.indexa-capital.connect'); diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index 9a40acee..4f6bc323 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -267,6 +267,57 @@ it('returns to the accounts step when bank authorization fails during onboarding expect($connection->trashed())->toBeTrue(); }); +it('deep links straight to the connections step via ?step=create-account', function () { + $user = User::factory()->create([ + 'onboarded_at' => null, + ]); + + $this->actingAs($user); + + $page = visit('/onboarding?step=create-account'); + + $page->wait(1) + // Lands on the connections step, skipping the welcome step entirely. + ->assertSee('Create an Account') + ->assertSee('Manual') + ->assertDontSee('Welcome to') + ->assertNoJavascriptErrors(); +}); + +it('polls and shows a connection finalized in another browser', function () { + $user = User::factory()->create([ + 'onboarded_at' => null, + ]); + + $this->actingAs($user); + + // User sits on the connections step with no accounts yet. + $page = visit('/onboarding?step=create-account'); + $page->wait(1) + ->assertSee('Create an Account') + ->assertDontSee('Polled Bank'); + + // The bank flow is finalized elsewhere (iOS PWA -> Safari): an account is + // created server-side without this browser doing anything. + $bank = Bank::factory()->create(['name' => 'Polled Bank']); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + ]); + Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'banking_connection_id' => $connection->id, + 'type' => 'checking', + 'currency_code' => 'EUR', + ]); + + // The 4s poll picks it up and the connection appears without a manual refresh. + $page->wait(6) + ->assertSee('Your Accounts') + ->assertSee('Polled Bank') + ->assertNoJavascriptErrors(); +}); + // ============================================================================= // More Accounts Flow Tests // ============================================================================= diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php index 6da2314c..255b0159 100644 --- a/tests/Feature/Onboarding/OnboardingControllerTest.php +++ b/tests/Feature/Onboarding/OnboardingControllerTest.php @@ -80,6 +80,30 @@ it('does not return transactions belonging to other users', function () { ); }); +it('lands directly on the connections step when ?step=create-account is requested', function () { + $user = User::factory()->create(['onboarded_at' => null]); + + $response = $this->actingAs($user)->get('/onboarding?step=create-account'); + + $response->assertSuccessful() + ->assertInertia(fn ($page) => $page + ->component('onboarding/index') + ->where('initialStep', 'create-account') + ); +}); + +it('ignores an unknown step and falls back to the default flow', function () { + $user = User::factory()->create(['onboarded_at' => null]); + + $response = $this->actingAs($user)->get('/onboarding?step=not-a-real-step'); + + $response->assertSuccessful() + ->assertInertia(fn ($page) => $page + ->component('onboarding/index') + ->where('initialStep', null) + ); +}); + it('returns banks and accounts props on onboarding index', function () { $user = User::factory()->create(['onboarded_at' => null]); $globalBank = Bank::factory()->create(['user_id' => null]); diff --git a/tests/Feature/OpenBanking/AuthorizationControllerTest.php b/tests/Feature/OpenBanking/AuthorizationControllerTest.php index 01931fb1..6cad79c0 100644 --- a/tests/Feature/OpenBanking/AuthorizationControllerTest.php +++ b/tests/Feature/OpenBanking/AuthorizationControllerTest.php @@ -245,6 +245,46 @@ test('callback with valid code stores pending accounts and redirects to mapping' Queue::assertNothingPushed(); }); +test('callback during onboarding redirects a logged-in user directly to the connections step', function () { + Queue::fake(); + + $user = User::factory()->notOnboarded()->create(); + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Test Bank', + 'aspsp_country' => 'ES', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('createSession') + ->with('test-code') + ->once() + ->andReturn([ + 'session_id' => 'session-onboarding', + 'accounts' => [ + [ + 'uid' => 'ext-account-1', + 'currency' => 'EUR', + 'name' => 'My Checking Account', + 'account_id' => ['iban' => 'ES1234567890123456789012'], + ], + ], + 'aspsp' => ['name' => 'Test Bank', 'country' => 'ES'], + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->get('/open-banking/callback?code=test-code'); + + $response->assertRedirect(route('onboarding', ['step' => 'create-account'])); + + $this->assertDatabaseHas('accounts', [ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + ]); +}); + // Reauthorize tests test('reauthorize returns 403 when user does not own the connection', function () { @@ -297,7 +337,7 @@ test('reauthorize starts new authorization and sets connection to pending for er $mockProvider = Mockery::mock(BankingProviderInterface::class); $mockProvider->shouldReceive('startAuthorization') - ->with('CaixaBank', 'ES', config('services.enablebanking.redirect_url')) + ->with('CaixaBank', 'ES', config('services.enablebanking.redirect_url'), Mockery::type('string')) ->once() ->andReturn([ 'url' => 'https://bank.example.com/reauthorize', @@ -359,7 +399,7 @@ test('reconnect link redirects expired connections to bank authorization', funct $mockProvider = Mockery::mock(BankingProviderInterface::class); $mockProvider->shouldReceive('startAuthorization') - ->with('Santander', 'ES', config('services.enablebanking.redirect_url')) + ->with('Santander', 'ES', config('services.enablebanking.redirect_url'), Mockery::type('string')) ->once() ->andReturn([ 'url' => 'https://bank.example.com/reauthorize', @@ -747,3 +787,152 @@ test('callback stores iban in pending accounts data', function () { expect($connection->pending_accounts_data)->toHaveCount(1); expect($connection->pending_accounts_data[0]['account_id']['iban'])->toBe('ES9999999999999999999999'); }); + +// Authless callback via state token (iOS PWA returns to Safari without a session) + +test('start authorization persists a state token and sends it to the provider', function () { + $user = User::factory()->onboarded()->create(); + + $capturedState = null; + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('startAuthorization') + ->once() + ->withArgs(function (string $name, string $country, string $url, string $state) use (&$capturedState): bool { + $capturedState = $state; + + return $name === 'Test Bank' && $country === 'ES' && $state !== ''; + }) + ->andReturn([ + 'url' => 'https://bank.example.com/authorize', + 'authorization_id' => 'auth-state-1', + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $this->actingAs($user)->postJson('/open-banking/authorize', [ + 'aspsp_name' => 'Test Bank', + 'country' => 'ES', + ])->assertOk(); + + $connection = $user->bankingConnections()->first(); + expect($connection->state_token)->not->toBeEmpty(); + expect($connection->state_token)->toBe($capturedState); +}); + +test('callback finalizes the connection from the state token without an authenticated session', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'CaixaBank', + 'aspsp_country' => 'ES', + 'state_token' => 'state-token-abc', + ]); + + $accounts = [ + [ + 'uid' => 'ext-account-1', + 'currency' => 'EUR', + 'name' => 'My Checking Account', + 'account_id' => ['iban' => 'ES1234567890123456789012'], + ], + ]; + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('createSession') + ->with('test-code') + ->once() + ->andReturn([ + 'session_id' => 'session-authless', + 'accounts' => $accounts, + 'aspsp' => ['name' => 'CaixaBank', 'country' => 'ES'], + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + // No actingAs(): the PWA handed the redirect to Safari, which has no session. + $response = $this->get('/open-banking/callback?code=test-code&state=state-token-abc'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('open-banking/connection-complete') + ->where('status', 'success') + ); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->session_id)->toBe('session-authless'); + expect($connection->state_token)->toBeNull(); +}); + +test('callback resolves the connection owner from the state token regardless of who is authenticated', function () { + Queue::fake(); + + $owner = User::factory()->onboarded()->create(); + $other = User::factory()->onboarded()->create(); + + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $owner->id, + 'aspsp_name' => 'CaixaBank', + 'aspsp_country' => 'ES', + 'state_token' => 'state-token-owner', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('createSession') + ->with('test-code') + ->once() + ->andReturn([ + 'session_id' => 'session-owner', + 'accounts' => [ + [ + 'uid' => 'ext-account-1', + 'currency' => 'EUR', + 'name' => 'Owner Account', + 'account_id' => ['iban' => 'ES1234567890123456789012'], + ], + ], + 'aspsp' => ['name' => 'CaixaBank', 'country' => 'ES'], + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($other)->get('/open-banking/callback?code=test-code&state=state-token-owner'); + + $response->assertRedirect(route('open-banking.map-accounts', $connection)); + + $connection->refresh(); + expect($connection->user_id)->toBe($owner->id); + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->session_id)->toBe('session-owner'); + expect($connection->state_token)->toBeNull(); +}); + +test('callback without a session or a resolvable state redirects to login', function () { + $response = $this->get('/open-banking/callback?code=test-code&state=unknown-token'); + + $response->assertRedirect(route('login')); +}); + +test('callback renders the completion page on error without an authenticated session', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $user->id, + 'state_token' => 'state-token-error', + ]); + + // No actingAs(): the error came back to Safari, which has no session. + $response = $this->get('/open-banking/callback?error=access_denied&error_description=Denied&state=state-token-error'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('open-banking/connection-complete') + ->where('status', 'error') + ->where('message', 'Denied') + ); + + expect($connection->fresh()->trashed())->toBeTrue(); +}); diff --git a/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php b/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php index 8fec4865..d6bad452 100644 --- a/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php +++ b/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php @@ -16,9 +16,12 @@ test('guests cannot access authorize route', function () { ])->assertUnauthorized(); }); -test('guests are redirected away from callback route', function () { +test('guests hitting the callback with no resolvable connection are sent to login', function () { + // The callback is intentionally public so iOS PWAs that return to Safari (without a + // session) can still finalize the connection via the state token. With no state and + // no session there is nothing to finalize, so the guest is sent to login. $this->get('/open-banking/callback?code=test') - ->assertRedirect(route('register')); + ->assertRedirect(route('login')); }); test('guests are redirected away from connections index', function () {