<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class AuthenticaController extends Controller
{
private function basicAuth(): string
{
return base64_encode(
config('services.authentica.client_id').':'.config('services.authentica.client_secret')
);
}
private function url(string $path): string
{
return config('services.authentica.base_url').$path;
}
/** POST /verify/start — your frontend calls you; you call Authentica /session */
public function start(Request $request)
{
// 1) YOUR checks for this action (password, balance, permissions, ownership …)
$userRef = 'user-123'; // stable id — same value every time
$channel = 'sms'; // SMS-only — set on the server
$operationSensitivity = 'normal'; // your backend decides; "sensitive" forces OTP
$state = (string) Str::uuid(); // opaque, single use, carries no meaning
$body = Http::withHeaders([
'Authorization' => 'Basic '.$this->basicAuth(),
'Accept' => 'application/json',
'Idempotency-Key' => $state, // a timeout retry must not text the user twice
])->post($this->url('/api/V1/Verify/session'), [
'userRef' => $userRef,
'destination' => $request->input('destination'), // phone number
'channel' => $channel,
'state' => $state,
'returnUrl' => config('services.authentica.return_url'),
'operationSensitivity' => $operationSensitivity,
])->json();
$verifyUrl = data_get($body, 'result.verifyUrl');
$handoff = data_get($body, 'result.handoff');
$sessionId = data_get($body, 'result.sessionId');
if (! data_get($body, 'isSuccess') || ! $verifyUrl || ! $handoff) {
return response()->json([
'error' => data_get($body, 'errorCode', 'session_failed'),
], 502);
}
// Remember WHAT is being verified, WHO for, and the sessionId you read the outcome with.
Cache::put("otp:state:{$state}", [
'kind' => $request->input('action'), // 'login' | 'payment' | 'phone-change' | …
'params' => $request->input('params'), // e.g. ['paymentId' => 8842]
'userRef' => $userRef,
'sessionId' => $sessionId,
'done' => false,
], now()->addMinutes(15));
// `handoff` is single-use with a 120 s TTL — never put it in a URL.
return response()->json(['verifyUrl' => $verifyUrl, 'handoff' => $handoff]);
}
/**
* GET /verify/done — the registered returnUrl. A plain GET: normal SameSite=Lax
* cookies arrive, no CSRF exemption needed, nothing secret on the URL.
*
* `session_id` and `state` are inputs to a lookup, not evidence. Nothing is trusted
* until GET /session/{id} — authenticated with your client credentials — says so.
*/
public function done(Request $request)
{
$state = (string) $request->query('state');
$sessionId = (string) $request->query('session_id');
// 1) Resolve YOUR pending record.
$pending = $state ? Cache::get("otp:state:{$state}") : null;
if (! $pending) {
return redirect('/verify/failed');
}
if ($pending['done']) {
return redirect('/verify/success'); // a refresh
}
// 2) Ask Authentica. Use the sessionId from YOUR record, not the URL.
$result = $this->sessionStatus($pending['sessionId'] ?: $sessionId);
// 3) Three things must agree: verified, our state, our user.
if (data_get($result, 'status') !== 'verified'
|| data_get($result, 'state') !== $state
|| data_get($result, 'userRef') !== $pending['userRef']) {
return redirect('/verify/failed');
}
// 4) Fulfil once, then establish YOUR OWN session.
$this->onVerified($state, $pending);
Auth::loginUsingId($pending['userRef']); // your own auth
return redirect('/verify/success');
}
/** GET /api/V1/Verify/session/{id} with Basic auth. Returns the `result` array, or null. */
private function sessionStatus(string $sessionId): ?array
{
if ($sessionId === '') {
return null;
}
$body = Http::withHeaders([
'Authorization' => 'Basic '.$this->basicAuth(),
'Accept' => 'application/json',
])->get($this->url('/api/V1/Verify/session/'.rawurlencode($sessionId)))->json();
// The response also carries `assertionExchanged` — a legacy wire field. Ignore it.
return data_get($body, 'isSuccess') ? data_get($body, 'result') : null;
}
/** Idempotent fulfilment — called from the landing route AND the reconciliation sweep. */
private function onVerified(string $state, array $pending): void
{
if ($pending['done']) {
return;
}
$pending['done'] = true;
Cache::put("otp:state:{$state}", $pending, now()->addMinutes(15));
// YOUR success logic for $pending['kind']:
// 'payment' => $this->capture($pending['params']['paymentId']);
// 'phone-change' => $this->savePhone($pending['userRef'], $pending['params']['phone']);
}
}