From 1be66f099c3125d8529c3b4ac7bf2812a943fe92 Mon Sep 17 00:00:00 2001 From: Hickmeister <35031453+Hickmeister@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:27:07 +0100 Subject: [PATCH] Add Parcel2Go account consent with PKCE --- .../Controllers/EtsySettingsController.php | 6 +- .../IntegrationSettingsController.php | 1 + .../Parcel2GoConnectionController.php | 47 +++++++ app/Http/Controllers/ShippingController.php | 10 +- app/Services/Parcel2Go.php | 116 ++++++++++++++- resources/views/settings/index.blade.php | 10 +- routes/web.php | 3 + tests/Feature/IntegrationSettingsTest.php | 4 +- tests/Feature/Parcel2GoOAuthTest.php | 133 ++++++++++++++++++ 9 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/Parcel2GoConnectionController.php create mode 100644 tests/Feature/Parcel2GoOAuthTest.php diff --git a/app/Http/Controllers/EtsySettingsController.php b/app/Http/Controllers/EtsySettingsController.php index 96655d8..a51da80 100644 --- a/app/Http/Controllers/EtsySettingsController.php +++ b/app/Http/Controllers/EtsySettingsController.php @@ -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), diff --git a/app/Http/Controllers/IntegrationSettingsController.php b/app/Http/Controllers/IntegrationSettingsController.php index 063788e..e1ede92 100644 --- a/app/Http/Controllers/IntegrationSettingsController.php +++ b/app/Http/Controllers/IntegrationSettingsController.php @@ -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'], diff --git a/app/Http/Controllers/Parcel2GoConnectionController.php b/app/Http/Controllers/Parcel2GoConnectionController.php new file mode 100644 index 0000000..715661b --- /dev/null +++ b/app/Http/Controllers/Parcel2GoConnectionController.php @@ -0,0 +1,47 @@ +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.']); + } + } +} diff --git a/app/Http/Controllers/ShippingController.php b/app/Http/Controllers/ShippingController.php index c470f08..0b452e9 100644 --- a/app/Http/Controllers/ShippingController.php +++ b/app/Http/Controllers/ShippingController.php @@ -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'); diff --git a/app/Services/Parcel2Go.php b/app/Services/Parcel2Go.php index a04814b..a770cd2 100644 --- a/app/Services/Parcel2Go.php +++ b/app/Services/Parcel2Go.php @@ -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) { diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 131bb0a..318b1bb 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -56,6 +56,7 @@ + -

To request account history, sign in to Parcel2Go My Account → Settings → API and check this client's permissions. If my-profile is unavailable, email apihelp@parcel2go.com with your account email and Client ID. Ask them to enable my-profile for your live client. Never email the Client secret. After confirmation, select “Shipping and account history” here.

+

Keep basic public-api access for quotes and bookings. The separate Authorization Code client connects your own account for shipment history and labels.

+
+

Parcel2Go account consent

$integrations['parcel2go']['profile_connected'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['profile_connected']])>{{ $integrations['parcel2go']['profile_connected'] ? 'Connected' : 'Not connected' }}
+

Create an Authorization Code client in Parcel2Go. Set Application URL to {{ url('/') }} and Redirect URI to this exact address:

+ {{ $integrations['parcel2go']['callback_url'] }} +

Save its Client ID above, then sign in and approve my-profile access. The account tokens are encrypted in the database. If consent rejects this scope, contact apihelp@parcel2go.com.

+ @if($integrations['parcel2go']['profile_client_configured'])
@csrf
@endif +
diff --git a/routes/web.php b/routes/web.php index ebc2cfb..ba5a331 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Http\Controllers\DryerController; use App\Http\Controllers\EtsyController; use App\Http\Controllers\EtsySettingsController; use App\Http\Controllers\IntegrationSettingsController; +use App\Http\Controllers\Parcel2GoConnectionController; use App\Http\Controllers\PrinterController; use App\Http\Controllers\ShippingController; use App\Http\Controllers\UserController; @@ -29,6 +30,8 @@ Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void Route::post('/shipping/collections/book', [ShippingController::class, 'bookCollection'])->name('shipping.book-collection'); Route::post('/shipping/collections/reset', [ShippingController::class, 'resetBooking'])->name('shipping.reset-booking'); Route::get('/shipping/labels/{orderLineId}', [ShippingController::class, 'label'])->name('shipping.label'); + Route::post('/shipping/parcel2go/connect', [Parcel2GoConnectionController::class, 'connect'])->name('parcel2go.connect'); + Route::get('/shipping/parcel2go/callback', [Parcel2GoConnectionController::class, 'callback'])->name('parcel2go.callback'); Route::get('/etsy', [EtsyController::class, 'index'])->name('etsy.index'); Route::post('/etsy/connect', [EtsyController::class, 'connect'])->name('etsy.connect'); Route::get('/etsy/callback', [EtsyController::class, 'callback'])->name('etsy.callback'); diff --git a/tests/Feature/IntegrationSettingsTest.php b/tests/Feature/IntegrationSettingsTest.php index a894d14..2aee66a 100644 --- a/tests/Feature/IntegrationSettingsTest.php +++ b/tests/Feature/IntegrationSettingsTest.php @@ -108,9 +108,11 @@ class IntegrationSettingsTest extends TestCase $this->assertDatabaseCount('integration_settings', 0); $this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [ - 'scope' => 'public-api my-profile', 'collection_postcode' => 'SW1A 1AA', + 'scope' => 'public-api my-profile', 'collection_postcode' => 'SW1A 1AA', 'profile_client_id' => 'profile-client', ])->assertRedirect(route('settings.index')); $this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']); $this->assertSame('SW1A 1AA', IntegrationSetting::findOrFail('parcel2go')->settings['collection_postcode']); + $this->assertSame('profile-client', IntegrationSetting::findOrFail('parcel2go')->settings['profile_client_id']); + $this->actingAs($admin)->get(route('settings.index'))->assertOk()->assertDontSee('profile-client'); } } diff --git a/tests/Feature/Parcel2GoOAuthTest.php b/tests/Feature/Parcel2GoOAuthTest.php new file mode 100644 index 0000000..ab377df --- /dev/null +++ b/tests/Feature/Parcel2GoOAuthTest.php @@ -0,0 +1,133 @@ +configureParcel2Go(); + Http::preventStrayRequests(); + + $this->post(route('parcel2go.connect'))->assertRedirect(route('login')); + $this->actingAs(User::factory()->create())->post(route('parcel2go.connect'))->assertForbidden(); + Http::assertNothingSent(); + } + + public function test_consent_uses_pkce_and_exact_callback_url(): void + { + $this->configureParcel2Go(); + Http::preventStrayRequests(); + + $response = $this->actingAs(User::factory()->create(['role' => 'admin']))->post(route('parcel2go.connect')); + $query = []; + parse_str(parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query); + $pending = session('parcel2go_oauth'); + + $this->assertSame('https://auth.example.test/connect/authorize', strtok($response->headers->get('Location'), '?')); + $this->assertSame('profile-client', $query['client_id']); + $this->assertSame(route('parcel2go.callback'), $query['redirect_uri']); + $this->assertSame('public-api my-profile offline_access', $query['scope']); + $this->assertSame('S256', $query['code_challenge_method']); + $this->assertSame($pending['state'], $query['state']); + $this->assertSame(rtrim(strtr(base64_encode(hash('sha256', $pending['verifier'], true)), '+/', '-_'), '='), $query['code_challenge']); + Http::assertNothingSent(); + } + + public function test_callback_rejects_bad_state_without_exchanging_a_code(): void + { + $this->configureParcel2Go(); + Http::preventStrayRequests(); + + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->withSession(['parcel2go_oauth' => ['state' => 'expected', 'verifier' => 'verifier', 'started' => time()]]) + ->get(route('parcel2go.callback', ['state' => 'wrong', 'code' => 'code'])) + ->assertRedirect(route('settings.index'))->assertSessionHasErrors('parcel2go'); + $this->assertDatabaseMissing('integration_settings', ['provider' => 'parcel2go_oauth']); + Http::assertNothingSent(); + } + + public function test_callback_stores_encrypted_tokens_and_uses_them_for_account_history(): void + { + $this->configureParcel2Go(); + Http::preventStrayRequests(); + Http::fake([ + 'https://auth.example.test/connect/token' => Http::response([ + 'access_token' => 'profile-token', 'refresh_token' => 'refresh-token', 'expires_in' => 3600, + ]), + 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [['OrderLineId' => 123]]]), + ]); + + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->withSession(['parcel2go_oauth' => ['state' => 'expected', 'verifier' => 'verifier', 'started' => time()]]) + ->get(route('parcel2go.callback', ['state' => 'expected', 'code' => 'code'])) + ->assertRedirect(route('settings.index'))->assertSessionHas('status'); + $connection = IntegrationSetting::findOrFail('parcel2go_oauth'); + $this->assertSame('profile-token', $connection->settings['access_token']); + $this->assertStringNotContainsString('profile-token', $connection->getRawOriginal('settings')); + Http::assertSent(fn ($request) => $request->url() === 'https://auth.example.test/connect/token' + && $request['grant_type'] === 'authorization_code' && $request['code_verifier'] === 'verifier'); + $this->assertSame([['OrderLineId' => 123]], app(Parcel2Go::class)->fetch('me/orders/detail')['Orders']); + Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/me/orders/detail' + && $request->hasHeader('Authorization', 'Bearer profile-token')); + } + + public function test_expired_account_token_is_refreshed_before_history_request(): void + { + $this->configureParcel2Go(); + IntegrationSetting::create(['provider' => 'parcel2go_oauth', 'settings' => [ + 'client_id' => 'profile-client', 'access_token' => 'old-token', + 'refresh_token' => 'old-refresh', 'expires_at' => now()->subMinute()->toIso8601String(), + ]]); + Http::preventStrayRequests(); + Http::fake([ + 'https://auth.example.test/connect/token' => Http::response([ + 'access_token' => 'new-token', 'refresh_token' => 'new-refresh', 'expires_in' => 3600, + ]), + 'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => []]), + ]); + + $this->assertSame([], app(Parcel2Go::class)->fetch('me/orders/detail')['Orders']); + $this->assertSame('new-refresh', IntegrationSetting::findOrFail('parcel2go_oauth')->settings['refresh_token']); + Http::assertSent(fn ($request) => $request->url() === 'https://auth.example.test/connect/token' + && $request['grant_type'] === 'refresh_token' && $request['refresh_token'] === 'old-refresh'); + Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/me/orders/detail' + && $request->hasHeader('Authorization', 'Bearer new-token')); + } + + public function test_callback_does_not_save_a_token_without_history_permission(): void + { + $this->configureParcel2Go(); + Http::preventStrayRequests(); + Http::fake([ + 'https://auth.example.test/connect/token' => Http::response(['access_token' => 'limited-token', 'expires_in' => 3600]), + 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403), + ]); + + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->withSession(['parcel2go_oauth' => ['state' => 'expected', 'verifier' => 'verifier', 'started' => time()]]) + ->get(route('parcel2go.callback', ['state' => 'expected', 'code' => 'code'])) + ->assertRedirect(route('settings.index'))->assertSessionHasErrors('parcel2go'); + $this->assertDatabaseMissing('integration_settings', ['provider' => 'parcel2go_oauth']); + } + + private function configureParcel2Go(): void + { + config()->set('services.parcel2go', [ + 'client_id' => 'basic-client', 'client_secret' => 'basic-secret', + 'profile_client_id' => 'profile-client', + 'auth_url' => 'https://auth.example.test/connect/token', + 'base_url' => 'https://shipping.example.test/api', + 'scope' => 'public-api', + ]); + } +}