Add Parcel2Go account consent with PKCE
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 26s

This commit is contained in:
Hickmeister
2026-10-11 17:27:07 +01:00
parent 1c80811713
commit 1be66f099c
9 changed files with 321 additions and 9 deletions
+114 -2
View File
@@ -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) {