.. include:: /Includes.rst.txt .. _adr-008-http-client: ========================= ADR-008: HTTP client ========================= .. contents:: Table of contents :local: :depth: 2 Status ====== Accepted Date ==== 2026-01-03 Context ======= Applications often need to make authenticated HTTP requests to external APIs using secrets stored in the vault. The typical pattern exposes secrets to application code: .. code-block:: php :caption: Typical insecure pattern $apiKey = $vault->retrieve('stripe_api_key'); $client->request('POST', '/charges', [ 'headers' => ['Authorization' => 'Bearer ' . $apiKey], ]); // $apiKey remains in memory, possibly logged This approach: - Exposes secrets to application code - Risks logging secrets in debug output - Requires manual memory cleanup - Duplicates authentication logic across services Problem statement ================= How can applications make authenticated HTTP requests using vault secrets without exposing the secret values to application code? Decision drivers ================ - **Secret isolation**: Application code should never see raw secrets - **Memory safety**: Secrets cleared from memory immediately after use - **Standards compliance**: Use PSR-18 for HTTP client interoperability - **Flexibility**: Support various authentication methods - **Auditability**: Log API calls without exposing credentials - **Simplicity**: Fluent API for common use cases Considered options ================== Option 1: Helper methods returning configured clients ----------------------------------------------------- Factory methods that return pre-configured HTTP clients. **Pros:** - Simple API **Cons:** - Secrets exposed during client creation - Limited flexibility - Hard to audit Option 2: Request middleware/interceptor ---------------------------------------- Middleware that injects credentials into requests. **Pros:** - Transparent injection **Cons:** - Framework-specific (Guzzle middleware vs PSR-18) - Complex configuration Option 3: PSR-18 wrapper with fluent authentication API ------------------------------------------------------- Immutable wrapper implementing PSR-18 with authentication configuration. **Pros:** - Standards-compliant (PSR-18) - Immutable (thread-safe, predictable) - Fluent API for configuration - Secret injection at request time **Cons:** - Wrapper overhead - Must implement all PSR-18 methods Decision ======== We chose **PSR-18 wrapper with fluent authentication API** because: 1. **Standards compliance**: Works with any PSR-18 compatible code 2. **Immutability**: Each ``with*`` call returns new instance, preventing state issues 3. **Late binding**: Secrets retrieved only when request is sent 4. **Memory safety**: ``sodium_memzero()`` clears secrets after injection 5. **Audit integration**: Logs API calls with secret identifiers (not values) Implementation ============== Interface design ---------------- .. code-block:: php :caption: Classes/Http/VaultHttpClientInterface.php interface VaultHttpClientInterface extends ClientInterface { public function withAuthentication( string $secretIdentifier, SecretPlacement $placement = SecretPlacement::Bearer, array $options = [], ): static; public function withOAuth(OAuthConfig $config, string $reason = ''): static; public function withReason(string $reason): static; } SecretPlacement enum -------------------- Type-safe authentication placement options: .. code-block:: php :caption: Classes/Http/SecretPlacement.php enum SecretPlacement: string { case Bearer = 'bearer'; // Authorization: Bearer {secret} case BasicAuth = 'basic'; // Authorization: Basic {base64} case Header = 'header'; // Custom header case QueryParam = 'query'; // URL query parameter case BodyField = 'body_field'; // Request body field case OAuth2 = 'oauth2'; // OAuth 2.0 with token refresh case ApiKey = 'api_key'; // X-API-Key header } Fluent API usage ---------------- .. code-block:: php :caption: Using VaultHttpClient use GuzzleHttp\Psr7\Request; use Netresearch\NrVault\Http\SecretPlacement; // Bearer authentication $response = $this->httpClient ->withAuthentication('stripe_api_key', SecretPlacement::Bearer) ->sendRequest(new Request('POST', 'https://api.stripe.com/v1/charges')); // Custom header $response = $this->httpClient ->withAuthentication('api_token', SecretPlacement::Header, [ 'headerName' => 'X-API-Key', ]) ->sendRequest(new Request('GET', 'https://api.example.com/data')); // Basic authentication with two secrets $response = $this->httpClient ->withAuthentication('service_password', SecretPlacement::BasicAuth, [ 'usernameSecret' => 'service_username', 'reason' => 'Fetching secure data', ]) ->sendRequest(new Request('GET', 'https://api.example.com/secure')); Immutable implementation ------------------------ .. code-block:: php :caption: Classes/Http/VaultHttpClient.php final readonly class VaultHttpClient implements VaultHttpClientInterface { public function __construct( private VaultServiceInterface $vaultService, private AuditLogServiceInterface $auditLogService, private ?ClientInterface $innerClient = null, private ?string $secretIdentifier = null, private ?SecretPlacement $placement = null, // ... other configuration ) {} public function withAuthentication( string $secretIdentifier, SecretPlacement $placement = SecretPlacement::Bearer, array $options = [], ): static { // Return NEW instance with updated configuration return new self( $this->vaultService, $this->auditLogService, $this->innerClient, $secretIdentifier, $placement, // ... merge options ); } public function sendRequest(RequestInterface $request): ResponseInterface { // Inject authentication into request $request = $this->injectAuthentication($request); // Send request $response = $this->getInnerClient()->sendRequest($request); // Audit log (secret identifier, not value) $this->logHttpCall($request, $response); return $response; } } Memory-safe secret injection ---------------------------- .. code-block:: php :caption: Secure injection with immediate cleanup private function injectBearer(RequestInterface $request): RequestInterface { $secret = $this->vaultService->retrieve($this->secretIdentifier); try { return $request->withHeader('Authorization', 'Bearer ' . $secret); } finally { sodium_memzero($secret); // Clear from memory immediately } } private function injectBasicAuth(RequestInterface $request): RequestInterface { $password = $this->vaultService->retrieve($this->secretIdentifier); $username = $this->usernameSecretIdentifier ? $this->vaultService->retrieve($this->usernameSecretIdentifier) : ''; try { $credentials = base64_encode($username . ':' . $password); return $request->withHeader('Authorization', 'Basic ' . $credentials); } finally { sodium_memzero($password); if ($username !== '') { sodium_memzero($username); } } } OAuth 2.0 support ----------------- .. code-block:: php :caption: OAuth configuration $config = OAuthConfig::clientCredentials( tokenUrl: 'https://oauth.example.com/token', clientIdSecret: 'oauth_client_id', clientSecretSecret: 'oauth_client_secret', scopes: ['read', 'write'], ); $response = $this->httpClient ->withOAuth($config, 'API access') ->sendRequest($request); The :php:`OAuthTokenManager` handles: - Token caching (in-memory) - Automatic refresh before expiry - Secure credential handling Secure client factory --------------------- .. code-block:: php :caption: Classes/Http/SecureHttpClientFactory.php final class SecureHttpClientFactory { public function create(): ClientInterface { return new Client([ 'debug' => false, // Never log request/response bodies 'http_errors' => false, // Handle errors in vault client // Respect TYPO3 HTTP settings 'proxy' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['proxy'] ?? null, 'verify' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['verify'] ?? true, 'timeout' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout'] ?? 30, ]); } } Audit logging ------------- .. code-block:: php :caption: Logging without exposing secrets private function logHttpCall(RequestInterface $request, ResponseInterface $response): void { $this->auditLogService->log( $this->secretIdentifier, // Which secret was used 'http_call', $response->getStatusCode() < 400, // Success flag null, $this->reason, context: HttpCallContext::fromRequest( $request->getMethod(), (string) $request->getUri(), $response->getStatusCode(), ), ); } Consequences ============ Positive -------- - **Secret isolation**: Application code never sees raw secret values - **Memory safety**: ``sodium_memzero()`` clears secrets immediately - **Standards compliance**: PSR-18 compatible, works with any framework - **Immutable design**: Thread-safe, predictable behavior - **Audit trail**: API calls logged with context, not credentials - **TYPO3 integration**: Respects proxy, SSL, timeout settings - **No debug leaks**: ``debug: false`` prevents request/response logging Negative -------- - **Wrapper overhead**: Additional object creation per request - **PSR-18 limitation**: Async requests not supported by PSR-18 - **Memory pressure**: Brief window where secret exists in memory Risks ----- - Exception handlers might capture request objects with injected secrets - Memory dumps could expose secrets during injection window Mitigation ---------- - Use ``try/finally`` to ensure cleanup even on exceptions - Avoid storing configured requests in variables - Document memory safety requirements Related decisions ================= - :ref:`adr-006-audit-logging` - HTTP calls are logged References ========== - `PSR-18 HTTP Client `_ - `libsodium Memory Management `_