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
client and the redact helper are
specifically introduced in #148; the rest is on main as of
PR #146.
Date
2026-05-22
Context
Classes/ issues token-endpoint
requests for adapters that need OAuth client-credentials /
refresh-token flows. Until PR #145 the token request path used a
Client injected via constructor and did not go
through Secure. Two real consequences:
- SSRF protections were missing on the token endpoint. The
is/Host Allowed ischecks 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 theDangerous Ip Literal oauth.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
token. 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.Endpoint + client Id Secret + grant Type + scopes
A separate finding (multi-axis review) was that error-path log
messages and audit-log payloads could quote
$exception->get which — for token failures — frequently
echoes back the request body, leaking client_,
refresh_, and Authorization: Bearer values into
warm storage.
Decision
Three converging changes packaged as PRs #145 and #146:
- Mandatory hardened client.
OAuth'sToken Manager Clientconstructor parameter no longer defaults toInterface $http Client new Guzzle. Callers MUST inject a hardened client — in practice every production caller threads one built byHttp\\ Client (...) Secure, 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.Http Client Factory:: create () - tokenEndpoint isHostAllowed gate.
OAuthadditionally accepts aToken Manager Securesolely to gate the configuredHttp Client Factory tokenhost throughEndpoint isbefore any token request fires. The DNS-pin middleware on the injected client rejects dangerous resolved IPs at request time, but it does NOT apply theHost Allowed () $GLOBALSallowlist admins use to restrict outbound calls to a known set of partner hostnames. Without this extra gate, an attacker-controlled config pointing at['TYPO3_ CONF_ VARS'] ['HTTP'] ['allowed_ hosts'] http://would fail per-request DNS validation but the host-allowlist failure mode (a hard, audit-logged refusal pre-flight) was missing.169. 254. 169. 254 -
Cache key fragmentation by secret. The OAuth token-cache key now includes
$config->client(the vault handle, not the secret value) so a credential rotation invalidates the cache cleanly:Secret Secret hash('xxh128', implode(':', [ $config->tokenEndpoint, $config->clientIdSecret, $config->clientSecretSecret, // new $config->grantType, $config->getScopesString(), ]));Copied! - Credential redaction in error paths. A
redacthelper replacesCredentials client_,secret=... refresh_, andtoken=... Authorization: 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:
OAuthnow takes `Token Manager 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 Secure) 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.Http Client Factory - Adapter authors must invalidate their own caches if they cache
OAuthinstances across config changes. (Most adapters fetch the manager from DI per request, so this is a non-issue in practice.)Token Manager
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
Secureconstructs Guzzle clients.Http Client Factory