From 06bd840f20b729082d71875c27688248e8877b48 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 12 Oct 2026 16:05:10 +0200 Subject: [PATCH 1/8] Add Auth and ZammadClient::create(), fix outdated v3 docs (#168) Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 61 +++++++++---- README.md | 66 ++++++++++---- ...ative-clients.md => custom-http-client.md} | 71 ++++++--------- docs/migration-v3-examples.md | 26 +++--- docs/migration-v3.md | 6 +- .../cookbook/{00-plain.php => 00-connect.php} | 16 ++-- examples/cookbook/01-quick-start.php | 6 +- examples/cookbook/02-crud.php | 6 +- examples/cookbook/03-listing.php | 6 +- examples/cookbook/04-updates.php | 6 +- examples/cookbook/05-impersonation.php | 6 +- examples/cookbook/06-search.php | 6 +- examples/cookbook/07-custom-http-client.php | 65 ++++++++++++++ .../{07-laravel.php => 08-laravel.php} | 0 examples/cookbook/09-slim.php | 44 --------- .../{08-symfony.php => 09-symfony.php} | 0 examples/cookbook/10-slim.php | 78 ++++++++++++++++ examples/cookbook/README.md | 21 ++++- src/Bridge/LaravelServiceProvider.php | 8 +- src/Bridge/SymfonyBundle.php | 8 +- src/Core/Auth.php | 89 +++++++++++++++++++ src/Core/Transport/RequestHandler.php | 13 +++ src/Factory/GuzzleClientFactory.php | 43 +++++++-- src/ZammadClient.php | 30 ++++++- test/Unit/Core/AuthTest.php | 82 +++++++++++++++++ test/Unit/Core/RequestHandlerTest.php | 58 ++++++++++++ .../Core/Traits/CreatesRequestHandler.php | 16 +++- test/Unit/GuzzleClientFactoryTest.php | 62 +++++++++++++ 28 files changed, 707 insertions(+), 192 deletions(-) rename docs/{alternative-clients.md => custom-http-client.md} (66%) rename examples/cookbook/{00-plain.php => 00-connect.php} (52%) create mode 100644 examples/cookbook/07-custom-http-client.php rename examples/cookbook/{07-laravel.php => 08-laravel.php} (100%) delete mode 100644 examples/cookbook/09-slim.php rename examples/cookbook/{08-symfony.php => 09-symfony.php} (100%) create mode 100644 examples/cookbook/10-slim.php create mode 100644 src/Core/Auth.php create mode 100644 test/Unit/Core/AuthTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a22ca2a..70cbfa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,42 +1,67 @@ # Changelog -## [3.0.0] — unreleased +## [3.2.0] — unreleased + +### Added +- `Auth` — credentials as a transport-independent value: `Auth::token()`, `Auth::oauth2()`, `Auth::basic()`. The constructor is private, so the set of valid schemes is closed by construction; empty tokens and usernames are rejected on creation instead of surfacing as a 401 later +- `ZammadClient::create(string $url, Auth $auth, ?ConnectionConfig $config = null)` — one-line entry point for the default (Guzzle) transport +- `RequestHandler` accepts an optional `auth:` argument and applies the `Authorization` header per request + +### Deprecated +- `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` — use the constructor with an `Auth` value instead: `new GuzzleClientFactory($url, Auth::token($token))`. They keep working and will be removed in 4.0 + +### Changed +- Authentication no longer depends on the HTTP client supporting client-level default headers. It is applied by `RequestHandler` when composing each request, so any PSR-18 transport authenticates the same way — the hand-written `AuthMiddleware` that the docs previously required for Symfony's `Psr18Client` is obsolete +- `GuzzleClientFactory` takes `(string $url, Auth $auth, ?ConnectionConfig $config)` and no longer sets an `Authorization` default header on the Guzzle client. Its constructor was private before, so no caller is affected +- Documentation: corrected examples that referenced `ZammadClient::withToken()` and the impersonation methods removed in 3.0.0 ([#168](https://github.com/zammad/zammad-api-client-php/issues/168)) + +`GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`; they are deprecated as of this release but remain functional until 4.0. + +**One behavioural change to be aware of:** an empty or whitespace-only token (or username) is now rejected with an `InvalidArgumentException` at construction, where it previously produced a client that failed with HTTP 401 on its first request. Code that builds a client from an unset environment variable will fail earlier and more clearly than before — including the Laravel and Symfony bridges, which fall back to an empty token when `ZAMMAD_TOKEN` is not set. + +## [3.1.0] — 2026-10-02 + +### Added +- `RoleRepository` / `RoleDTO` for `/api/v1/roles`, accessible via `$client->role()` — resolve role names to the IDs needed for `UserDTO::$role_ids`; `permission_ids`, `group_ids` and `default_at_signup` are writable on create and update ([#167](https://github.com/zammad/zammad-api-client-php/pull/167)) + +### Changed +- Documentation: README restructured for newcomers (install / Packagist section), cookbook setup instructions and a broken personal access token link fixed + +## [3.0.0] — 2026-09-11 ### Added -- `RoleRepository` / `RoleDTO` for `/api/v1/roles`, accessible via `$client->role()` — resolve role names to the IDs needed for `UserDTO::$role_ids`; `permission_ids` and `group_ids` are writable on create and update - PSR-18 / PSR-17 compliant HTTP layer (`RequestHandler`, `RetryAfterMiddleware`) -- Typed DTOs for all 11 resources (`Ticket`, `User`, `Organization`, `Group`, `Role`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`) +- Typed DTOs for all 10 resources (`Ticket`, `User`, `Organization`, `Group`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`) - Repository pattern with generator-based pagination (`AbstractRepository`) - `patch()` method for partial updates via `array` or `TicketUpdateDTO` -- Proper exception hierarchy: `AuthenticationException`, `NotFoundException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException` -- `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods +- Proper exception hierarchy: `AuthenticationException`, `ForbiddenException`, `NotFoundException`, `BadRequestException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException` +- `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods (namespace `ZammadAPIClient\Factory`) - PSR-3 Logger injection - OpenAPI schema validation in integration tests - Framework bridges for Laravel and Symfony - `TicketArticleType` enum for article channel types -- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` baut den `RequestHandler` -- `ImpersonationHandler` — stateless decorator fur API-Impersonation -- `ImpersonationHandler` — scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))` +- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` builds the `RequestHandler` +- `ImpersonationHandler` — stateless decorator for API impersonation, scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))` ### Changed - **Breaking:** PHP >= 8.1 required -- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (Namespace `ZammadAPIClient\Factory`). +- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (namespace `ZammadAPIClient\Factory`). Non-Guzzle via `new ZammadClient(new RequestHandler($psr18Client, $psr17Factory, $url))`. -- **Breaking:** `ZammadClient::withClient()` entfernt; `ZammadClient` constructor akzeptiert `RequestHandlerInterface|ClientFactoryInterface` -- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` entfernt. +- **Breaking:** `ZammadClient::withClient()` removed; the `ZammadClient` constructor accepts `RequestHandlerInterface|ClientFactoryInterface` +- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` removed. Impersonation via `new ZammadClient(new ImpersonationHandler($handler, $userId))`. -- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` entfernt. - `RequestHandler` halt keinen Impersonation-State mehr. -- **Breaking:** `RequestHandlerInterface::getRaw()` Signatur um `$headers`-Parameter erweitert +- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` removed. + `RequestHandler` no longer holds any impersonation state. +- **Breaking:** `RequestHandlerInterface::getRaw()` signature extended by a `$headers` parameter - **Breaking:** `Client` class replaced by `ZammadClient` with repository accessors - **Breaking:** Array return values replaced by typed DTOs -- **Breaking:** Guzzle used as default transport; `withClient()` supports any PSR-18 client -- `getListKey()` in Repository-Klassen nicht mehr abstrakt; Default ist `$this->resourcePath` +- **Breaking:** Guzzle used as default transport; any PSR-18 client can be injected via `RequestHandler` +- `getListKey()` in repository classes is no longer abstract; it defaults to `$this->resourcePath` - Typed repository accessors (`$client->ticket()`, `$client->user()`, …) are the recommended public API; the underlying `repo()` method is `@internal`. ### Removed -- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, jetzt entfernt -- Shared mutable Impersonation-State aus `RequestHandler` entfernt +- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, now removed +- Shared mutable impersonation state in `RequestHandler` --- diff --git a/README.md b/README.md index 595a6b3..9c99117 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ resolve `ZammadClient` from the container instead of constructing it by hand. ```php require __DIR__ . '/vendor/autoload.php'; // skip if your framework autoloads +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -$client = ZammadClient::withToken('https://zammad.example', 'your-token'); +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); ``` **3. Try it** — fetch, create, update and search tickets: @@ -86,10 +87,11 @@ Repositories are accessed via typed accessors: `$client->ticket()`, `$client->us ### Connecting ```php +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -// ZammadClient normalizes the URL — /api/v1 is appended automatically -$client = ZammadClient::withToken('https://zammad.example', 'your-token'); +// The URL is normalized — /api/v1 is appended automatically +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); ``` ### Fetching @@ -361,27 +363,49 @@ For user and organization CRUD examples, refer to the integration tests in [`tes ## Authentication +Credentials are a value of their own. `Auth` has a private constructor, so the +only way to build one is through the three named constructors below — an empty +token or username is rejected right there instead of surfacing as a 401 later. + ```php +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\ConnectionConfig; +use ZammadAPIClient\ZammadClient; + // Token — sends Authorization: Token token=your-token (Zammad personal access token) -ZammadClient::withToken($url, 'your-token'); +$client = ZammadClient::create($url, Auth::token('your-token')); // OAuth2 — sends Authorization: Bearer your-oauth-token (OAuth2 access token) -ZammadClient::withOAuth2($url, 'your-oauth-token'); +$client = ZammadClient::create($url, Auth::oauth2('your-oauth-token')); // Basic Auth — sends Authorization: Basic base64(user:pass) -ZammadClient::withBasicAuth($url, 'admin@example.com', 'test'); +$client = ZammadClient::create($url, Auth::basic('admin@example.com', 'test')); // Options -ZammadClient::withToken($url, 'your-token', +$client = ZammadClient::create($url, Auth::token('your-token'), new ConnectionConfig(verifySsl: false, maxRetries: 5), ); // Pass a PSR-3 Logger to log HTTP requests and retries -ZammadClient::withToken($url, 'your-token', +$client = ZammadClient::create($url, Auth::token('your-token'), new ConnectionConfig(logger: $myLogger), ); ``` +The credential is applied per request by the transport layer, so it works the +same with any PSR-18 client. Guzzle is only the default — to use another one, +build the handler yourself: + +```php +use ZammadAPIClient\Core\Transport\RequestHandler; + +$handler = new RequestHandler($psr18Client, $psr17Factory, $url, auth: Auth::token('your-token')); +$client = new ZammadClient($handler); +``` + +Runnable example: [`examples/cookbook/07-custom-http-client.php`](examples/cookbook/07-custom-http-client.php). +Container wiring and security notes: [Custom HTTP client](docs/custom-http-client.md). + | ConnectionConfig property | Type | Default | Description | |---------------------------|------|---------|-------------| | `maxRetries` | `int` | `3` | Number of retries on HTTP 429 before throwing `RateLimitException` | @@ -396,8 +420,8 @@ Prefer to resolve `ZammadClient` from your framework's container instead of constructing it by hand? Bridges are provided for Laravel and Symfony — configure credentials once, then inject the same shared client everywhere. -Full runnable setups: [`examples/cookbook/07-laravel.php`](examples/cookbook/07-laravel.php) -and [`examples/cookbook/08-symfony.php`](examples/cookbook/08-symfony.php). +Full runnable setups: [`examples/cookbook/08-laravel.php`](examples/cookbook/08-laravel.php) +and [`examples/cookbook/09-symfony.php`](examples/cookbook/09-symfony.php). ### Laravel @@ -705,18 +729,24 @@ $client->ticket()->patch(42, new TicketUpdateDTO( ## Impersonation +Impersonation is scoped to its own client instance — wrap the handler in an +`ImpersonationHandler`. The original client keeps acting as the authenticated +user, so there is no state to reset and nothing can leak between calls. + ```php -// Temporary — auto-cleanup via finally +use ZammadAPIClient\Core\Transport\ImpersonationHandler; + // Accepts user ID (int), login, or email (string) -$client->performOnBehalfOf(1, fn() => $client->ticket()->find(42)); -$client->performOnBehalfOf('agent@example.com', fn() => $client->ticket()->find(42)); +$asUser = new ZammadClient(new ImpersonationHandler($client->getHandler(), 1)); +$ticket = $asUser->ticket()->find(42); + +$asAgent = new ZammadClient(new ImpersonationHandler($client->getHandler(), 'agent@example.com')); -// Persistent — same parameter types -$client->setOnBehalfOfUser(1); -// ... all subsequent requests act as user #1 ... -$client->unsetOnBehalfOfUser(); +// $client itself is unaffected ``` +A runnable recipe is in [`examples/cookbook/05-impersonation.php`](examples/cookbook/05-impersonation.php). + ## Development ```bash @@ -752,7 +782,7 @@ v2 reference documentation is preserved in [docs/v2-reference.md](docs/v2-refere | v2 | v3 | |----|----| -| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, ...)` | +| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::create($url, Auth::token(...))` | | `$client->resource(TICKET)->get(1)` | `$client->ticket()->find(1)` | | `$ticket->getValue('title')` | `$ticket->title` | | `$ticket->getValues()` | `$ticket->toArray()` | diff --git a/docs/alternative-clients.md b/docs/custom-http-client.md similarity index 66% rename from docs/alternative-clients.md rename to docs/custom-http-client.md index 186ddfc..b5d1e60 100644 --- a/docs/alternative-clients.md +++ b/docs/custom-http-client.md @@ -1,9 +1,11 @@ -# Using an Alternative PSR-18 HTTP Client +# Using a Custom HTTP Client -The `ZammadClient` constructor accepts any PSR-18 `ClientInterface` implementation via `RequestHandlerInterface`. The factory methods (`withToken()`, `withOAuth2()`, `withBasicAuth()`) use Guzzle by default for convenience, but you can inject any PSR-18 client directly. +The `ZammadClient` constructor accepts any PSR-18 `ClientInterface` implementation via `RequestHandlerInterface`. `ZammadClient::create()` uses Guzzle for convenience, but nothing is lost by building the handler yourself: credentials, retry behaviour and logging are configured the same way either side. Rate-limit retry (HTTP 429) is handled automatically by `RequestHandler` — no manual wiring needed. +A runnable version of the setup below is in [`examples/cookbook/07-custom-http-client.php`](../examples/cookbook/07-custom-http-client.php). + ## Symfony HttpClient Requires `composer require symfony/http-client nyholm/psr7` (or `guzzlehttp/psr7` for PSR-17 factories). @@ -12,7 +14,8 @@ Requires `composer require symfony/http-client nyholm/psr7` (or `guzzlehttp/psr7 use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Psr18Client; use GuzzleHttp\Psr7\HttpFactory; -use ZammadAPIClient\Core\RequestHandler; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\ZammadClient; $handler = new RequestHandler( @@ -25,51 +28,26 @@ $handler = new RequestHandler( new HttpFactory(), 'https://zammad.example', maxRetries: 3, + auth: Auth::token('your-zammad-api-token'), ); $client = new ZammadClient($handler); ``` -### With Authentication Headers - -Symfony's `Psr18Client` does not accept global default headers. Instead, configure them on the `RequestHandler` by passing them via `$options` on each `request()` call, or use a PSR-18 middleware to inject the `Authorization` header. - -**Option A — Inject headers via a PSR-18 decorator middleware:** - -```php -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; - -final class AuthMiddleware implements ClientInterface -{ - public function __construct( - private ClientInterface $next, - private string $token, - ) {} - - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->next->sendRequest( - $request->withHeader('Authorization', "Token token={$this->token}") - ); - } -} - -$http = new AuthMiddleware( - new Psr18Client(HttpClient::create(['timeout' => 30])), - 'your-zammad-api-token', -); +### Authentication -// Rate-limit retry is handled automatically by RequestHandler -$handler = new RequestHandler($http, new HttpFactory(), 'https://zammad.example'); -``` +Pass an `Auth` to the `RequestHandler`, as above. It applies the `Authorization` +header to each outgoing request, so it does not rely on the PSR-18 client +supporting client-level default headers — PSR-18 has no such concept, and +Symfony's `Psr18Client` does not offer one. -**Option B — Build the Guzzle client with auth headers, then swap to Symfony later:** +`Auth::token()`, `Auth::oauth2()` and `Auth::basic()` are the available schemes. +If you need credentials that change at runtime (an OAuth2 token being refreshed, +say), wrap your PSR-18 client in a decorator that sets the header per request and +leave `auth` unset. -If you're migrating away from Guzzle, start with `ZammadClient::withToken()` and refactor step by step. +If you're migrating away from Guzzle, start with `ZammadClient::create()` and +refactor step by step — the handler built above supports exactly the same options. ## Laravel @@ -79,7 +57,8 @@ The `LaravelServiceProvider` binds `ZammadClient` as a singleton. To use Symfony use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Psr18Client; use GuzzleHttp\Psr7\HttpFactory; -use ZammadAPIClient\Core\RequestHandler; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\ZammadClient; class AppServiceProvider extends ServiceProvider @@ -108,11 +87,17 @@ The `SymfonyBundle` can be extended or replaced entirely by defining your own se ```yaml # config/services.yaml services: + app.zammad_auth: + class: ZammadAPIClient\Core\Auth + factory: ['ZammadAPIClient\Core\Auth', 'token'] + arguments: + $token: '%env(ZAMMAD_TOKEN)%' + ZammadAPIClient\ZammadClient: - factory: ['ZammadAPIClient\ZammadClient', 'withToken'] + factory: ['ZammadAPIClient\ZammadClient', 'create'] arguments: $url: '%env(ZAMMAD_URL)%' - $token: '%env(ZAMMAD_TOKEN)%' + $auth: '@app.zammad_auth' # Or with a custom PSR-18 stack: app.zammad_client: diff --git a/docs/migration-v3-examples.md b/docs/migration-v3-examples.md index 02d55ef..00daa43 100644 --- a/docs/migration-v3-examples.md +++ b/docs/migration-v3-examples.md @@ -14,11 +14,12 @@ $client = new Client([ **v3:** ```php +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -$client = ZammadClient::withToken( +$client = ZammadClient::create( 'https://zammad.example', - 'your-token', + Auth::token('your-token'), ); ``` @@ -35,9 +36,9 @@ new Client(['url' => $url, 'username' => '...', 'password' => '...']); // Basic **v3:** ```php -ZammadClient::withToken($url, '...'); // Token -ZammadClient::withOAuth2($url, '...'); // OAuth2 -ZammadClient::withBasicAuth($url, '...', '...'); // Basic +ZammadClient::create($url, Auth::token('...')); // Token +ZammadClient::create($url, Auth::oauth2('...')); // OAuth2 +ZammadClient::create($url, Auth::basic('...', '...')); // Basic ``` --- @@ -342,18 +343,15 @@ $client->unsetOnBehalfOfUser(); **v3:** ```php -// Temporary (auto-cleanup on callback return or exception) -$client->performOnBehalfOf(1, function () use ($client) { - $client->ticket()->find(42); -}); +use ZammadAPIClient\Core\Transport\ImpersonationHandler; -// Persistent -$client->setOnBehalfOfUser(1); -// ... API calls ... -$client->unsetOnBehalfOfUser(); +$asUser = new ZammadClient(new ImpersonationHandler($client->getHandler(), 1)); +$asUser->ticket()->find(42); + +// $client itself keeps acting as the authenticated user ``` -> **Breaking:** v2 used a username string (`'myuser'`). v3 requires a **numeric user ID**. Use `$client->user()->search('login:myuser')` to resolve a username to an ID if needed. +> **Breaking:** v2 toggled impersonation on the client itself, so the state was global and had to be reset. v3 scopes it to a separate client instance — there is nothing to unset and nothing can leak between calls. A user ID, login or email all work (`int|string`). --- diff --git a/docs/migration-v3.md b/docs/migration-v3.md index 484d0fa..113c81a 100644 --- a/docs/migration-v3.md +++ b/docs/migration-v3.md @@ -4,7 +4,7 @@ | v2 | v3 | |---|---| -| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, $token)` | +| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::create($url, Auth::token($token))` | | `$client->resource(TICKET)` | `$client->ticket()` | | `$ticket->get(1)` | `$client->ticket()->find(1)` | | `$ticket->getValue('title')` | `$ticket->title` | @@ -41,9 +41,9 @@ $client = new \ZammadAPIClient\Client([ ]); // v3 -$client = \ZammadAPIClient\ZammadClient::withToken( +$client = \ZammadAPIClient\ZammadClient::create( 'https://zammad.example', - 'your-token' + \ZammadAPIClient\Core\Auth::token('your-token') ); ``` diff --git a/examples/cookbook/00-plain.php b/examples/cookbook/00-connect.php similarity index 52% rename from examples/cookbook/00-plain.php rename to examples/cookbook/00-connect.php index eb7a6cc..96e395e 100644 --- a/examples/cookbook/00-plain.php +++ b/examples/cookbook/00-connect.php @@ -1,17 +1,21 @@ ['User-Agent' => 'Zammad API PHP'], + 'timeout' => 30, + ]), +); + +$handler = new RequestHandler( + $httpClient, + new Psr17Factory(), + $url, + maxRetries: 3, + auth: Auth::token($token), +); + +$client = new ZammadClient($handler); + +echo "Connected to {$url} (Symfony HttpClient)\n"; +echo "Ticket #1: " . $client->ticket()->find(1)->title . "\n"; diff --git a/examples/cookbook/07-laravel.php b/examples/cookbook/08-laravel.php similarity index 100% rename from examples/cookbook/07-laravel.php rename to examples/cookbook/08-laravel.php diff --git a/examples/cookbook/09-slim.php b/examples/cookbook/09-slim.php deleted file mode 100644 index 4baae3a..0000000 --- a/examples/cookbook/09-slim.php +++ /dev/null @@ -1,44 +0,0 @@ -create([ - 'headers' => [ - 'User-Agent' => 'Zammad API PHP', - 'Authorization' => "Token token={$token}", - ], - ]), -); - -$psr17 = new Psr17Factory(); - -$handler = new RequestHandler($symfonyClient, $psr17, $url); -$client = new ZammadClient($handler); - -echo "Connected to {$url} (Symfony HttpClient)\n"; -echo "Ticket #1: " . $client->ticket()->find(1)->title . "\n"; diff --git a/examples/cookbook/08-symfony.php b/examples/cookbook/09-symfony.php similarity index 100% rename from examples/cookbook/08-symfony.php rename to examples/cookbook/09-symfony.php diff --git a/examples/cookbook/10-slim.php b/examples/cookbook/10-slim.php new file mode 100644 index 0000000..7bb9149 --- /dev/null +++ b/examples/cookbook/10-slim.php @@ -0,0 +1,78 @@ +addDefinitions([ + * ZammadClient::class => fn (): ZammadClient => ZammadClient::create( + * $_ENV['ZAMMAD_URL'], + * Auth::token($_ENV['ZAMMAD_TOKEN']), + * ), + * ]); + * + * AppFactory::setContainer($builder->build()); + * $app = AppFactory::create(); + * $app->addErrorMiddleware(true, true, true); + * + * $app->get('/tickets/{id}', ShowTicket::class); + * $app->run(); + * + * The action gets the client injected — PHP-DI autowires the constructor, so + * nothing else has to know where the client comes from: + * + * use Psr\Http\Message\ResponseInterface; + * use Psr\Http\Message\ServerRequestInterface; + * use ZammadAPIClient\Exceptions\NotFoundException; + * use ZammadAPIClient\ZammadClient; + * + * final class ShowTicket + * { + * public function __construct(private ZammadClient $zammad) {} + * + * public function __invoke( + * ServerRequestInterface $request, + * ResponseInterface $response, + * array $args, + * ): ResponseInterface { + * try { + * $ticket = $this->zammad->ticket()->find((int) $args['id']); + * } catch (NotFoundException) { + * return $response->withStatus(404); + * } + * + * $response->getBody()->write((string) json_encode($ticket->toArray())); + * + * return $response->withHeader('Content-Type', 'application/json'); + * } + * } + * + * Definitions are lazy: the client is built the first time something asks for + * it, not on every request. Because Auth rejects an empty token, a missing + * ZAMMAD_TOKEN surfaces as an InvalidArgumentException at that point rather + * than as a confusing 401 later. + * + * To use a different HTTP client here, return a client built from your own + * RequestHandler instead — see 07-custom-http-client.php. + */ + +declare(strict_types=1); + +// Wiring happens in your Slim application — this file is for reference. +fwrite(STDOUT, "See docblock above for Slim 4 setup instructions.\n"); diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md index 49ea9e2..52a00e9 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -12,18 +12,31 @@ export ZAMMAD_TOKEN=your-token ## Recipes +**Getting started** + | File | Description | |---|---| -| [`00-plain.php`](00-plain.php) | Guzzle setup (used by recipes 01-06). Standalone, copy-paste-ready. | +| [`00-connect.php`](00-connect.php) | Connect to Zammad. Standalone, copy-paste-ready; recipes 01-06 repeat this block. | | [`01-quick-start.php`](01-quick-start.php) | Client instantiation + find ticket #1. | | [`02-crud.php`](02-crud.php) | Create, read, and delete tickets. | | [`03-listing.php`](03-listing.php) | `all()` streaming, `list()` pagination, `totalCount()`. | | [`04-updates.php`](04-updates.php) | `patch()` partial update + `TicketUpdateDTO`. | | [`05-impersonation.php`](05-impersonation.php) | `ImpersonationHandler` for scoped on-behalf-of requests. | | [`06-search.php`](06-search.php) | Full-text search via `search()` and `searchList()`. | -| [`07-laravel.php`](07-laravel.php) | Laravel service container setup. | -| [`08-symfony.php`](08-symfony.php) | Symfony bundle setup. | -| [`09-slim.php`](09-slim.php) | Non-Guzzle setup (Symfony HttpClient + Nyholm PSR-17). | + +**Custom HTTP client** + +| File | Description | +|---|---| +| [`07-custom-http-client.php`](07-custom-http-client.php) | Use your own PSR-18 client instead of Guzzle (example: Symfony HttpClient + Nyholm PSR-17). See [docs/custom-http-client.md](../../docs/custom-http-client.md). | + +**Framework integration** + +| File | Description | +|---|---| +| [`08-laravel.php`](08-laravel.php) | Laravel service container setup. | +| [`09-symfony.php`](09-symfony.php) | Symfony bundle setup. | +| [`10-slim.php`](10-slim.php) | Slim 4 / PHP-DI setup. No bridge needed — the pattern for any PSR-11 container. | ## Run diff --git a/src/Bridge/LaravelServiceProvider.php b/src/Bridge/LaravelServiceProvider.php index 90f3c0e..6f363b1 100644 --- a/src/Bridge/LaravelServiceProvider.php +++ b/src/Bridge/LaravelServiceProvider.php @@ -5,7 +5,7 @@ namespace ZammadAPIClient\Bridge; use Illuminate\Support\ServiceProvider; -use ZammadAPIClient\Factory\GuzzleClientFactory; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; /** @@ -71,7 +71,7 @@ * 2. `ZAMMAD_URL` / `ZAMMAD_TOKEN` environment variables (`.env`) * 3. Built-in defaults (`http://127.0.0.1:8098`, empty token) * - * @see ZammadClient::withToken() + * @see ZammadClient::create() */ final class LaravelServiceProvider extends ServiceProvider { @@ -88,9 +88,7 @@ public function register(): void $url = config('zammad.url') ?: env('ZAMMAD_URL', 'http://127.0.0.1:8098'); $token = config('zammad.token') ?: env('ZAMMAD_TOKEN', ''); - return new ZammadClient( - GuzzleClientFactory::withToken($url, $token), - ); + return ZammadClient::create($url, Auth::token($token)); }); } } diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php index 8f4a138..c4df0b5 100644 --- a/src/Bridge/SymfonyBundle.php +++ b/src/Bridge/SymfonyBundle.php @@ -7,7 +7,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; -use ZammadAPIClient\Factory\GuzzleClientFactory; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; /** @@ -66,7 +66,7 @@ * // $container->get('zammad_client')->ticket()->all(); * ``` * - * @see ZammadClient::withToken() + * @see ZammadClient::create() */ final class SymfonyBundle extends Bundle { @@ -88,9 +88,7 @@ public function load(array $configs, ContainerBuilder $container): void $url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098'); $token = $resolved['token'] ?? (string) ($_ENV['ZAMMAD_TOKEN'] ?? ''); - $client = new ZammadClient( - GuzzleClientFactory::withToken($url, $token), - ); + $client = ZammadClient::create($url, Auth::token($token)); $container->set(ZammadClient::class, $client); $container->setAlias('zammad_client', ZammadClient::class); $container->registerForAutoconfiguration(ZammadClient::class)->setAutowired(true); diff --git a/src/Core/Auth.php b/src/Core/Auth.php new file mode 100644 index 0000000..f35163d --- /dev/null +++ b/src/Core/Auth.php @@ -0,0 +1,89 @@ +`. + * + * @throws InvalidArgumentException If $token is empty. + */ + public static function token(string $token): self + { + return new self('Token token=' . self::requireNonEmpty($token, 'API token')); + } + + /** + * OAuth2 access token — sends `Authorization: Bearer `. + * + * @throws InvalidArgumentException If $token is empty. + */ + public static function oauth2(string $token): self + { + return new self('Bearer ' . self::requireNonEmpty($token, 'OAuth2 token')); + } + + /** + * HTTP basic auth — sends `Authorization: Basic base64(user:password)`. + * + * An empty password is permitted (RFC 7617); the username is required. + * + * @throws InvalidArgumentException If $user is empty. + */ + public static function basic(string $user, string $password): self + { + return new self( + 'Basic ' . base64_encode(self::requireNonEmpty($user, 'Username') . ':' . $password), + ); + } + + /** + * Returns the ready-to-send `Authorization` header value. + * + * @internal Consumed by the transport layer; not part of the public API. + */ + public function header(): string + { + return $this->header; + } + + /** + * Keeps the credential out of var_dump(), print_r() and stack traces. + * + * @return array + */ + public function __debugInfo(): array + { + return ['header' => '***redacted***']; + } + + private static function requireNonEmpty(string $value, string $label): string + { + return trim($value) === '' + ? throw new InvalidArgumentException("{$label} must not be empty.") + : $value; + } +} diff --git a/src/Core/Transport/RequestHandler.php b/src/Core/Transport/RequestHandler.php index 65a4b28..be48b1c 100644 --- a/src/Core/Transport/RequestHandler.php +++ b/src/Core/Transport/RequestHandler.php @@ -12,6 +12,7 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; use ZammadAPIClient\Exceptions\AuthenticationException; use ZammadAPIClient\Exceptions\BadRequestException; @@ -46,6 +47,7 @@ final class RequestHandler implements RequestHandlerInterface private StreamFactoryInterface $streamFactory; private string $baseUrl; private LoggerInterface $logger; + private ?Auth $auth; private ?ResponseInterface $lastResponse = null; /** @@ -54,6 +56,9 @@ final class RequestHandler implements RequestHandlerInterface * @param string $baseUrl Base URL; the API prefix (`/api/v1`) is appended if missing. * @param LoggerInterface $logger PSR-3 logger; defaults to NullLogger. * @param int $maxRetries Max retries on HTTP 429 (0 = disable). + * @param Auth|null $auth Credentials; sent as `Authorization` on every request. + * Null leaves the header unset, e.g. when the PSR-18 client + * already adds it. */ public function __construct( ClientInterface $httpClient, @@ -61,6 +66,7 @@ public function __construct( string $baseUrl, LoggerInterface $logger = new NullLogger(), int $maxRetries = 3, + ?Auth $auth = null, ) { if (!$factory instanceof StreamFactoryInterface) { throw new InvalidArgumentException( @@ -74,6 +80,7 @@ public function __construct( $this->streamFactory = $factory; $this->baseUrl = self::normalizeBaseUrl($baseUrl); $this->logger = $logger; + $this->auth = $auth; } /** @@ -235,6 +242,12 @@ private function dispatch(string $method, string $uri, array $options): Response $request = $this->requestFactory->createRequest($method, $fullUri) ->withHeader('Accept', 'application/json'); + // Set before the per-request headers below so callers keep the last word, + // matching how a client-level default header behaves. + if ($this->auth !== null) { + $request = $request->withHeader('Authorization', $this->auth->header()); + } + if (isset($options['headers']) && is_array($options['headers'])) { foreach ($options['headers'] as $name => $value) { $request = $request->withHeader($name, $value); diff --git a/src/Factory/GuzzleClientFactory.php b/src/Factory/GuzzleClientFactory.php index 4d6d923..0472e02 100644 --- a/src/Factory/GuzzleClientFactory.php +++ b/src/Factory/GuzzleClientFactory.php @@ -7,49 +7,76 @@ use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Psr7\HttpFactory; use Psr\Log\NullLogger; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\ConnectionConfig; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; use ZammadAPIClient\Core\Contracts\ClientFactoryInterface; use ZammadAPIClient\Core\Transport\RequestHandler; +/** + * Builds a Guzzle-backed {@see RequestHandler}. + * + * Most callers never touch this class — {@see \ZammadAPIClient\ZammadClient::create()} + * wires it up for them. Use it directly only to hand a pre-built factory to the + * `ZammadClient` constructor, e.g. in a DI container definition. + */ final class GuzzleClientFactory implements ClientFactoryInterface { public const USER_AGENT = 'Zammad API PHP'; - private function __construct( + public function __construct( private readonly string $url, - private readonly string $authHeader, - private ?ConnectionConfig $config = null, + private readonly Auth $auth, + private readonly ?ConnectionConfig $config = null, ) { } + /** + * @deprecated 3.2.0 Use the constructor with an {@see Auth} value instead: + * `new GuzzleClientFactory($url, Auth::token($token))`. + * Will be removed in v4.0. + */ public static function withToken( string $url, string $token, ?ConnectionConfig $config = null, ): self { - return new self($url, "Token token={$token}", $config); + return new self($url, Auth::token($token), $config); } + /** + * @deprecated 3.2.0 Use the constructor with an {@see Auth} value instead: + * `new GuzzleClientFactory($url, Auth::oauth2($token))`. + * Will be removed in v4.0. + */ public static function withOAuth2( string $url, string $token, ?ConnectionConfig $config = null, ): self { - return new self($url, "Bearer {$token}", $config); + return new self($url, Auth::oauth2($token), $config); } + /** + * @deprecated 3.2.0 Use the constructor with an {@see Auth} value instead: + * `new GuzzleClientFactory($url, Auth::basic($user, $pass))`. + * Will be removed in v4.0. + */ public static function withBasicAuth( string $url, string $user, string $pass, ?ConnectionConfig $config = null, ): self { - return new self($url, 'Basic ' . base64_encode("{$user}:{$pass}"), $config); + return new self($url, Auth::basic($user, $pass), $config); } /** * Creates a request handler configured with this factory's credentials. + * + * The `Authorization` header is not set on the Guzzle client here — it is + * applied per request by {@see RequestHandler}, which keeps authentication + * working for any PSR-18 transport. */ public function createHandler(): RequestHandlerInterface { @@ -57,8 +84,7 @@ public function createHandler(): RequestHandlerInterface $httpClient = new GuzzleClient([ 'headers' => [ - 'User-Agent' => self::USER_AGENT, - 'Authorization' => $this->authHeader, + 'User-Agent' => self::USER_AGENT, ], 'verify' => $config->verifySsl, 'timeout' => $config->timeout, @@ -72,6 +98,7 @@ public function createHandler(): RequestHandlerInterface $this->url, logger: $config->logger ?? new NullLogger(), maxRetries: $config->maxRetries, + auth: $this->auth, ); } } diff --git a/src/ZammadClient.php b/src/ZammadClient.php index 23f89d9..bd561f3 100644 --- a/src/ZammadClient.php +++ b/src/ZammadClient.php @@ -4,9 +4,12 @@ namespace ZammadAPIClient; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\ConnectionConfig; use ZammadAPIClient\Core\Contracts\ClientFactoryInterface; use ZammadAPIClient\Core\Contracts\ClientInterface as ZammadClientInterface; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; +use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Repository\RepositoryRegistry; use ZammadAPIClient\Core\Traits\RepositoryAccessors; @@ -14,13 +17,17 @@ /** * Entry point for the Zammad API. * - * $client = new ZammadClient( - * GuzzleClientFactory::withToken('https://zammad.example', 'your-token'), - * ); + * $client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); * * $client->ticket()->find(1); * $client->user()->all(); * + * To use a different HTTP transport, build the handler yourself and pass it to + * the constructor — credentials and retry behaviour work exactly the same: + * + * $handler = new RequestHandler($psr18, $psr17, $url, auth: Auth::token('your-token')); + * $client = new ZammadClient($handler); + * * @internal Prefer {@see ZammadClientInterface} for type hints. */ final class ZammadClient implements ZammadClientInterface @@ -40,6 +47,23 @@ public function __construct( : $source; } + /** + * Creates a client for the given Zammad instance, using Guzzle as transport. + * + * This is the shortcut for the default transport, not the only full-featured + * way in: passing your own {@see RequestHandlerInterface} to the constructor + * supports the same credentials and options. + * + * @param string $url The Zammad base URL; `/api/v1` is appended if missing. + */ + public static function create( + string $url, + Auth $auth, + ?ConnectionConfig $config = null, + ): self { + return new self(new GuzzleClientFactory($url, $auth, $config)); + } + public function getHandler(): RequestHandlerInterface { return $this->handler; diff --git a/test/Unit/Core/AuthTest.php b/test/Unit/Core/AuthTest.php new file mode 100644 index 0000000..94f4c69 --- /dev/null +++ b/test/Unit/Core/AuthTest.php @@ -0,0 +1,82 @@ +header()); + } + + public function testOAuth2BuildsBearerHeader(): void + { + self::assertSame('Bearer secret', Auth::oauth2('secret')->header()); + } + + public function testBasicBuildsBase64EncodedHeader(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin@example.com:hunter2'), + Auth::basic('admin@example.com', 'hunter2')->header(), + ); + } + + public function testEmptyTokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('API token must not be empty.'); + + Auth::token(''); + } + + public function testWhitespaceOnlyTokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + Auth::token(" \t "); + } + + public function testEmptyOAuth2TokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('OAuth2 token must not be empty.'); + + Auth::oauth2(''); + } + + public function testEmptyUsernameIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Username must not be empty.'); + + Auth::basic('', 'hunter2'); + } + + /** + * RFC 7617 permits an empty password, so only the username is mandatory. + */ + public function testEmptyPasswordIsAccepted(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin:'), + Auth::basic('admin', '')->header(), + ); + } + + public function testDebugOutputDoesNotLeakTheCredential(): void + { + $dump = print_r(Auth::token('super-secret'), true); + + self::assertStringNotContainsString('super-secret', $dump); + self::assertStringContainsString('redacted', $dump); + } +} diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php index 98ad0a1..1b0a02b 100644 --- a/test/Unit/Core/RequestHandlerTest.php +++ b/test/Unit/Core/RequestHandlerTest.php @@ -14,6 +14,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\Exceptions\AuthenticationException; use ZammadAPIClient\Exceptions\BadRequestException; @@ -366,4 +367,61 @@ public function testDeleteReturnsEmptyArrayOnEmptyBody(): void self::assertSame([], $result); } + + /** + * The recording client below is a plain PSR-18 implementation, not Guzzle — + * so this also demonstrates that authentication no longer depends on the + * transport offering client-level default headers. + */ + public function testAuthorizationHeaderIsSentWhenAuthIsConfigured(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->get('tickets'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + public function testNoAuthorizationHeaderWithoutAuth(): void + { + $this->httpClient->response = new Response(200, [], '{}'); + + $this->handler->get('tickets'); + + self::assertFalse($this->httpClient->lastRequest?->hasHeader('Authorization')); + } + + /** + * getRaw() is the attachment path and used to rely on the client-level + * default header, so it is worth pinning down separately. + */ + public function testAuthorizationHeaderIsSentOnRawRequests(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], 'binary-payload'); + + $handler->getRaw('ticket_attachment/1/2/3'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + public function testPerRequestHeaderOverridesConfiguredAuth(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->request('GET', 'tickets', ['headers' => ['Authorization' => 'Bearer override']]); + + self::assertSame( + 'Bearer override', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } } diff --git a/test/Unit/Core/Traits/CreatesRequestHandler.php b/test/Unit/Core/Traits/CreatesRequestHandler.php index 77d3ddc..91a49c9 100644 --- a/test/Unit/Core/Traits/CreatesRequestHandler.php +++ b/test/Unit/Core/Traits/CreatesRequestHandler.php @@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\HttpFactory; use Psr\Http\Client\ClientInterface; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\RequestHandler; /** @@ -22,8 +23,17 @@ private function setUpRequestHandler(): void $this->httpFactory = new HttpFactory(); } - private function createHandler(ClientInterface $client, int $maxRetries = 0): RequestHandler - { - return new RequestHandler($client, $this->httpFactory, self::BASE_URL, maxRetries: $maxRetries); + private function createHandler( + ClientInterface $client, + int $maxRetries = 0, + ?Auth $auth = null, + ): RequestHandler { + return new RequestHandler( + $client, + $this->httpFactory, + self::BASE_URL, + maxRetries: $maxRetries, + auth: $auth, + ); } } diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php index fceaa26..bb522d1 100644 --- a/test/Unit/GuzzleClientFactoryTest.php +++ b/test/Unit/GuzzleClientFactoryTest.php @@ -5,8 +5,13 @@ namespace ZammadAPIClient\Tests\Unit; use Mockery\Adapter\Phpunit\MockeryTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use ReflectionProperty; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Contracts\ClientInterface; +use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; +use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\ZammadClient; @@ -42,4 +47,61 @@ public function testFactoryWrappedInZammadClient(): void self::assertInstanceOf(ClientInterface::class, $client); } + + /** + * The three static helpers are deprecated but supported until v4.0, so these + * calls are deliberate: they pin down that the transitional path stays + * equivalent to constructing the factory with an explicit {@see Auth}. + * Do not remove them as leftovers — they go when the helpers do. + * + * @return iterable + */ + public static function equivalentFactories(): iterable + { + $url = 'https://zammad.example'; + + yield 'token' => [ + GuzzleClientFactory::withToken($url, 'test-token'), + new GuzzleClientFactory($url, Auth::token('test-token')), + ]; + yield 'oauth2' => [ + GuzzleClientFactory::withOAuth2($url, 'oauth-token'), + new GuzzleClientFactory($url, Auth::oauth2('oauth-token')), + ]; + yield 'basic' => [ + GuzzleClientFactory::withBasicAuth($url, 'admin', 'test'), + new GuzzleClientFactory($url, Auth::basic('admin', 'test')), + ]; + } + + #[DataProvider('equivalentFactories')] + public function testLegacyHelperMatchesExplicitAuthConstructor( + GuzzleClientFactory $legacy, + GuzzleClientFactory $explicit, + ): void { + self::assertSame( + self::authHeaderOf($legacy->createHandler()), + self::authHeaderOf($explicit->createHandler()), + ); + } + + public function testZammadClientCreateUsesTheGivenAuth(): void + { + $client = ZammadClient::create('https://zammad.example', Auth::token('test-token')); + + self::assertSame('Token token=test-token', self::authHeaderOf($client->getHandler())); + } + + /** + * Reads the credential the handler will apply. White-box on purpose: the + * Guzzle-backed path cannot be observed without making a real request. + */ + private static function authHeaderOf(RequestHandlerInterface $handler): string + { + $auth = (new ReflectionProperty(RequestHandler::class, 'auth'))->getValue($handler); + + self::assertInstanceOf(Auth::class, $auth); + + return $auth->header(); + } } From 8e3a5b627f1cea0bf24d342e3361cab6991828b1 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 12 Oct 2026 16:41:47 +0200 Subject: [PATCH 2/8] =?UTF-8?q?Docs:=20fix=20custom=20HTTP=20client=20guid?= =?UTF-8?q?e=20=E2=80=94=20examples=20lacked=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + docs/custom-http-client.md | 44 +++++++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cbfa7..06bdfeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Authentication no longer depends on the HTTP client supporting client-level default headers. It is applied by `RequestHandler` when composing each request, so any PSR-18 transport authenticates the same way — the hand-written `AuthMiddleware` that the docs previously required for Symfony's `Psr18Client` is obsolete - `GuzzleClientFactory` takes `(string $url, Auth $auth, ?ConnectionConfig $config)` and no longer sets an `Authorization` default header on the Guzzle client. Its constructor was private before, so no caller is affected - Documentation: corrected examples that referenced `ZammadClient::withToken()` and the impersonation methods removed in 3.0.0 ([#168](https://github.com/zammad/zammad-api-client-php/issues/168)) +- **Fixed:** the Laravel override and custom-middleware examples in the custom HTTP client guide built a client with no credentials at all, so every request would have failed with HTTP 401. They now pass an `Auth` to the `RequestHandler` `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`; they are deprecated as of this release but remain functional until 4.0. diff --git a/docs/custom-http-client.md b/docs/custom-http-client.md index b5d1e60..7a62d36 100644 --- a/docs/custom-http-client.md +++ b/docs/custom-http-client.md @@ -8,12 +8,12 @@ A runnable version of the setup below is in [`examples/cookbook/07-custom-http-c ## Symfony HttpClient -Requires `composer require symfony/http-client nyholm/psr7` (or `guzzlehttp/psr7` for PSR-17 factories). +Requires `composer require symfony/http-client nyholm/psr7`. Any PSR-17 factory works; `guzzlehttp/psr7` is already installed as a dependency if you prefer it. ```php use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Psr18Client; -use GuzzleHttp\Psr7\HttpFactory; +use Nyholm\Psr7\Factory\Psr17Factory; use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\ZammadClient; @@ -25,7 +25,7 @@ $handler = new RequestHandler( 'verify_peer' => true, 'verify_host' => true, ])), - new HttpFactory(), + new Psr17Factory(), 'https://zammad.example', maxRetries: 3, auth: Auth::token('your-zammad-api-token'), @@ -56,7 +56,7 @@ The `LaravelServiceProvider` binds `ZammadClient` as a singleton. To use Symfony ```php use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Psr18Client; -use GuzzleHttp\Psr7\HttpFactory; +use Nyholm\Psr7\Factory\Psr17Factory; use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\ZammadClient; @@ -70,9 +70,10 @@ class AppServiceProvider extends ServiceProvider new Psr18Client(HttpClient::create([ 'timeout' => config('zammad.timeout', 30), ])), - new HttpFactory(), + new Psr17Factory(), config('zammad.url'), maxRetries: 3, + auth: Auth::token(config('zammad.token')), ); return new ZammadClient($handler); }); @@ -100,6 +101,14 @@ services: $auth: '@app.zammad_auth' # Or with a custom PSR-18 stack: + app.zammad_request_handler: + class: ZammadAPIClient\Core\Transport\RequestHandler + arguments: + $httpClient: '@Symfony\Component\HttpClient\Psr18Client' + $factory: '@Nyholm\Psr7\Factory\Psr17Factory' + $baseUrl: '%env(ZAMMAD_URL)%' + $auth: '@app.zammad_auth' + app.zammad_client: class: ZammadAPIClient\ZammadClient arguments: @@ -110,7 +119,14 @@ services: `RequestHandler` internally applies rate-limit retry via `RetryAfterMiddleware`. You only need to compose your own middleware around the base client — retry behavior is added automatically (set `maxRetries: 0` to disable). +`LoggingMiddleware` and `MetricsMiddleware` below stand in for your own decorators. + ```php +use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\HttpClient\Psr18Client; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; + $http = new LoggingMiddleware( new MetricsMiddleware( new Psr18Client(), @@ -118,12 +134,20 @@ $http = new LoggingMiddleware( ); // Rate-limit retry is applied internally by RequestHandler -$handler = new RequestHandler($http, new HttpFactory(), 'https://zammad.example', maxRetries: 5); +$handler = new RequestHandler( + $http, + new Psr17Factory(), + 'https://zammad.example', + maxRetries: 5, + auth: Auth::token('your-zammad-api-token'), +); ``` ## Important Security Notes -- **Configure timeouts**: Always set `timeout` and connection timeout on your PSR-18 client. The factory methods default to 30s/10s respectively. -- **Verify TLS**: Do not disable TLS verification in production. The factory methods default to `verifySsl = true`. -- **Redirects**: The factory methods disable redirect following (`allow_redirects = false`). If using your own PSR-18 client, configure redirect behavior carefully — following redirects may leak the `Authorization` header to unintended hosts. -- **Debug logging**: Use a PSR-3 logger via `ConnectionConfig`. Never enable raw HTTP debug output in production — it will expose API tokens in log files. +Building the handler yourself means the defaults `ZammadClient::create()` applies are no longer applied for you. Each of these is now your responsibility: + +- **Configure timeouts**: Always set a request and a connection timeout on your PSR-18 client. `ZammadClient::create()` defaults to 30s and 10s (`ConnectionConfig::$timeout` / `$connectTimeout`). +- **Verify TLS**: Do not disable TLS verification in production. `ZammadClient::create()` defaults to `verifySsl = true`. +- **Redirects**: `ZammadClient::create()` disables redirect following (`allow_redirects = false`). Configure your own client carefully — following redirects may leak the `Authorization` header to unintended hosts. +- **Debug logging**: Pass a PSR-3 logger as the `logger:` argument of `RequestHandler` (on the `create()` path it comes from `ConnectionConfig::$logger`). Never enable raw HTTP debug output in production — it will expose API tokens in log files. From db083a21dba7411624b427af018d9828cd4c09b7 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 12 Oct 2026 17:17:39 +0200 Subject: [PATCH 3/8] Docs: explain that ImpersonationHandler inherits the client's credentials Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 ++++++++++--- docs/migration-v3-examples.md | 2 ++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9c99117..9af345b 100644 --- a/README.md +++ b/README.md @@ -730,13 +730,20 @@ $client->ticket()->patch(42, new TicketUpdateDTO( ## Impersonation Impersonation is scoped to its own client instance — wrap the handler in an -`ImpersonationHandler`. The original client keeps acting as the authenticated -user, so there is no state to reset and nothing can leak between calls. +`ImpersonationHandler`. It decorates the handler you hand it, so the credentials +of the original client carry over; the only thing it adds is the `From` header. +That original client keeps acting as the authenticated user, so there is no state +to reset and nothing can leak between calls. ```php +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\ImpersonationHandler; +use ZammadAPIClient\ZammadClient; + +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); -// Accepts user ID (int), login, or email (string) +// Accepts user ID (int), login, or email (string); no credentials needed here — +// they come from $client's handler $asUser = new ZammadClient(new ImpersonationHandler($client->getHandler(), 1)); $ticket = $asUser->ticket()->find(42); diff --git a/docs/migration-v3-examples.md b/docs/migration-v3-examples.md index 00daa43..505ba5a 100644 --- a/docs/migration-v3-examples.md +++ b/docs/migration-v3-examples.md @@ -352,6 +352,8 @@ $asUser->ticket()->find(42); ``` > **Breaking:** v2 toggled impersonation on the client itself, so the state was global and had to be reset. v3 scopes it to a separate client instance — there is nothing to unset and nothing can leak between calls. A user ID, login or email all work (`int|string`). +> +> No credentials are passed to `ImpersonationHandler`: it decorates `$client`'s existing handler and inherits its authentication, adding only the `From` header. --- From 1e7e599d08b2396747e5b6730ba26e0f7c930b10 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 12 Oct 2026 17:34:45 +0200 Subject: [PATCH 4/8] Auth: trim credentials; withdraw unbacked deprecation notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim credentials before validation, so a whitespace-only token is rejected like an empty one, and document that a token is required. Drop the @deprecated notices on GuzzleClientFactory::with*() and on SerializesToArray::id(). Both promised removal in v4.0, but there is no v4 milestone, issue or roadmap, and the project has no deprecation or versioning policy at all — the v2 to v3 bump removed APIs with no deprecation period whatsoever. Nor is there a reason to remove them: the helpers are four-line delegations to Auth that cost no maintenance, and id() is required by DTOInterface and called by Resource itself, so the marker contradicted a live contract and the library's own usage. ZammadClient::create() remains the recommended entry point; that is said in the class docblock, the README and the CHANGELOG instead of by a removal date the project cannot keep. Fix three defects found in review: SymfonyBundle registered the client with ContainerBuilder::set(), which stores a live instance. Instances set that way do not survive container compilation, so injecting ZammadClient failed the build with "non-existent service" — the bundle never worked in a compiled container. It now registers a lazy Definition with ZammadClient::create() as its factory and an inlined Auth definition, resolving %env(ZAMMAD_TOKEN)% at runtime so the token stays out of var/cache. Verified by compiling and dumping a container against symfony/dependency-injection 7.4 outside this repo, since src/Bridge/ has no tests and no static analysis (#169). The password parameters of Auth::basic() and withBasicAuth() are marked #[\SensitiveParameter]. An empty username throws from inside basic(), so with PHP's default zend.exception_ignore_args=0 the password appeared verbatim in the stack trace. Covered by a regression test that skips on PHP 8.1 and when the ini strips arguments. The Symfony service IDs in the custom HTTP client guide were class names rather than registered services, so copying that YAML failed the build. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 11 +++--- docs/custom-http-client.md | 15 +++++++- src/Bridge/LaravelServiceProvider.php | 6 ++- src/Bridge/SymfonyBundle.php | 24 +++++++++--- src/Core/Auth.php | 22 +++++++++-- src/Core/Traits/SerializesToArray.php | 5 ++- src/Factory/GuzzleClientFactory.php | 14 ++----- test/Unit/Core/AuthTest.php | 55 +++++++++++++++++++++++++++ test/Unit/GuzzleClientFactoryTest.php | 7 ++-- 9 files changed, 126 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06bdfeb..a3c7ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,19 @@ - `ZammadClient::create(string $url, Auth $auth, ?ConnectionConfig $config = null)` — one-line entry point for the default (Guzzle) transport - `RequestHandler` accepts an optional `auth:` argument and applies the `Authorization` header per request -### Deprecated -- `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` — use the constructor with an `Auth` value instead: `new GuzzleClientFactory($url, Auth::token($token))`. They keep working and will be removed in 4.0 - ### Changed - Authentication no longer depends on the HTTP client supporting client-level default headers. It is applied by `RequestHandler` when composing each request, so any PSR-18 transport authenticates the same way — the hand-written `AuthMiddleware` that the docs previously required for Symfony's `Psr18Client` is obsolete - `GuzzleClientFactory` takes `(string $url, Auth $auth, ?ConnectionConfig $config)` and no longer sets an `Authorization` default header on the Guzzle client. Its constructor was private before, so no caller is affected +- The `@deprecated` marker on `DTOInterface::id()`'s trait implementation is withdrawn. It announced a removal in 4.0 that was never planned, was never mentioned in the 3.0.0 changelog that introduced it, and contradicted both the interface — which requires the method and is not deprecated — and the library's own use of it in `Resource`. Reading `$dto->id` directly remains the more readable form - Documentation: corrected examples that referenced `ZammadClient::withToken()` and the impersonation methods removed in 3.0.0 ([#168](https://github.com/zammad/zammad-api-client-php/issues/168)) - **Fixed:** the Laravel override and custom-middleware examples in the custom HTTP client guide built a client with no credentials at all, so every request would have failed with HTTP 401. They now pass an `Auth` to the `RequestHandler` +- **Fixed:** `SymfonyBundle` registered the client with `ContainerBuilder::set()`, which stores a live instance rather than a definition. Instances set that way do not survive container compilation, so any service injecting `ZammadClient` failed the build with *"has a dependency on a non-existent service"* — the bundle never worked in a compiled container. It now registers a lazy `Definition` with `ZammadClient::create()` as its factory and an inlined `Auth` definition, so nothing is instantiated at compile time and the credentials are resolved from `%env(ZAMMAD_TOKEN)%` at runtime instead of being written into `var/cache` +- **Fixed:** the password parameters of `Auth::basic()` and `GuzzleClientFactory::withBasicAuth()` are marked `#[\SensitiveParameter]`. An empty username throws from inside `Auth::basic()`, so with PHP's default `zend.exception_ignore_args=0` the password appeared verbatim in the stack trace. The attribute is honoured from PHP 8.2 on and inert on 8.1 +- **Fixed:** the Symfony service IDs in the custom HTTP client guide. `@Symfony\Component\HttpClient\Psr18Client` and `@Nyholm\Psr7\Factory\Psr17Factory` are class names, not registered services, so copying that YAML failed the build. FrameworkBundle aliases `Psr\Http\Client\ClientInterface`, and the `nyholm/psr7` Flex recipe registers `nyholm.psr7.psr17_factory` -`GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`; they are deprecated as of this release but remain functional until 4.0. +`GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`. They remain supported shorthand for the explicit-`Auth` constructor and are not scheduled for removal; `ZammadClient::create()` is simply the recommended entry point for new code. -**One behavioural change to be aware of:** an empty or whitespace-only token (or username) is now rejected with an `InvalidArgumentException` at construction, where it previously produced a client that failed with HTTP 401 on its first request. Code that builds a client from an unset environment variable will fail earlier and more clearly than before — including the Laravel and Symfony bridges, which fall back to an empty token when `ZAMMAD_TOKEN` is not set. +**One behavioural change to be aware of:** an empty or whitespace-only token (or username) is now rejected with an `InvalidArgumentException` at construction, where it previously produced a client that failed with HTTP 401 on its first request. Code that builds a client from an unset environment variable therefore fails earlier and more clearly than before. Both framework bridges resolve the client lazily, so this surfaces the first time something asks the container for it, not while the container is built: Laravel falls back to an empty token (`env('ZAMMAD_TOKEN', '')`) and throws `InvalidArgumentException`, while the Symfony bundle falls back to `%env(ZAMMAD_TOKEN)%` and throws `EnvNotFoundException` when the variable is unset, or `InvalidArgumentException` when it is set but empty. The Symfony bundle only became lazy in this release — see the fix above; before it, an empty token made `cache:clear` (and with it `composer install`) fail outright. The bundles are still covered by neither tests nor static analysis ([#169](https://github.com/zammad/zammad-api-client-php/issues/169)); the bundle change above was verified by compiling and dumping a container against symfony/dependency-injection 7.4 outside this repository. ## [3.1.0] — 2026-10-02 diff --git a/docs/custom-http-client.md b/docs/custom-http-client.md index 7a62d36..68f7ead 100644 --- a/docs/custom-http-client.md +++ b/docs/custom-http-client.md @@ -104,8 +104,8 @@ services: app.zammad_request_handler: class: ZammadAPIClient\Core\Transport\RequestHandler arguments: - $httpClient: '@Symfony\Component\HttpClient\Psr18Client' - $factory: '@Nyholm\Psr7\Factory\Psr17Factory' + $httpClient: '@Psr\Http\Client\ClientInterface' + $factory: '@nyholm.psr7.psr17_factory' $baseUrl: '%env(ZAMMAD_URL)%' $auth: '@app.zammad_auth' @@ -115,6 +115,17 @@ services: - '@app.zammad_request_handler' ``` +The two service IDs in the custom stack are the ones Symfony actually registers: +FrameworkBundle aliases `Psr\Http\Client\ClientInterface` to its `psr18.http_client` +service as soon as `symfony/http-client` is installed, and `nyholm.psr7.psr17_factory` +comes from the `nyholm/psr7` Flex recipe. The class names are *not* service IDs — the +package itself ships no DI configuration. If you installed it without Flex, declare the +factory yourself: + +```yaml + Nyholm\Psr7\Factory\Psr17Factory: ~ +``` + ## Custom PSR-18 Middleware Stack `RequestHandler` internally applies rate-limit retry via `RetryAfterMiddleware`. You only need to compose your own middleware around the base client — retry behavior is added automatically (set `maxRetries: 0` to disable). diff --git a/src/Bridge/LaravelServiceProvider.php b/src/Bridge/LaravelServiceProvider.php index 6f363b1..e986d09 100644 --- a/src/Bridge/LaravelServiceProvider.php +++ b/src/Bridge/LaravelServiceProvider.php @@ -69,7 +69,11 @@ * * 1. `config/zammad.php` values (after `vendor:publish`) * 2. `ZAMMAD_URL` / `ZAMMAD_TOKEN` environment variables (`.env`) - * 3. Built-in defaults (`http://127.0.0.1:8098`, empty token) + * 3. Built-in default URL (`http://127.0.0.1:8098`) + * + * A token is required: there is no usable default for it. If neither + * `config('zammad.token')` nor `ZAMMAD_TOKEN` is set, resolving + * {@see ZammadClient} from the container throws `InvalidArgumentException`. * * @see ZammadClient::create() */ diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php index c4df0b5..545e92d 100644 --- a/src/Bridge/SymfonyBundle.php +++ b/src/Bridge/SymfonyBundle.php @@ -5,6 +5,7 @@ namespace ZammadAPIClient\Bridge; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; use ZammadAPIClient\Core\Auth; @@ -85,13 +86,24 @@ public function load(array $configs, ContainerBuilder $container): void $resolved = array_merge($resolved, $config); } - $url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098'); - $token = $resolved['token'] ?? (string) ($_ENV['ZAMMAD_TOKEN'] ?? ''); + $container->setParameter('zammad.default_url', 'http://127.0.0.1:8098'); - $client = ZammadClient::create($url, Auth::token($token)); - $container->set(ZammadClient::class, $client); - $container->setAlias('zammad_client', ZammadClient::class); - $container->registerForAutoconfiguration(ZammadClient::class)->setAutowired(true); + $url = $resolved['url'] ?? '%env(default:zammad.default_url:ZAMMAD_URL)%'; + $token = $resolved['token'] ?? '%env(ZAMMAD_TOKEN)%'; + + $auth = (new Definition(Auth::class)) + ->setFactory([Auth::class, 'token']) + ->setArguments([$token]); + + $container->setDefinition( + ZammadClient::class, + (new Definition(ZammadClient::class)) + ->setFactory([ZammadClient::class, 'create']) + ->setArguments([$url, $auth]) + ->setPublic(true), + ); + + $container->setAlias('zammad_client', ZammadClient::class)->setPublic(true); } public function getNamespace(): string diff --git a/src/Core/Auth.php b/src/Core/Auth.php index f35163d..cede846 100644 --- a/src/Core/Auth.php +++ b/src/Core/Auth.php @@ -51,9 +51,14 @@ public static function oauth2(string $token): self * * An empty password is permitted (RFC 7617); the username is required. * + * `$password` is marked `#[\SensitiveParameter]` so that it is redacted from + * stack traces: an empty username throws from this very frame, and PHP's + * default `zend.exception_ignore_args=0` would otherwise print the password + * as a literal argument. The attribute is inert on PHP 8.1. + * * @throws InvalidArgumentException If $user is empty. */ - public static function basic(string $user, string $password): self + public static function basic(string $user, #[\SensitiveParameter] string $password): self { return new self( 'Basic ' . base64_encode(self::requireNonEmpty($user, 'Username') . ':' . $password), @@ -80,10 +85,21 @@ public function __debugInfo(): array return ['header' => '***redacted***']; } + /** + * Returns the value with surrounding whitespace removed. + * + * Trimming is not cosmetic. A credential read from a file or an environment + * variable commonly carries a trailing newline, which PSR-7 rejects as an + * invalid header value — the error then points at the transport rather than + * at the token. Leading whitespace is worse: it survives into the header and + * the request simply comes back 401. + */ private static function requireNonEmpty(string $value, string $label): string { - return trim($value) === '' + $trimmed = trim($value); + + return $trimmed === '' ? throw new InvalidArgumentException("{$label} must not be empty.") - : $value; + : $trimmed; } } diff --git a/src/Core/Traits/SerializesToArray.php b/src/Core/Traits/SerializesToArray.php index a4534dc..8bd9b10 100644 --- a/src/Core/Traits/SerializesToArray.php +++ b/src/Core/Traits/SerializesToArray.php @@ -105,8 +105,9 @@ public function toArray(): array * The consuming class must declare `public readonly ?int $id = null`. * This method reads it directly; no property access indirection is used. * - * @deprecated Use the `$dto->id` property directly. The method will be - * removed in v4.0 alongside the DTOInterface contract change. + * Required by {@see \ZammadAPIClient\Core\Contracts\DTOInterface} and used + * internally by {@see \ZammadAPIClient\Core\Repository\Resource}. Reading + * `$dto->id` directly is equivalent and reads better in application code. */ public function id(): ?int { diff --git a/src/Factory/GuzzleClientFactory.php b/src/Factory/GuzzleClientFactory.php index 0472e02..a3ffa21 100644 --- a/src/Factory/GuzzleClientFactory.php +++ b/src/Factory/GuzzleClientFactory.php @@ -32,9 +32,7 @@ public function __construct( } /** - * @deprecated 3.2.0 Use the constructor with an {@see Auth} value instead: - * `new GuzzleClientFactory($url, Auth::token($token))`. - * Will be removed in v4.0. + * Shorthand for `new self($url, Auth::token($token))`. */ public static function withToken( string $url, @@ -45,9 +43,7 @@ public static function withToken( } /** - * @deprecated 3.2.0 Use the constructor with an {@see Auth} value instead: - * `new GuzzleClientFactory($url, Auth::oauth2($token))`. - * Will be removed in v4.0. + * Shorthand for `new self($url, Auth::oauth2($token))`. */ public static function withOAuth2( string $url, @@ -58,14 +54,12 @@ public static function withOAuth2( } /** - * @deprecated 3.2.0 Use the constructor with an {@see Auth} value instead: - * `new GuzzleClientFactory($url, Auth::basic($user, $pass))`. - * Will be removed in v4.0. + * Shorthand for `new self($url, Auth::basic($user, $pass))`. */ public static function withBasicAuth( string $url, string $user, - string $pass, + #[\SensitiveParameter] string $pass, ?ConnectionConfig $config = null, ): self { return new self($url, Auth::basic($user, $pass), $config); diff --git a/test/Unit/Core/AuthTest.php b/test/Unit/Core/AuthTest.php index 94f4c69..a179896 100644 --- a/test/Unit/Core/AuthTest.php +++ b/test/Unit/Core/AuthTest.php @@ -5,6 +5,7 @@ namespace ZammadAPIClient\Tests\Unit\Core; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use ZammadAPIClient\Core\Auth; @@ -72,6 +73,37 @@ public function testEmptyPasswordIsAccepted(): void ); } + /** + * A token read from a file or an environment variable commonly carries a + * trailing newline. Untrimmed it reaches PSR-7, which rejects it as an + * invalid header value and blames the transport; leading whitespace is + * worse, surviving into the header and coming back as a plain 401. + * + * @return iterable + */ + public static function paddedTokens(): iterable + { + yield 'trailing newline' => ["secret\n"]; + yield 'trailing space' => ['secret ']; + yield 'leading space' => [' secret']; + yield 'leading tab' => ["\tsecret"]; + yield 'both sides' => [" secret\r\n"]; + } + + #[DataProvider('paddedTokens')] + public function testSurroundingWhitespaceIsStripped(string $padded): void + { + self::assertSame('Token token=secret', Auth::token($padded)->header()); + } + + public function testUsernameIsTrimmedButPasswordIsNot(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin: hunter2 '), + Auth::basic(' admin ', ' hunter2 ')->header(), + ); + } + public function testDebugOutputDoesNotLeakTheCredential(): void { $dump = print_r(Auth::token('super-secret'), true); @@ -79,4 +111,27 @@ public function testDebugOutputDoesNotLeakTheCredential(): void self::assertStringNotContainsString('super-secret', $dump); self::assertStringContainsString('redacted', $dump); } + + /** + * An empty username throws from inside {@see Auth::basic()}, so that frame — + * password and all — lands in the stack trace. `#[\SensitiveParameter]` is what + * keeps the password out of it; without the attribute this test fails. + */ + public function testStackTraceDoesNotLeakTheBasicAuthPassword(): void + { + if (PHP_VERSION_ID < 80200) { + self::markTestSkipped('#[\SensitiveParameter] is honoured from PHP 8.2 on.'); + } + if (ini_get('zend.exception_ignore_args')) { + self::markTestSkipped('Arguments are stripped from traces by zend.exception_ignore_args.'); + } + + try { + Auth::basic('', 'hunter2'); + self::fail('Expected an InvalidArgumentException for the empty username.'); + } catch (InvalidArgumentException $e) { + self::assertStringNotContainsString('hunter2', $e->getTraceAsString()); + self::assertStringContainsString('SensitiveParameterValue', $e->getTraceAsString()); + } + } } diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php index bb522d1..ba9e5b3 100644 --- a/test/Unit/GuzzleClientFactoryTest.php +++ b/test/Unit/GuzzleClientFactoryTest.php @@ -49,10 +49,9 @@ public function testFactoryWrappedInZammadClient(): void } /** - * The three static helpers are deprecated but supported until v4.0, so these - * calls are deliberate: they pin down that the transitional path stays - * equivalent to constructing the factory with an explicit {@see Auth}. - * Do not remove them as leftovers — they go when the helpers do. + * The three static helpers are shorthand for constructing the factory with an + * explicit {@see Auth}, so these calls are deliberate: they pin down that both + * forms stay equivalent. Do not remove them as leftovers. * * @return iterable */ From 5abc9a8d5314b054c84dd066c0c7c421e9766383 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Wed, 14 Oct 2026 21:16:54 +0200 Subject: [PATCH 5/8] Auth: refuse to send credentials over cleartext HTTP RequestHandler attached the Authorization header to every request without ever looking at the URL scheme, so an http:// base URL put the token on the wire for anyone on the path to read. Constructing a handler with an Auth and a plain http:// URL whose host is not loopback now throws an InvalidArgumentException. Loopback is exempt -- localhost, any .localhost name, the whole 127.0.0.0/8 range and ::1 -- so every default this project ships keeps working. Where the connection is protected by other means, ConnectionConfig(allowInsecureHttp: true) opts out, and the exception message says so. The check sits in the constructor, not in dispatch(): $baseUrl is fixed for the lifetime of the handler and every request method funnels through dispatch(), so checking once covers all of them. It keys on the Auth it is handed, which leaves one case uncovered: a PSR-18 client that sets the header itself, as the custom-transport guide suggests for runtime-refreshed credentials. That header is invisible from here. The docblock, the guide and the changelog say so rather than implying the guard is complete. Breaking for anyone pointing the client at a remote Zammad over plain HTTP. Co-Authored-By: Claude Opus 5 (1M context) --- src/Core/ConnectionConfig.php | 5 ++ src/Core/Transport/RequestHandler.php | 83 +++++++++++++++++++++++++++ src/Factory/GuzzleClientFactory.php | 1 + 3 files changed, 89 insertions(+) diff --git a/src/Core/ConnectionConfig.php b/src/Core/ConnectionConfig.php index 36309e8..8feef58 100644 --- a/src/Core/ConnectionConfig.php +++ b/src/Core/ConnectionConfig.php @@ -8,12 +8,17 @@ final class ConnectionConfig { + /** + * @param bool $allowInsecureHttp Permit credentials over plain `http://` to a non-loopback host. + * Off by default; see {@see Transport\RequestHandler}. + */ public function __construct( public readonly int $maxRetries = 3, public readonly bool $verifySsl = true, public readonly int $timeout = 30, public readonly int $connectTimeout = 10, public readonly ?LoggerInterface $logger = null, + public readonly bool $allowInsecureHttp = false, ) { } } diff --git a/src/Core/Transport/RequestHandler.php b/src/Core/Transport/RequestHandler.php index be48b1c..0c40c04 100644 --- a/src/Core/Transport/RequestHandler.php +++ b/src/Core/Transport/RequestHandler.php @@ -35,6 +35,8 @@ * - Maps HTTP error codes to typed domain exceptions before returning * (see {@see self::dispatch()} and the private `mapError`/`validationError` methods). * - Stores the last PSR-7 response so callers can inspect headers afterward. + * - Applies the configured {@see Auth} credentials, and refuses to do so over an + * unencrypted connection to a non-loopback host. * * HTTP 429 rate-limit retry is handled transparently — no manual wiring needed. */ @@ -59,6 +61,12 @@ final class RequestHandler implements RequestHandlerInterface * @param Auth|null $auth Credentials; sent as `Authorization` on every request. * Null leaves the header unset, e.g. when the PSR-18 client * already adds it. + * @param bool $allowInsecureHttp Permit credentials over plain `http://` to a + * non-loopback host; see + * {@see self::assertCredentialsAreNotSentInClear()}. + * + * @throws InvalidArgumentException If $factory is not also a StreamFactoryInterface, or if $auth would + * be sent in cleartext (see $allowInsecureHttp). */ public function __construct( ClientInterface $httpClient, @@ -67,6 +75,7 @@ public function __construct( LoggerInterface $logger = new NullLogger(), int $maxRetries = 3, ?Auth $auth = null, + bool $allowInsecureHttp = false, ) { if (!$factory instanceof StreamFactoryInterface) { throw new InvalidArgumentException( @@ -81,6 +90,80 @@ public function __construct( $this->baseUrl = self::normalizeBaseUrl($baseUrl); $this->logger = $logger; $this->auth = $auth; + + if (!$allowInsecureHttp) { + self::assertCredentialsAreNotSentInClear($this->baseUrl, $auth); + } + } + + /** + * Refuses to build a handler that would send credentials over an unencrypted + * connection to a host other than the local machine. + * + * `dispatch()` attaches the `Authorization` header to every request, and + * $baseUrl is fixed for the lifetime of the handler — so checking once here + * covers every request this handler will ever make. + * + * Loopback is exempt: a token sent to 127.0.0.1 never reaches a network. That + * covers the defaults shipped with this library and the usual local tunnel in + * front of a remote instance. For everything else, use `https://`, or pass + * `allowInsecureHttp: true` when the transport is already protected by other + * means (a VPN, a service mesh, an SSH tunnel on a non-loopback interface). + * + * The check keys on $auth, so it cannot see credentials that the PSR-18 client + * itself adds — a setup the $auth parameter explicitly allows. Those remain the + * caller's responsibility. + * + * @throws InvalidArgumentException If credentials would travel in cleartext. + */ + private static function assertCredentialsAreNotSentInClear(string $baseUrl, ?Auth $auth): void + { + if ($auth === null) { + return; + } + + $parts = parse_url($baseUrl); + + if (!is_array($parts) || ($parts['scheme'] ?? null) !== 'http') { + return; + } + + $host = $parts['host'] ?? ''; + + if (self::isLoopbackHost($host)) { + return; + } + + throw new InvalidArgumentException(sprintf( + 'Refusing to send Zammad credentials in cleartext to "http://%s". Use https://, or pass ' + . 'allowInsecureHttp: true (ConnectionConfig(allowInsecureHttp: true)) if the connection ' + . 'is already protected by other means.', + $host, + )); + } + + /** + * Whether the host resolves to the local machine by definition, i.e. without + * a DNS lookup: `localhost` and any `.localhost` subdomain (RFC 6761), the + * whole 127.0.0.0/8 range, and the IPv6 loopback `::1`. + * + * A host that merely happens to point at 127.0.0.1 today is not loopback here. + * That would require resolving it, and the answer could differ by the time the + * request goes out. + */ + private static function isLoopbackHost(string $host): bool + { + $host = strtolower(trim($host, '[]')); + + if ($host === 'localhost' || str_ends_with($host, '.localhost')) { + return true; + } + + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + return str_starts_with($host, '127.'); + } + + return in_array($host, ['::1', '0:0:0:0:0:0:0:1'], true); } /** diff --git a/src/Factory/GuzzleClientFactory.php b/src/Factory/GuzzleClientFactory.php index a3ffa21..be00342 100644 --- a/src/Factory/GuzzleClientFactory.php +++ b/src/Factory/GuzzleClientFactory.php @@ -93,6 +93,7 @@ public function createHandler(): RequestHandlerInterface logger: $config->logger ?? new NullLogger(), maxRetries: $config->maxRetries, auth: $this->auth, + allowInsecureHttp: $config->allowInsecureHttp, ); } } From 826aa6dd38b6d366e42d56b1e67fff75acecee64 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Wed, 14 Oct 2026 21:17:02 +0200 Subject: [PATCH 6/8] Test: cover the cleartext-credential guard Pins down both halves of the new check: that it fires on a non-loopback http:// URL with credentials, and that it stays out of the way everywhere else -- https://, loopback in all four spellings (localhost, .localhost subdomain, 127.0.0.0/8 beyond 127.0.0.1, and ::1), and a handler built without an Auth at all. The last one is the documented gap, so it gets a test of its own: the guard cannot see a header the PSR-18 client adds, and a future reader should find that stated rather than inferred. Two more go through GuzzleClientFactory, because allowInsecureHttp has to travel ConnectionConfig -> factory -> handler before ZammadClient::create() can reach it. Without them that wiring is untested. CreatesRequestHandler gains $baseUrl and $allowInsecureHttp parameters; its BASE_URL default is unchanged, so no existing test is affected. Co-Authored-By: Claude Opus 5 (1M context) --- test/Unit/Core/RequestHandlerTest.php | 84 +++++++++++++++++++ .../Core/Traits/CreatesRequestHandler.php | 5 +- test/Unit/GuzzleClientFactoryTest.php | 25 ++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php index 1b0a02b..cae047f 100644 --- a/test/Unit/Core/RequestHandlerTest.php +++ b/test/Unit/Core/RequestHandlerTest.php @@ -7,6 +7,7 @@ use GuzzleHttp\Psr7\Response; use InvalidArgumentException; use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientExceptionInterface; @@ -424,4 +425,87 @@ public function testPerRequestHeaderOverridesConfiguredAuth(): void $this->httpClient->lastRequest?->getHeaderLine('Authorization'), ); } + + public function testCredentialsOverPlainHttpAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refusing to send Zammad credentials in cleartext to "http://zammad.example"'); + + $this->createHandler( + $this->httpClient, + auth: Auth::token('secret'), + baseUrl: 'http://zammad.example', + ); + } + + public function testCredentialsOverPlainHttpArePermittedWithOptOut(): void + { + $handler = $this->createHandler( + $this->httpClient, + auth: Auth::token('secret'), + baseUrl: 'http://zammad.example', + allowInsecureHttp: true, + ); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->get('tickets'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + /** + * A credential that never leaves the machine is not at risk, so the guard + * must not fire on the loopback defaults this library ships with. + */ + #[DataProvider('loopbackUrlProvider')] + public function testCredentialsOverPlainHttpArePermittedOnLoopback(string $url): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret'), baseUrl: $url); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->get('tickets'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + /** + * @return iterable + */ + public static function loopbackUrlProvider(): iterable + { + yield 'localhost' => ['http://localhost:3000']; + yield 'localhost subdomain' => ['http://zammad.localhost']; + yield 'IPv4 loopback' => ['http://127.0.0.1:8098']; + yield 'IPv4 loopback range' => ['http://127.0.0.2:3000']; + yield 'IPv6 loopback' => ['http://[::1]:3000']; + } + + public function testHttpsIsNeverRejected(): void + { + $handler = $this->createHandler( + $this->httpClient, + auth: Auth::token('secret'), + baseUrl: 'https://zammad.example', + ); + + self::assertInstanceOf(RequestHandler::class, $handler); + } + + /** + * The guard can only see credentials it was given. A handler built without + * an Auth is the documented case for a PSR-18 client that adds the header + * itself — invisible from here, and therefore not rejected. + */ + public function testPlainHttpWithoutAuthIsNotRejected(): void + { + $handler = $this->createHandler($this->httpClient, baseUrl: 'http://zammad.example'); + + self::assertInstanceOf(RequestHandler::class, $handler); + } } diff --git a/test/Unit/Core/Traits/CreatesRequestHandler.php b/test/Unit/Core/Traits/CreatesRequestHandler.php index 91a49c9..4ac1df6 100644 --- a/test/Unit/Core/Traits/CreatesRequestHandler.php +++ b/test/Unit/Core/Traits/CreatesRequestHandler.php @@ -27,13 +27,16 @@ private function createHandler( ClientInterface $client, int $maxRetries = 0, ?Auth $auth = null, + ?string $baseUrl = null, + bool $allowInsecureHttp = false, ): RequestHandler { return new RequestHandler( $client, $this->httpFactory, - self::BASE_URL, + $baseUrl ?? self::BASE_URL, maxRetries: $maxRetries, auth: $auth, + allowInsecureHttp: $allowInsecureHttp, ); } } diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php index ba9e5b3..1a6a0a5 100644 --- a/test/Unit/GuzzleClientFactoryTest.php +++ b/test/Unit/GuzzleClientFactoryTest.php @@ -4,10 +4,12 @@ namespace ZammadAPIClient\Tests\Unit; +use InvalidArgumentException; use Mockery\Adapter\Phpunit\MockeryTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use ReflectionProperty; +use ZammadAPIClient\Core\ConnectionConfig; use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Contracts\ClientInterface; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; @@ -91,6 +93,29 @@ public function testZammadClientCreateUsesTheGivenAuth(): void self::assertSame('Token token=test-token', self::authHeaderOf($client->getHandler())); } + /** + * The cleartext guard lives in RequestHandler, but allowInsecureHttp has to + * travel ConnectionConfig → factory → handler to be reachable from + * ZammadClient::create(). These two pin down that wiring. + */ + public function testCreateRejectsCredentialsOverPlainHttp(): void + { + $this->expectException(InvalidArgumentException::class); + + ZammadClient::create('http://zammad.example', Auth::token('test-token')); + } + + public function testCreateForwardsTheInsecureHttpOptOut(): void + { + $client = ZammadClient::create( + 'http://zammad.example', + Auth::token('test-token'), + new ConnectionConfig(allowInsecureHttp: true), + ); + + self::assertSame('Token token=test-token', self::authHeaderOf($client->getHandler())); + } + /** * Reads the credential the handler will apply. White-box on purpose: the * Guzzle-backed path cannot be observed without making a real request. From 3a66e0eb735c75f4e91a5027dc43aca30ee9bcab Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Wed, 14 Oct 2026 21:17:15 +0200 Subject: [PATCH 7/8] Docs: fix three guides that contradicted what they showed The Symfony section of the custom HTTP client guide offered the create() definition and a custom PSR-18 stack in one services: block, with the custom stack under its own app.zammad_client ID. Autowiring resolves a ZammadClient type-hint to the ZammadAPIClient\ZammadClient ID, so anyone copying that block got the Guzzle client and a dead custom stack. Verified by compiling both variants against symfony/dependency-injection: before, a consumer type-hinting ZammadClient received GuzzleHttp\Client even with the custom stack defined; now it receives the custom PSR-18 client. They are two clearly alternative blocks, both registering the class ID -- the shape SymfonyBundle already uses -- with a note to drop the bundle, since it registers the same ID. The Slim recipe told readers to create a .env and then read $_ENV without loading one. Slim ships no dotenv loader, and $_ENV is empty altogether unless php.ini's variables_order includes E. It now uses getenv(), like every other recipe, and says where the variables are expected to come from in a web app. 08-laravel.php keeps its .env line: Laravel does load it. The v3 migration guide allowed ^3.0 while the instantiation shown directly below it calls ZammadClient::create(), which exists from 3.2.0 on. Also documents the new cleartext guard where credentials are introduced -- README, the custom HTTP client guide and the ConnectionConfig table -- and changes the cookbook's example URL, which used a non-loopback http:// host the guard now rejects. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +++++ README.md | 9 ++++++++- docs/custom-http-client.md | 37 +++++++++++++++++++++++++++++++---- docs/migration-v3.md | 2 +- examples/cookbook/10-slim.php | 13 +++++++++--- 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c7ce3..5dd79be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,20 @@ - `Auth` — credentials as a transport-independent value: `Auth::token()`, `Auth::oauth2()`, `Auth::basic()`. The constructor is private, so the set of valid schemes is closed by construction; empty tokens and usernames are rejected on creation instead of surfacing as a 401 later - `ZammadClient::create(string $url, Auth $auth, ?ConnectionConfig $config = null)` — one-line entry point for the default (Guzzle) transport - `RequestHandler` accepts an optional `auth:` argument and applies the `Authorization` header per request +- `ConnectionConfig::$allowInsecureHttp` / `RequestHandler`'s `allowInsecureHttp:` argument — opt out of the cleartext-credential check described below ### Changed - Authentication no longer depends on the HTTP client supporting client-level default headers. It is applied by `RequestHandler` when composing each request, so any PSR-18 transport authenticates the same way — the hand-written `AuthMiddleware` that the docs previously required for Symfony's `Psr18Client` is obsolete +- **Breaking:** credentials are no longer sent in cleartext. Constructing a client with an `Auth` and a plain `http://` URL whose host is not loopback now throws an `InvalidArgumentException` instead of putting the token on the wire for anyone on the path to read. Loopback is exempt — `localhost`, any `.localhost` name, the whole `127.0.0.0/8` range and `::1` — so every default this project ships (`http://localhost:3000` in the cookbook, `http://127.0.0.1:8098` in both bridges) is unaffected, as is any `https://` URL. **Who is affected:** anyone pointing the client at a remote Zammad over plain HTTP, e.g. an app server talking to an internal instance or a container reaching `http://zammad-nginx:3000`. Switch the URL to `https://`, or, if the connection is already protected by other means (VPN, service mesh, SSH tunnel), opt out with `new ConnectionConfig(allowInsecureHttp: true)` — or `allowInsecureHttp: true` when building a `RequestHandler` directly. The check keys on the `Auth` it is given, so an `Authorization` header that your own PSR-18 client adds stays your responsibility ([#173](https://github.com/zammad/zammad-api-client-php/pull/173)) - `GuzzleClientFactory` takes `(string $url, Auth $auth, ?ConnectionConfig $config)` and no longer sets an `Authorization` default header on the Guzzle client. Its constructor was private before, so no caller is affected - The `@deprecated` marker on `DTOInterface::id()`'s trait implementation is withdrawn. It announced a removal in 4.0 that was never planned, was never mentioned in the 3.0.0 changelog that introduced it, and contradicted both the interface — which requires the method and is not deprecated — and the library's own use of it in `Resource`. Reading `$dto->id` directly remains the more readable form - Documentation: corrected examples that referenced `ZammadClient::withToken()` and the impersonation methods removed in 3.0.0 ([#168](https://github.com/zammad/zammad-api-client-php/issues/168)) - **Fixed:** the Laravel override and custom-middleware examples in the custom HTTP client guide built a client with no credentials at all, so every request would have failed with HTTP 401. They now pass an `Auth` to the `RequestHandler` - **Fixed:** `SymfonyBundle` registered the client with `ContainerBuilder::set()`, which stores a live instance rather than a definition. Instances set that way do not survive container compilation, so any service injecting `ZammadClient` failed the build with *"has a dependency on a non-existent service"* — the bundle never worked in a compiled container. It now registers a lazy `Definition` with `ZammadClient::create()` as its factory and an inlined `Auth` definition, so nothing is instantiated at compile time and the credentials are resolved from `%env(ZAMMAD_TOKEN)%` at runtime instead of being written into `var/cache` - **Fixed:** the password parameters of `Auth::basic()` and `GuzzleClientFactory::withBasicAuth()` are marked `#[\SensitiveParameter]`. An empty username throws from inside `Auth::basic()`, so with PHP's default `zend.exception_ignore_args=0` the password appeared verbatim in the stack trace. The attribute is honoured from PHP 8.2 on and inert on 8.1 +- **Fixed:** the Symfony section of the custom HTTP client guide offered the `create()` definition and a custom PSR-18 stack in one `services:` block, with the custom stack under its own `app.zammad_client` ID. Autowiring resolves a `ZammadClient` type-hint to the `ZammadAPIClient\ZammadClient` ID, so anyone copying that block got the Guzzle client and a dead custom stack. The two are now separate, clearly alternative blocks, both registering the class ID — the same shape `SymfonyBundle` uses +- **Fixed:** the Slim recipe (`examples/cookbook/10-slim.php`) told readers to create a `.env` file and then read `$_ENV` without loading one — and `$_ENV` is empty altogether unless `variables_order` includes `E`. It now uses `getenv()`, like every other recipe +- **Fixed:** the v3 migration guide's `composer require` line allowed `^3.0`, while the client instantiation shown directly below it uses `ZammadClient::create()`, which only exists from 3.2.0 - **Fixed:** the Symfony service IDs in the custom HTTP client guide. `@Symfony\Component\HttpClient\Psr18Client` and `@Nyholm\Psr7\Factory\Psr17Factory` are class names, not registered services, so copying that YAML failed the build. FrameworkBundle aliases `Psr\Http\Client\ClientInterface`, and the `nyholm/psr7` Flex recipe registers `nyholm.psr7.psr17_factory` `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`. They remain supported shorthand for the explicit-`Auth` constructor and are not scheduled for removal; `ZammadClient::create()` is simply the recommended entry point for new code. diff --git a/README.md b/README.md index 9af345b..5cfad2f 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,7 @@ CSV format follows Zammad's import specification (header row with field names ma The primary example is the [`examples/cookbook/`](examples/cookbook/README.md) directory — runnable recipes covering tickets, stateful resources, pagination, error handling, impersonation, and search. Run them against any Zammad instance: ```bash -ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=http://your-zammad:3000 \ +ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=https://your-zammad.example \ ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN=your-token \ php examples/cookbook/01-quick-start.php ``` @@ -392,6 +392,12 @@ $client = ZammadClient::create($url, Auth::token('your-token'), ); ``` +The client refuses to send a credential in cleartext: a `http://` URL pointing at +anything but the local machine throws an `InvalidArgumentException` instead of +leaking the token to the network. Loopback is exempt, so the local defaults keep +working. If the connection is protected by other means — a VPN, a service mesh, a +tunnel — opt out with `new ConnectionConfig(allowInsecureHttp: true)`. + The credential is applied per request by the transport layer, so it works the same with any PSR-18 client. Guzzle is only the default — to use another one, build the handler yourself: @@ -413,6 +419,7 @@ Container wiring and security notes: [Custom HTTP client](docs/custom-http-clien | `timeout` | `int` | `30` | Total request timeout in seconds | | `connectTimeout` | `int` | `10` | Connection timeout in seconds | | `logger` | `?LoggerInterface` | `null` | PSR-3 Logger for HTTP request/retry logging | +| `allowInsecureHttp` | `bool` | `false` | Permit credentials over plain `http://` to a non-loopback host | ## Framework integration diff --git a/docs/custom-http-client.md b/docs/custom-http-client.md index 68f7ead..42eed9c 100644 --- a/docs/custom-http-client.md +++ b/docs/custom-http-client.md @@ -46,6 +46,14 @@ If you need credentials that change at runtime (an OAuth2 token being refreshed, say), wrap your PSR-18 client in a decorator that sets the header per request and leave `auth` unset. +Constructing a `RequestHandler` with an `Auth` and a plain `http://` base URL +throws an `InvalidArgumentException`, because the credential would travel the +network in cleartext. Loopback hosts are exempt; for anything else that is +already protected by other means — a VPN, a service mesh, an SSH tunnel — pass +`allowInsecureHttp: true`. Note that the check sees only the `Auth` you hand it: +a decorator that sets the header itself, as described above, is invisible to it +and remains your responsibility. + If you're migrating away from Guzzle, start with `ZammadClient::create()` and refactor step by step — the handler built above supports exactly the same options. @@ -83,7 +91,15 @@ class AppServiceProvider extends ServiceProvider ## Symfony -The `SymfonyBundle` can be extended or replaced entirely by defining your own service definition: +The `SymfonyBundle` can be extended or replaced entirely by defining your own service definition. + +Both variants below register the client under the service ID `ZammadAPIClient\ZammadClient`, +because that is the ID Symfony resolves when a service type-hints `ZammadClient`. Pick +**one** of them, and remove `SymfonyBundle` from `config/bundles.php` — the bundle +registers the very same ID, so leaving it enabled means one definition silently wins over +the other. + +Either the default (Guzzle) transport: ```yaml # config/services.yaml @@ -99,8 +115,21 @@ services: arguments: $url: '%env(ZAMMAD_URL)%' $auth: '@app.zammad_auth' +``` + +…or your own PSR-18 stack. Note that the class service ID is the one that gets the custom +handler — a separate `app.zammad_client` ID would be ignored by autowiring, and every +service type-hinting `ZammadClient` would silently keep using the Guzzle definition above: + +```yaml +# config/services.yaml +services: + app.zammad_auth: + class: ZammadAPIClient\Core\Auth + factory: ['ZammadAPIClient\Core\Auth', 'token'] + arguments: + $token: '%env(ZAMMAD_TOKEN)%' - # Or with a custom PSR-18 stack: app.zammad_request_handler: class: ZammadAPIClient\Core\Transport\RequestHandler arguments: @@ -109,8 +138,7 @@ services: $baseUrl: '%env(ZAMMAD_URL)%' $auth: '@app.zammad_auth' - app.zammad_client: - class: ZammadAPIClient\ZammadClient + ZammadAPIClient\ZammadClient: arguments: - '@app.zammad_request_handler' ``` @@ -161,4 +189,5 @@ Building the handler yourself means the defaults `ZammadClient::create()` applie - **Configure timeouts**: Always set a request and a connection timeout on your PSR-18 client. `ZammadClient::create()` defaults to 30s and 10s (`ConnectionConfig::$timeout` / `$connectTimeout`). - **Verify TLS**: Do not disable TLS verification in production. `ZammadClient::create()` defaults to `verifySsl = true`. - **Redirects**: `ZammadClient::create()` disables redirect following (`allow_redirects = false`). Configure your own client carefully — following redirects may leak the `Authorization` header to unintended hosts. +- **Plain HTTP**: `RequestHandler` refuses an `Auth` over `http://` to a non-loopback host, and `allowInsecureHttp: true` turns that off. The check cannot see an `Authorization` header your own PSR-18 decorator adds — if you authenticate that way, make sure the URL is `https://` yourself. - **Debug logging**: Pass a PSR-3 logger as the `logger:` argument of `RequestHandler` (on the `create()` path it comes from `ConnectionConfig::$logger`). Never enable raw HTTP debug output in production — it will expose API tokens in log files. diff --git a/docs/migration-v3.md b/docs/migration-v3.md index 113c81a..5faec0e 100644 --- a/docs/migration-v3.md +++ b/docs/migration-v3.md @@ -28,7 +28,7 @@ ### 1. Update composer.json ```bash -composer require zammad/zammad-api-client-php:^3.0 +composer require zammad/zammad-api-client-php:^3.2 ``` ### 2. Replace Client Instantiation diff --git a/examples/cookbook/10-slim.php b/examples/cookbook/10-slim.php index 7bb9149..3c1e470 100644 --- a/examples/cookbook/10-slim.php +++ b/examples/cookbook/10-slim.php @@ -10,7 +10,14 @@ * * Requirements: * composer require slim/slim slim/psr7 php-di/php-di zammad/zammad-api-client-php - * .env → ZAMMAD_URL=https://zammad.example ZAMMAD_TOKEN=xxx + * + * ZAMMAD_URL and ZAMMAD_TOKEN come from the environment — your php-fpm pool, your + * container, or `export` in the shell for the built-in server. Read with getenv(), + * like the other recipes: $_ENV is empty unless php.ini's variables_order includes + * E, and Slim loads no .env file of its own. + * + * export ZAMMAD_URL=https://zammad.example + * export ZAMMAD_TOKEN=xxx * * public/index.php: * @@ -22,8 +29,8 @@ * $builder = new ContainerBuilder(); * $builder->addDefinitions([ * ZammadClient::class => fn (): ZammadClient => ZammadClient::create( - * $_ENV['ZAMMAD_URL'], - * Auth::token($_ENV['ZAMMAD_TOKEN']), + * getenv('ZAMMAD_URL') ?: 'http://localhost:3000', + * Auth::token(getenv('ZAMMAD_TOKEN') ?: ''), * ), * ]); * From 4980e1d5cd478979222ee5f7d1bebd34c5a650bd Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Wed, 14 Oct 2026 21:24:28 +0200 Subject: [PATCH 8/8] Docs: fix the no-Flex PSR-17 factory fallback in the Symfony guide The custom-stack YAML references '@nyholm.psr7.psr17_factory', but the fallback for installations without the Flex recipe declared the service under its class name instead. A reader following it hit exactly the error the surrounding paragraph exists to prevent: The service "app.zammad_request_handler" has a dependency on a non-existent service "nyholm.psr7.psr17_factory". The reference is explicit, so autowiring does not bridge the two IDs. The fallback now declares the ID the handler actually asks for. Verified by compiling the snippets extracted from this file against symfony/dependency-injection with nyholm/psr7 installed and no Flex-provided service present: the previous fallback failed with the error above, the new one compiles and yields Nyholm\Psr7\Factory\Psr17Factory. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- docs/custom-http-client.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd79be..e3dd866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - **Fixed:** the Symfony section of the custom HTTP client guide offered the `create()` definition and a custom PSR-18 stack in one `services:` block, with the custom stack under its own `app.zammad_client` ID. Autowiring resolves a `ZammadClient` type-hint to the `ZammadAPIClient\ZammadClient` ID, so anyone copying that block got the Guzzle client and a dead custom stack. The two are now separate, clearly alternative blocks, both registering the class ID — the same shape `SymfonyBundle` uses - **Fixed:** the Slim recipe (`examples/cookbook/10-slim.php`) told readers to create a `.env` file and then read `$_ENV` without loading one — and `$_ENV` is empty altogether unless `variables_order` includes `E`. It now uses `getenv()`, like every other recipe - **Fixed:** the v3 migration guide's `composer require` line allowed `^3.0`, while the client instantiation shown directly below it uses `ZammadClient::create()`, which only exists from 3.2.0 -- **Fixed:** the Symfony service IDs in the custom HTTP client guide. `@Symfony\Component\HttpClient\Psr18Client` and `@Nyholm\Psr7\Factory\Psr17Factory` are class names, not registered services, so copying that YAML failed the build. FrameworkBundle aliases `Psr\Http\Client\ClientInterface`, and the `nyholm/psr7` Flex recipe registers `nyholm.psr7.psr17_factory` +- **Fixed:** the Symfony service IDs in the custom HTTP client guide. `@Symfony\Component\HttpClient\Psr18Client` and `@Nyholm\Psr7\Factory\Psr17Factory` are class names, not registered services, so copying that YAML failed the build. FrameworkBundle aliases `Psr\Http\Client\ClientInterface`, and the `nyholm/psr7` Flex recipe registers `nyholm.psr7.psr17_factory`. The fallback for installations without Flex, in that same section, then declared the factory under its class name while the handler referenced `nyholm.psr7.psr17_factory` — reproducing the very *"has a dependency on a non-existent service"* build error it was meant to avoid. It now declares the ID the handler asks for `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`. They remain supported shorthand for the explicit-`Auth` constructor and are not scheduled for removal; `ZammadClient::create()` is simply the recommended entry point for new code. diff --git a/docs/custom-http-client.md b/docs/custom-http-client.md index 42eed9c..2f015ed 100644 --- a/docs/custom-http-client.md +++ b/docs/custom-http-client.md @@ -148,10 +148,12 @@ FrameworkBundle aliases `Psr\Http\Client\ClientInterface` to its `psr18.http_cli service as soon as `symfony/http-client` is installed, and `nyholm.psr7.psr17_factory` comes from the `nyholm/psr7` Flex recipe. The class names are *not* service IDs — the package itself ships no DI configuration. If you installed it without Flex, declare the -factory yourself: +factory under the ID referenced above — the reference is explicit, so autowiring will not +bridge a differently named service: ```yaml - Nyholm\Psr7\Factory\Psr17Factory: ~ + nyholm.psr7.psr17_factory: + class: Nyholm\Psr7\Factory\Psr17Factory ``` ## Custom PSR-18 Middleware Stack