Skip to main content

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\Rs256JwksJwtVerifierJwtVerifierInterface implementation for RS256 and ES256 tokens. Strict JOSE kid matching, explicit algorithm allowlist, issuer/audience/time claim validation, max token length, max lifetime cap.
  • BetterRoute\Middleware\Jwt\JwksProviderInterface — provider contract with keys() and refresh().
  • BetterRoute\Middleware\Jwt\HttpJwksProvider — fetches JWKS over HTTPS through wp_remote_get (with sslverify => true), caches via transients, registers the better_route/jwks_refresh action 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:

  • none and HS* 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 kid match is required; on miss the provider is refreshed once and the lookup is retried.
  • RSA keys must declare kty=RSA and carry n + e; ES256 keys must declare kty=EC, crv=P-256, and carry x + y. Optional use must be sig and optional alg must match the token algorithm.
  • HttpJwksProvider rejects non-https URIs 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): boolhash_equals constant-time compare.
  • Crypto::token(int $bytes = 32, string|CryptoEncoding $encoding = Base64Url): string — CSPRNG token via random_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\CryptoEncoding enum (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 — reads CF-Connecting-IP / X-Forwarded-For only when REMOTE_ADDR is inside a trusted proxy CIDR. Otherwise the headers are ignored. Header priority is constructor-controlled. X-Forwarded-For uses the first valid IP in the comma-delimited list.
  • BetterRoute\Middleware\Network\ClientIpResolverInterface — minimal resolve(mixed $request = null): ?string contract; RateLimitMiddleware accepts it next to the legacy ClientIpResolver.
  • BetterRoute\Middleware\Network\CidrMatcher — IPv4 and IPv6 aware matcher; single IPs without /prefix are also accepted.
  • BetterRoute\Middleware\Network\IpAllowlistMiddleware — rejects requests whose resolved client IP is outside the allowlist. failClosed: true (default) returns 403 client_ip_unavailable when the resolver cannot determine an IP; 403 client_ip_not_allowed when 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: 300 seconds. Default algorithm: sha256.
  • BetterRoute\Middleware\Auth\HmacSecretProviderInterfacesecretFor(string $keyId): ?string for 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 under hmac. 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\SingleUseTokenStoreInterfaceconsume(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\WpdbSingleUseTokenStorewpdb-backed store with installSchema() and TTL pruning.
  • BetterRoute\Middleware\Write\WpCacheSingleUseTokenStore — uses wp_cache_add for 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 to wp_salt('better_route_single_use_token').
  • The consumed context is attached to request attributes under singleUseToken so 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.php
  • src/Middleware/Auth/ArrayHmacSecretProvider.php
  • src/Middleware/Auth/HmacSecretProviderInterface.php
  • src/Middleware/Auth/HmacSignatureMiddleware.php
  • src/Middleware/Jwt/HttpJwksProvider.php
  • src/Middleware/Jwt/JwksKeySanitizer.php
  • src/Middleware/Jwt/JwksProviderInterface.php
  • src/Middleware/Jwt/Rs256JwksJwtVerifier.php
  • src/Middleware/Jwt/StaticJwksProvider.php
  • src/Middleware/Network/CidrMatcher.php
  • src/Middleware/Network/ClientIpResolverInterface.php
  • src/Middleware/Network/IpAllowlistMiddleware.php
  • src/Middleware/Network/TrustedProxyClientIpResolver.php
  • src/Middleware/Write/ArraySingleUseTokenStore.php
  • src/Middleware/Write/SingleUseTokenMiddleware.php
  • src/Middleware/Write/SingleUseTokenStoreInterface.php
  • src/Middleware/Write/WpCacheSingleUseTokenStore.php
  • src/Middleware/Write/WpdbSingleUseTokenStore.php
  • src/Support/Crypto.php
  • src/Support/CryptoEncoding.php
  • tests: tests/SecurityPrimitivesTest.php

Files changed

  • src/Http/ClientIpResolver.php — delegates to TrustedProxyClientIpResolver; constructor and resolve() API unchanged
  • src/Http/ResponseNormalizer.php — selects OAuthErrorNormalizer when routeMeta.error_format === 'oauth_rfc6749'
  • src/Middleware/Jwt/Hs256JwtVerifier.php — uses Crypto::equals() and Crypto::base64UrlDecode(); behavior unchanged
  • src/Middleware/RateLimit/RateLimitMiddleware.php$clientIpResolver now accepts Http\ClientIpResolver or Middleware\Network\ClientIpResolverInterface or null
  • src/Router/Router.php — dispatch adds normalized route metadata to RequestContext::$attributes['routeMeta']
  • src/Support/Version.php0.6.0-dev
  • README.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.
  • Hs256JwtVerifier keeps working — symmetric secrets are still fine for first-party JWTs. Use Rs256JwksJwtVerifier when the issuer publishes a JWKS (most OIDC providers).
  • Http\ClientIpResolver keeps working — it now delegates internally to the trusted-proxy resolver. New code should prefer TrustedProxyClientIpResolver directly.
  • BearerTokenAuthMiddleware and JwtAuthMiddleware already accepted both array scopes and OIDC-style space-delimited scope strings; 0.6.0 adds regression coverage for that behavior.

Breaking change checklist

None. 0.6.0 is purely additive.

ChangeAction
New middlewares (JWKS, HMAC, IP allowlist, single-use token)Opt-in per route or group
Http\ClientIpResolver delegates to TrustedProxyClientIpResolverNo action — constructor and resolve() API unchanged
RateLimitMiddleware accepts the new ClientIpResolverInterfaceNo action — existing constructor calls keep working
Router adds routeMeta to RequestContext::$attributesNo action — existing handlers ignore unknown attributes
OAuth error format is route-level opt-inNo action unless meta(['error_format' => 'oauth_rfc6749']) is set
Single-use token stores need a saltIf you skip the $hashSalt constructor argument, ensure WordPress is loaded so wp_salt() is available — otherwise pass an explicit salt