ADR-027: OAuth token requests use the secure HTTP client
Table of contents
Status
Accepted — documents the unified state after PRs #145, #146, and
the LOW-followups PR #148 land. The cache-key extension to
clientSecretSecret and the redactCredentials helper are
specifically introduced in #148; the rest is on main as of
PR #146.
Date
2026-05-22
Context
Classes/Http/OAuth/OAuthTokenManager issues token-endpoint
requests for adapters that need OAuth client-credentials /
refresh-token flows. Until PR #145 the token request path used a
ClientInterface injected via constructor and did not go
through SecureHttpClientFactory. Two real consequences:
- SSRF protections were missing on the token endpoint. The
isHostAllowed/isDangerousIpLiteralchecks documented in ADR-010: Secure Outbound inside nr-vault ran on the adapter's secret-fetch URL but not on its OAuth token URL — an attacker who could write theoauth.config could pivot to internal infrastructure even though the secret URL itself was hardened.token Endpoint - Cache-key collision risk. The token cache key originally
hashed
tokenEndpoint + clientIdSecret + grantType + scopes. Two adapters that shared a tokenEndpoint + clientId + scope but used different client secrets (e.g., a rotation in progress) would collide and serve the wrong cached token.
A separate finding (multi-axis review) was that error-path log
messages and audit-log payloads could quote
$exception->getMessage() which — for token failures — frequently
echoes back the request body, leaking client_secret=,
refresh_token=, and Authorization: Bearer values into
warm storage.
Decision
Three converging changes packaged as PRs #145 and #146:
- Mandatory hardened client.
OAuthTokenManager'sClientInterface $httpClientconstructor parameter no longer defaults tonew GuzzleHttp\\Client(...). Callers MUST inject a hardened client — in practice every production caller threads one built bySecureHttpClientFactory::create(), so the SSRF middleware (see ADR-026: DNS-rebinding defence via CURLOPT_RESOLVE) protects the token endpoint just like every other outbound request. Removing the default closes the only path that constructed a raw Guzzle client at runtime. The architectural lock added in ADR-028: PHPat architectural lock for HTTP client construction enforces that no other code re-introduces one. - tokenEndpoint isHostAllowed gate.
OAuthTokenManageradditionally accepts aSecureHttpClientFactorysolely to gate the configuredtokenEndpointhost throughisHostAllowed()before any token request fires. The DNS-pin middleware on the injected client rejects dangerous resolved IPs at request time, but it does NOT apply the$GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts']allowlist admins use to restrict outbound calls to a known set of partner hostnames. Without this extra gate, an attacker-controlled config pointing athttp://169.254.169.254would fail per-request DNS validation but the host-allowlist failure mode (a hard, audit-logged refusal pre-flight) was missing. -
Cache key fragmentation by secret. The OAuth token-cache key now includes
$config->clientSecretSecret(the vault handle, not the secret value) so a credential rotation invalidates the cache cleanly:hash('xxh128', implode(':', [ $config->tokenEndpoint, $config->clientIdSecret, $config->clientSecretSecret, // new $config->grantType, $config->getScopesString(), ]));Copied! - Credential redaction in error paths. A
redactCredentialshelper replacesclient_secret=...,refresh_token=..., andAuthorization: Bearer .../Basic ...patterns before the original exception message reaches the logger or the audit log. Four call-sites cover both the synchronous Guzzle exception path and the async retry path.
Consequences
Positive
- Token endpoints get the full DNS-rebinding + isHostAllowed defence that secret URLs already had.
- Credential rotation no longer needs a manual cache-bust.
- Token-endpoint failures leave no credential material in logs or audit rows — replayable secrets stay in the vault.
- Tested via fuzz suite (regression added: malformed token responses no longer leak secrets via the exception chain).
Negative
- Constructor signature change is a positional BC break for callers
using positional arguments:
OAuthTokenManagernow takes `Vault`. The new required third parameter (Service Interface, Client Interface, Secure Http Client Factory, ?Logger Interface, ?Request Factory Interface, ?Stream Factory Interface, ?Audit Log Service Interface SecureHttpClientFactory) shifts every parameter after it. Callers using named arguments are unaffected; positional callers must update — DI containers were updated in lockstep. Optional params are confined to the trailing positions (no required parameter follows an optional one) so adding future optional dependencies stays additive. - Adapter authors must invalidate their own caches if they cache
OAuthTokenManagerinstances across config changes. (Most adapters fetch the manager from DI per request, so this is a non-issue in practice.)
Verified
- Unit tests: cache-key fragmentation regression, isHostAllowed gate, credential-redaction across all four call-sites.
- Architecture test (ADR-028: PHPat architectural lock for HTTP client construction)
enforces that only
SecureHttpClientFactoryconstructs Guzzle clients.