feat(open-banking): finalize bank connection without a session via state token (#498)
## Why
iOS PWAs hand the bank redirect back to the system browser (Safari),
where the app session does not exist. The old `/open-banking/callback`
sat behind `auth` + `verified` middleware, so Safari hit it with no
session → bounced to `/login`, the callback body never ran, and the
connection stayed `pending` forever (4 stale rows observed in prod).
## What
Make the bank connection flow session-independent end to end.
### 1. State-token callback (session-independent)
- `store()` generates `Str::random(40)`, persists it on the connection,
and passes it to `startAuthorization` as `state`.
- `/open-banking/callback` is now **public**. It resolves the connection
from `state_token` (`resolveConnectionFromState`) and derives the owner
from the connection, not the session.
- Connection is finalized server-side (`session_id`, status,
`state_token = NULL`) before any redirect.
- New migration adds the nullable, unique `state_token` column; the
column is hidden on the model.
### 2. Completion page instead of login bounce
When the callback runs without a session (the Safari case), the
connection is already finalized server-side. Instead of bouncing the
user to `/login`, render a standalone "go back to the app" page — their
session lives in the PWA, not here.
- New page `resources/js/pages/open-banking/connection-complete.tsx`
(success / error states, dark mode).
- `finishRedirect()` renders the page when `!Auth::check()`; logged-in
users still get the normal redirect.
### 3. Onboarding: land on the connections step + poll cross-browser
- A logged-in user finishing the flow lands straight on the onboarding
connections step (`?step=create-account`), now resolved server-side via
a validated `initialStep` prop so the step is deterministic.
- While on that step the page polls every 4s (`usePoll`, `only:
['accounts']`), so a connection finalized in another browser (PWA →
Safari) shows up without a manual refresh.
## Flow
```
POST /authorize → stateToken = Str::random(40)
→ startAuthorization(state = stateToken)
→ create BankingConnection(pending, state_token)
EnableBanking → callback?code&state=stateToken (PUBLIC, no session needed)
→ find connection WHERE state_token = state
→ createSession(code) → finalize (session_id, status, state_token = NULL)
→ session present? redirect to connections step / mapping
: render "go back to the app" completion page
PWA (logged in) → onboarding?step=create-account → polls accounts every 4s
```
## Tests
36 passing. Covers:
- state-token persistence and session-less finalization
- owner resolved from the connection regardless of who is authenticated
- completion page rendered (success + error) when no session; login
fallback when no connection resolves
- logged-in user redirected directly to the onboarding connections step
- `?step=create-account` lands on that step; unknown step falls back to
default
This commit is contained in:
parent
cb728ce176
commit
a7dde5fbc5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,25 @@ use Inertia\Response;
|
|||
|
||||
class OnboardingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Steps a deep link may land on directly via ?step=.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> $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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('banking_connections', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<Head title={title} />
|
||||
|
||||
<div className="flex min-h-svh flex-col bg-[#FDFDFC] text-[#1b1b18] dark:bg-[#0a0a0a] dark:text-[#EDEDEC]">
|
||||
<main className="flex flex-1 items-center justify-center px-6 py-32">
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-8 text-center">
|
||||
{isSuccess ? (
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/60">
|
||||
<CheckCircle2 className="size-8 text-emerald-600 dark:text-emerald-300" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-950/60">
|
||||
<XCircle className="size-8 text-red-600 dark:text-red-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="font-heading text-3xl font-semibold sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
|
||||
{message}
|
||||
</p>
|
||||
<p className="text-base text-[#706f6c] dark:text-[#A1A09A]">
|
||||
{__(
|
||||
'You can close this window and go back to the app to continue.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue