v0.6.0
Released on the main branch as Composer tag v0.6.0.
Upgrade summary
{
"require": {
"better-route/better-route": "^0.6.0"
}
}
0.6.0 is additive — there are no breaking changes for existing 0.5.0 callers. The release ships general-purpose security primitives for integration-heavy WordPress REST APIs:
- asymmetric JWT verification from a JWKS document (RS256/ES256)
- shared crypto utilities for tokens, encoding, and constant-time compare
- trusted-proxy aware client IP resolution and CIDR allowlists
- HMAC request signatures with replay-window enforcement
- single-use token consumption (OAuth codes, magic links, password resets)
- OAuth RFC 6749 style error responses opt-in per route
The release is library-level only. It contains no wallet-specific or domain-specific logic.
Asymmetric JWT verification (RS256/ES256)
Hs256JwtVerifier (since 0.3.0) covers symmetric JWTs with a shared secret. 0.6.0 adds asymmetric verification backed by a JWKS document, which is what OIDC and most modern auth providers actually emit:
BetterRoute\Middleware\Jwt\Rs256JwksJwtVerifier—JwtVerifierInterfaceimplementation forRS256andES256tokens. Strict JOSEkidmatching, explicit algorithm allowlist, issuer/audience/time claim validation, max token length, max lifetime cap.BetterRoute\Middleware\Jwt\JwksProviderInterface— provider contract withkeys()andrefresh().BetterRoute\Middleware\Jwt\HttpJwksProvider— fetches JWKS over HTTPS throughwp_remote_get(withsslverify => true), caches via transients, registers thebetter_route/jwks_refreshaction for cache invalidation.BetterRoute\Middleware\Jwt\StaticJwksProvider— in-memory provider for tests.BetterRoute\Middleware\Jwt\JwksKeySanitizer— internal helper that strips private JWK fields before they reach the verifier.
Hardening highlights:
noneandHS*algorithms are rejected at the constructor — even if a misconfigured deployment passes them in$allowedAlgorithms.- The verifier never iterates every key to "find one that verifies." Strict
kidmatch is required; on miss the provider is refreshed once and the lookup is retried. - RSA keys must declare
kty=RSAand carryn+e; ES256 keys must declarekty=EC,crv=P-256, and carryx+y. Optionalusemust besigand optionalalgmust match the token algorithm. HttpJwksProviderrejects non-httpsURIs at construction.
See JWT and Bearer.
Crypto utilities
A shared BetterRoute\Support\Crypto helper backs the new primitives and is exposed for handler-level code that needs the same building blocks:
Crypto::equals(string $known, string $user): bool—hash_equalsconstant-time compare.Crypto::token(int $bytes = 32, string|CryptoEncoding $encoding = Base64Url): string— CSPRNG token viarandom_bytes.Crypto::tokenHex(int $bytes = 32): string— hex-encoded CSPRNG token.Crypto::base64UrlEncode(string $raw): string/Crypto::base64UrlDecode(string $encoded): string— strict base64url that rejects malformed input.BetterRoute\Support\CryptoEncodingenum (Hex,Base64,Base64Url).
Hs256JwtVerifier was rewired to use Crypto::equals() and Crypto::base64UrlDecode() — public behavior is unchanged but the primitives are now available outside the verifier.
Trusted-proxy IP resolution and CIDR allowlists
The legacy BetterRoute\Http\ClientIpResolver (since 0.3.0) keeps working — it now delegates internally to the hardened resolver below — but new code should reach for the network-aware primitives directly:
BetterRoute\Middleware\Network\TrustedProxyClientIpResolver— readsCF-Connecting-IP/X-Forwarded-Foronly whenREMOTE_ADDRis inside a trusted proxy CIDR. Otherwise the headers are ignored. Header priority is constructor-controlled.X-Forwarded-Foruses the first valid IP in the comma-delimited list.BetterRoute\Middleware\Network\ClientIpResolverInterface— minimalresolve(mixed $request = null): ?stringcontract;RateLimitMiddlewareaccepts it next to the legacyClientIpResolver.BetterRoute\Middleware\Network\CidrMatcher— IPv4 and IPv6 aware matcher; single IPs without/prefixare also accepted.BetterRoute\Middleware\Network\IpAllowlistMiddleware— rejects requests whose resolved client IP is outside the allowlist.failClosed: true(default) returns403 client_ip_unavailablewhen the resolver cannot determine an IP;403 client_ip_not_allowedwhen the IP does not match a CIDR.
Http\ClientIpResolver continues to expose its existing resolve(?array $server = null) constructor and method, so 0.5.0 callers do not need to change anything. New proxy-aware code should prefer TrustedProxyClientIpResolver.
HMAC request signatures
For server-to-server webhooks where Bearer tokens are not enough, HmacSignatureMiddleware validates a signed request before the handler runs:
BetterRoute\Middleware\Auth\HmacSignatureMiddleware— verifies signature, timestamp, and key id. Default headers:X-Signature,X-Timestamp,X-Key-Id. Default replay window:300seconds. Default algorithm:sha256.BetterRoute\Middleware\Auth\HmacSecretProviderInterface—secretFor(string $keyId): ?stringfor multi-key rotation.BetterRoute\Middleware\Auth\ArrayHmacSecretProvider— static map provider.
Canonical input:
timestamp + "\n" + method + "\n" + path + "\n" + sha256(body)
Accepted signature encodings (compared with Crypto::equals()):
- lowercase hex
- uppercase hex
- base64
- base64url
- the same values prefixed with
sha256=
Behavior:
- Unknown key IDs and missing/empty secrets fail closed with
401 invalid_signature. - A timestamp outside the replay window fails closed with
401 stale_signature. - An invalid timestamp format fails closed with
401 invalid_signature_timestamp. - On success, the middleware writes safe metadata (
['keyId' => ..., 'algorithm' => ...]) into request attributes underhmac. Raw secrets are never reflected.
Single-use tokens
OAuth authorization codes, password reset links, magic-link tokens — each one must be consumed at most once and the consumption must be atomic across concurrent requests. 0.6.0 adds a contract and three stores:
BetterRoute\Middleware\Write\SingleUseTokenMiddleware— consumes a token before the handler runs.BetterRoute\Middleware\Write\SingleUseTokenStoreInterface—consume(string $tokenHash): ?array,store(string $tokenHash, array $context, int $ttlSeconds): void,wasConsumed(string $tokenHash): bool.BetterRoute\Middleware\Write\ArraySingleUseTokenStore— in-memory store for tests.BetterRoute\Middleware\Write\WpdbSingleUseTokenStore—wpdb-backed store withinstallSchema()and TTL pruning.BetterRoute\Middleware\Write\WpCacheSingleUseTokenStore— useswp_cache_addfor a short-lived consume lock plus transient-backed records and used-marker.
Behavior:
- Token values are hashed with HMAC-SHA256 (
SingleUseTokenMiddleware::hashToken()) before reaching any store. The salt is caller-provided; if omitted, the middleware falls back towp_salt('better_route_single_use_token'). - The consumed context is attached to request attributes under
singleUseTokenso handlers can read what was bound to the token at issue time. - Reusing a consumed token throws
409 single_use_token_reused(ConflictException). - Unknown or expired tokens fail closed with
401 invalid_single_use_token. - Missing tokens fail closed with
400 single_use_token_required.
See Single-use tokens.
OAuth RFC 6749 error format
Routes that wrap an OAuth surface (/oauth/token, /oauth/authorize) need to emit OAuth-style error bodies, not the standard better-route envelope. 0.6.0 adds an opt-in:
$router->post('/oauth/token', $handler)
->meta(['error_format' => 'oauth_rfc6749'])
->publicRoute();
Response shape:
{
"error": "invalid_request",
"error_description": "Invalid request."
}
Optional error_uri is emitted when details.error_uri (or errorUri) is populated on the thrown exception.
BetterRoute\Http\OAuthErrorNormalizer is the new normalizer. Router::dispatch() now passes route metadata into RequestContext::$attributes['routeMeta'], and ResponseNormalizer switches normalizers based on that. internal_error is rewritten to server_error for 5xx responses to match the spec. The default better-route error envelope is unchanged for every other route.
Files added
src/Http/OAuthErrorNormalizer.phpsrc/Middleware/Auth/ArrayHmacSecretProvider.phpsrc/Middleware/Auth/HmacSecretProviderInterface.phpsrc/Middleware/Auth/HmacSignatureMiddleware.phpsrc/Middleware/Jwt/HttpJwksProvider.phpsrc/Middleware/Jwt/JwksKeySanitizer.phpsrc/Middleware/Jwt/JwksProviderInterface.phpsrc/Middleware/Jwt/Rs256JwksJwtVerifier.phpsrc/Middleware/Jwt/StaticJwksProvider.phpsrc/Middleware/Network/CidrMatcher.phpsrc/Middleware/Network/ClientIpResolverInterface.phpsrc/Middleware/Network/IpAllowlistMiddleware.phpsrc/Middleware/Network/TrustedProxyClientIpResolver.phpsrc/Middleware/Write/ArraySingleUseTokenStore.phpsrc/Middleware/Write/SingleUseTokenMiddleware.phpsrc/Middleware/Write/SingleUseTokenStoreInterface.phpsrc/Middleware/Write/WpCacheSingleUseTokenStore.phpsrc/Middleware/Write/WpdbSingleUseTokenStore.phpsrc/Support/Crypto.phpsrc/Support/CryptoEncoding.php- tests:
tests/SecurityPrimitivesTest.php
Files changed
src/Http/ClientIpResolver.php— delegates toTrustedProxyClientIpResolver; constructor andresolve()API unchangedsrc/Http/ResponseNormalizer.php— selectsOAuthErrorNormalizerwhenrouteMeta.error_format === 'oauth_rfc6749'src/Middleware/Jwt/Hs256JwtVerifier.php— usesCrypto::equals()andCrypto::base64UrlDecode(); behavior unchangedsrc/Middleware/RateLimit/RateLimitMiddleware.php—$clientIpResolvernow acceptsHttp\ClientIpResolverorMiddleware\Network\ClientIpResolverInterfaceornullsrc/Router/Router.php— dispatch adds normalized route metadata toRequestContext::$attributes['routeMeta']src/Support/Version.php—0.6.0-devREADME.md— 0.6.0 changelog and feature list
Compared to 0.5.0
- 0.5.0 hardened public-client APIs (atomic idempotency, CORS, ownership guards, audit enrichment).
- 0.6.0 hardens the identity boundary: who the caller is (RS256/ES256 JWKS, HMAC signatures), where they come from (trusted-proxy IP + CIDR allowlist), and what one-time grants they can spend (single-use tokens). It also adds an OAuth-compatible error envelope for routes that mimic an OAuth provider.
Hs256JwtVerifierkeeps working — symmetric secrets are still fine for first-party JWTs. UseRs256JwksJwtVerifierwhen the issuer publishes a JWKS (most OIDC providers).Http\ClientIpResolverkeeps working — it now delegates internally to the trusted-proxy resolver. New code should preferTrustedProxyClientIpResolverdirectly.BearerTokenAuthMiddlewareandJwtAuthMiddlewarealready accepted both array scopes and OIDC-style space-delimitedscopestrings; 0.6.0 adds regression coverage for that behavior.
Breaking change checklist
None. 0.6.0 is purely additive.
| Change | Action |
|---|---|
| New middlewares (JWKS, HMAC, IP allowlist, single-use token) | Opt-in per route or group |
Http\ClientIpResolver delegates to TrustedProxyClientIpResolver | No action — constructor and resolve() API unchanged |
RateLimitMiddleware accepts the new ClientIpResolverInterface | No action — existing constructor calls keep working |
Router adds routeMeta to RequestContext::$attributes | No action — existing handlers ignore unknown attributes |
| OAuth error format is route-level opt-in | No action unless meta(['error_format' => 'oauth_rfc6749']) is set |
| Single-use token stores need a salt | If you skip the $hashSalt constructor argument, ensure WordPress is loaded so wp_salt() is available — otherwise pass an explicit salt |