From 7f661d9aba1d0eee88148384d49c2aea7cabb22d Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 12 Oct 2026 16:05:10 +0200 Subject: [PATCH 1/4] 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 111460f574b1989917af5a01e24029228ea8ee5a Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 12 Oct 2026 16:41:47 +0200 Subject: [PATCH 2/4] =?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 0b8b20e28a7f9e8fc560c937a794026ba011fe52 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 12 Oct 2026 17:17:39 +0200 Subject: [PATCH 3/4] 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 2926f7a07a65f1b3f9ebd74e00a02811ed12001f Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 12 Oct 2026 17:34:45 +0200 Subject: [PATCH 4/4] 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 */