Add Parcel2Go account consent with PKCE
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 26s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 26s
This commit is contained in:
@@ -7,13 +7,14 @@ use App\Models\EtsyConnection;
|
||||
use App\Models\IntegrationSetting;
|
||||
use App\Services\Etsy;
|
||||
use App\Services\IntegrationSettings;
|
||||
use App\Services\Parcel2Go;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EtsySettingsController extends Controller
|
||||
{
|
||||
public function index(Request $request, Etsy $etsy, IntegrationSettings $settings): View
|
||||
public function index(Request $request, Etsy $etsy, IntegrationSettings $settings, Parcel2Go $parcel2Go): View
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
|
||||
@@ -37,6 +38,9 @@ class EtsySettingsController extends Controller
|
||||
'base_url' => $parcel2go['base_url'] ?? '',
|
||||
'scope' => $parcel2go['scope'] ?? 'public-api',
|
||||
'collection_postcode' => $parcel2go['collection_postcode'] ?? '',
|
||||
'profile_client_configured' => $parcel2Go->profileClientConfigured(),
|
||||
'profile_connected' => $parcel2Go->profileConnected(),
|
||||
'callback_url' => route('parcel2go.callback'),
|
||||
],
|
||||
'bambuddy' => [
|
||||
'stored' => in_array('bambuddy', $storedIntegrations, true),
|
||||
|
||||
@@ -22,6 +22,7 @@ class IntegrationSettingsController extends Controller
|
||||
'base_url' => ['nullable', 'url:https', 'max:2048'],
|
||||
'scope' => ['nullable', 'string', 'in:public-api,public-api my-profile,public-api payment,public-api payment my-profile'],
|
||||
'collection_postcode' => ['nullable', 'string', 'max:12'],
|
||||
'profile_client_id' => ['nullable', 'string', 'max:255'],
|
||||
],
|
||||
default => [
|
||||
'url' => ['nullable', 'url:https', 'max:2048'],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Parcel2Go;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
class Parcel2GoConnectionController extends Controller
|
||||
{
|
||||
public function connect(Request $request, Parcel2Go $parcel2Go): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
if (! $parcel2Go->profileClientConfigured()) {
|
||||
return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Save the Parcel2Go Authorization Code Client ID first.']);
|
||||
}
|
||||
|
||||
$state = bin2hex(random_bytes(24));
|
||||
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
|
||||
$request->session()->put('parcel2go_oauth', ['state' => $state, 'verifier' => $verifier, 'started' => time()]);
|
||||
|
||||
return redirect()->away($parcel2Go->authorizationUrl($state, $verifier));
|
||||
}
|
||||
|
||||
public function callback(Request $request, Parcel2Go $parcel2Go): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$pending = $request->session()->pull('parcel2go_oauth');
|
||||
if (! is_array($pending) || time() - ($pending['started'] ?? 0) > 600
|
||||
|| ! is_string($request->query('state'))
|
||||
|| ! hash_equals($pending['state'] ?? '', $request->query('state'))
|
||||
|| ! is_string($request->query('code'))) {
|
||||
return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Parcel2Go consent expired or was not verified. Please try again.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$parcel2Go->connectProfile($request->query('code'), $pending['verifier']);
|
||||
|
||||
return redirect()->route('settings.index')->with('status', 'Parcel2Go account connected. Shipment history is available in Shipping.');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Parcel2Go rejected the account connection. Check the Client ID, scopes and callback URL.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,13 @@ class ShippingController extends Controller
|
||||
$orders = $parcel2Go->fetch('me/orders/detail');
|
||||
$historyAvailable = true;
|
||||
} catch (Throwable $exception) {
|
||||
$profileNotice = $exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true)
|
||||
? 'Parcel2Go denied shipment history for this client. Request my-profile access from Parcel2Go API support.'
|
||||
: 'Parcel2Go shipment history is temporarily unavailable.';
|
||||
$profileNotice = $parcel2Go->profileConnected()
|
||||
? 'Parcel2Go account access needs attention. Reconnect the account in Settings.'
|
||||
: ($exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true)
|
||||
? 'Parcel2Go denied shipment history for this client. Connect an Authorization Code client in Settings.'
|
||||
: 'Parcel2Go shipment history is temporarily unavailable.');
|
||||
}
|
||||
if ($parcel2Go->hasScope('my-profile')) {
|
||||
if ($parcel2Go->profileConnected() || $parcel2Go->hasScope('my-profile')) {
|
||||
try {
|
||||
$account = $parcel2Go->fetch('me/detail');
|
||||
$balance = $parcel2Go->fetch('me/prepay/balance');
|
||||
|
||||
+114
-2
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\IntegrationSetting;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
@@ -15,6 +17,8 @@ class Parcel2Go
|
||||
|
||||
private bool $reducedScope = false;
|
||||
|
||||
private ?string $profileToken = null;
|
||||
|
||||
public function __construct(private IntegrationSettings $settings) {}
|
||||
|
||||
public function hasScope(string $scope): bool
|
||||
@@ -32,9 +36,70 @@ class Parcel2Go
|
||||
$this->accessToken();
|
||||
}
|
||||
|
||||
public function profileConnected(): bool
|
||||
{
|
||||
$connection = IntegrationSetting::find('parcel2go_oauth')?->settings;
|
||||
|
||||
return filled($connection['access_token'] ?? null)
|
||||
&& ($connection['client_id'] ?? null) === ($this->settings->for('parcel2go')['profile_client_id'] ?? null);
|
||||
}
|
||||
|
||||
public function profileClientConfigured(): bool
|
||||
{
|
||||
return filled($this->settings->for('parcel2go')['profile_client_id'] ?? null);
|
||||
}
|
||||
|
||||
public function authorizationUrl(string $state, string $verifier): string
|
||||
{
|
||||
$settings = $this->settings->for('parcel2go');
|
||||
if (blank($settings['profile_client_id'] ?? null)) {
|
||||
throw new RuntimeException('Parcel2Go Authorization Code Client ID is not configured.');
|
||||
}
|
||||
|
||||
return $this->authorizationEndpoint().'?'.http_build_query([
|
||||
'client_id' => $settings['profile_client_id'],
|
||||
'redirect_uri' => route('parcel2go.callback'),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'public-api my-profile offline_access',
|
||||
'state' => $state,
|
||||
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
|
||||
'code_challenge_method' => 'S256',
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
public function connectProfile(string $code, string $verifier): void
|
||||
{
|
||||
$settings = $this->settings->for('parcel2go');
|
||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->post($settings['auth_url'], [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $settings['profile_client_id'],
|
||||
'redirect_uri' => route('parcel2go.callback'),
|
||||
'code' => $code,
|
||||
'code_verifier' => $verifier,
|
||||
])->throw()->json();
|
||||
if (empty($tokens['access_token'])) {
|
||||
throw new RuntimeException('Parcel2Go did not return a profile access token.');
|
||||
}
|
||||
Http::withToken($tokens['access_token'])->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
->get($this->url('me/orders/detail'))->throw();
|
||||
|
||||
IntegrationSetting::updateOrCreate(['provider' => 'parcel2go_oauth'], ['settings' => [
|
||||
'client_id' => $settings['profile_client_id'],
|
||||
'access_token' => $tokens['access_token'],
|
||||
'refresh_token' => $tokens['refresh_token'] ?? null,
|
||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60))->toIso8601String(),
|
||||
]]);
|
||||
$this->profileToken = $tokens['access_token'];
|
||||
}
|
||||
|
||||
public function fetch(string $path): array
|
||||
{
|
||||
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
$token = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected()
|
||||
? $this->profileAccessToken()
|
||||
: $this->accessToken();
|
||||
|
||||
return Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
->get($this->url($path))->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
@@ -98,7 +163,8 @@ class Parcel2Go
|
||||
|
||||
public function labelPdf(string $reference, string $hash): string
|
||||
{
|
||||
$data = Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(20)
|
||||
$token = $this->profileConnected() ? $this->profileAccessToken() : $this->accessToken();
|
||||
$data = Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(20)
|
||||
->get($this->url('labels/'.$reference), [
|
||||
'referenceType' => 'OrderLineId', 'detailLevel' => 'Labels',
|
||||
'labelMedia' => 'A4', 'labelFormat' => 'PDF', 'hash' => $hash,
|
||||
@@ -117,6 +183,52 @@ class Parcel2Go
|
||||
return rtrim($this->settings->for('parcel2go')['base_url'], '/').'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function authorizationEndpoint(): string
|
||||
{
|
||||
$tokenUrl = $this->settings->for('parcel2go')['auth_url'] ?? '';
|
||||
if (! str_ends_with($tokenUrl, '/token')) {
|
||||
throw new RuntimeException('Parcel2Go token URL must end in /token.');
|
||||
}
|
||||
|
||||
return substr($tokenUrl, 0, -strlen('/token')).'/authorize';
|
||||
}
|
||||
|
||||
private function profileAccessToken(): string
|
||||
{
|
||||
if ($this->profileToken !== null) {
|
||||
return $this->profileToken;
|
||||
}
|
||||
$record = IntegrationSetting::find('parcel2go_oauth');
|
||||
$connection = $record?->settings ?? [];
|
||||
if (empty($connection['access_token']) || ($connection['client_id'] ?? null) !== ($this->settings->for('parcel2go')['profile_client_id'] ?? null)) {
|
||||
throw new RuntimeException('Parcel2Go account connection is missing or belongs to another API client.');
|
||||
}
|
||||
if (Carbon::parse($connection['expires_at'] ?? null)->isFuture()) {
|
||||
return $this->profileToken = $connection['access_token'];
|
||||
}
|
||||
if (empty($connection['refresh_token'])) {
|
||||
throw new RuntimeException('Parcel2Go account access expired. Reconnect the account.');
|
||||
}
|
||||
|
||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->post($this->settings->for('parcel2go')['auth_url'], [
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $connection['client_id'],
|
||||
'refresh_token' => $connection['refresh_token'],
|
||||
])->throw()->json();
|
||||
if (empty($tokens['access_token'])) {
|
||||
throw new RuntimeException('Parcel2Go did not refresh account access.');
|
||||
}
|
||||
$record->settings = array_replace($connection, [
|
||||
'access_token' => $tokens['access_token'],
|
||||
'refresh_token' => $tokens['refresh_token'] ?? $connection['refresh_token'],
|
||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60))->toIso8601String(),
|
||||
]);
|
||||
$record->save();
|
||||
|
||||
return $this->profileToken = $tokens['access_token'];
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
{
|
||||
if ($this->token !== null) {
|
||||
|
||||
Reference in New Issue
Block a user