micro-module / ist-auth-bundle
Pure HttpKernel JWT/IST authentication bundle for Symfony
Package info
github.com/temafey/micro_modules_ist_auth_bundle
Type:symfony-bundle
pkg:composer/micro-module/ist-auth-bundle
Requires
- php: ^8.4
- firebase/php-jwt: ^7.0
- monolog/monolog: ^3.0
- open-telemetry/api: ^1.0
- predis/predis: ^2.2 || ^3.0
- psr/log: ^3.0
- symfony/config: ^8.0
- symfony/dependency-injection: ^8.0
- symfony/http-foundation: ^8.0
- symfony/http-kernel: ^8.0
Requires (Dev)
- infection/infection: ^0.29
- nelmio/api-doc-bundle: ^4.0 || ^5.0
- nyholm/symfony-bundle-test: ^2.0 || ^3.0
- phpstan/phpstan: ^1.12 || ^2.0
- phpunit/phpunit: ^11.5
- qossmic/deptrac: ^2.0
- symplify/easy-coding-standard: ^12.0
Suggests
- micro-module/observability-bundle: Provides the OTel SDK bootstrap (MeterProvider, TracerProvider) needed to activate IstMetrics.
- nelmio/api-doc-bundle: Enables automatic OpenAPI bearerAuth scheme declaration and operation security annotation for IST-protected routes.
- open-telemetry/sdk: Required to actually export metrics and traces; without it OTel API calls are no-ops.
README
Pure HttpKernel JWT/IST authentication bundle for Symfony 8.
Validates Inter-Service Tokens (IST — JWT HS256) via pure symfony/http-kernel
event listeners. No symfony/security-bundle, no UserInterface, no Voter,
no firewall YAML.
Designed for inter-microservice authentication where a monolith (or any upstream service) mints short-lived HS256 JWTs and downstream microservices validate them per request without pulling in the full Symfony security stack.
Why this bundle exists
symfony/security-bundle is designed for session-based user authentication.
For stateless service-to-service JWTs it brings significant complexity
(firewalls, authenticators, passports, voters, user providers) and couples
every consumer to a heavy authentication lifecycle.
This bundle takes the opposite approach:
- 5 HttpKernel event listeners with locked priorities
- Single
PrincipalStaterequest attribute for principal propagation - Config-driven route policies (19+ entries in a typical consumer)
- Optional
#[IstAuthLevel]attribute for per-controller overrides - Published
Contracts\*namespace — consumers import only stable types
Total production footprint: ~40 classes, ~3,500 LOC.
Features
| Area | Capability |
|---|---|
| JWT validation | HS256-pinned, typ=IST enforced, 8KB payload cap, JSON depth ≤8, hash_equals via firebase/php-jwt |
| Algorithm confusion | Pre-decode alg === 'HS256' check, segment base64url regex, RSA-pubkey-as-HMAC defence (TG-01) |
| Policy engine | PCRE route-name matching, fail-safe REQUIRED default, 4 auth levels (None, Required, ExhibitorOwner, Admin) |
| Admin resolution | 3 strategies (allowlist, claim, audience-based) selectable via config |
| Resource ownership | Consumer-implemented ResourceOwnershipCheckerInterface port |
| Sub-request isolation | RequestStack depth ≤3, strips Authorization header, resets state |
| Bypass routes | Byte-anchored regex on _route name only (no path traversal) |
| Error envelopes | ER-1 (401) / ER-2 (403) from enum reasons — never $exception->getMessage() |
| Observability | Correlation-ID propagator, Monolog JWT redactor, Prometheus metrics, OTel span attrs |
| Anti-replay | Optional JTI guard with Redis + in-memory stores (opt-in via anti_replay.enabled) |
| OpenAPI | Auto-declares bearerAuth scheme via Nelmio post-processor |
| Audit | Dedicated security Monolog channel for SIEM |
Requirements
- PHP 8.4
- Symfony 8.0 (http-kernel, http-foundation, config, dependency-injection)
firebase/php-jwt^7.0monolog/monolog^3.0predis/predis^2.2 (only if usingRedisReplayStore)open-telemetry/api^1.0 (no-op without the SDK; see suggested packages)
Installation
composer require micro-module/ist-auth-bundle
Register the bundle in config/bundles.php:
return [ // ... MicroModule\IstAuth\IstAuthBundle::class => ['all' => true], ];
Configuration
Create config/packages/ist_auth.yaml:
micro_ist_auth: issuer: 'your-token-issuer' audience: 'your-service-name' algorithm: HS256 required: '%env(IST_AUTH_REQUIRED)%' # disabled | optional | required clock_skew_seconds: 5 # hard-capped at 60 keys_env_prefix: 'IST_AUTH_KEY_' # IST_AUTH_KEY_<kid>=<secret> admin: source: allowlist # allowlist | claim | audience allowlist: '%env(json:IST_ADMIN_ACCOUNT_IDS)%' anti_replay: enabled: false store: redis # redis | in_memory ttl_seconds: 60 bypass_routes: - '/\A_wdt.*\z/' - '/\Ahealth_.*\z/' - '/\Aapi_doc(_json)?\z/' - '/\Ametrics_.*\z/' route_policies: '/\Aapi_v1_news_list\z/': level: NONE owner_check: false '/\Aapi_v1_news_create\z/': level: EXHIBITOR_OWNER owner_check: true '/\Aapi_v1_news_publish\z/': level: ADMIN owner_check: false
Environment variables:
# Mode IST_AUTH_REQUIRED=required # disabled | optional | required # Signing keys (one per kid — at least 43 base64url chars each) IST_AUTH_KEY_k1=<base64url-secret> IST_AUTH_KEY_k2=<base64url-secret> # Admin allowlist (JSON array of stringified account IDs) IST_ADMIN_ACCOUNT_IDS='["42","100","999"]'
Fail-safe default: routes not matched by any route_policies entry are
treated as REQUIRED. Unknown paths never default to public.
Usage
Reading the principal in a controller
Type-hint the principal argument — the bundle's argument resolver injects the correct instance:
use MicroModule\IstAuth\Contracts\Principal\AuthenticatedPrincipal; use MicroModule\IstAuth\Contracts\Principal\IstPrincipal; #[Route('/api/v1/news', methods: ['POST'])] public function create(AuthenticatedPrincipal $principal, Request $request): Response { // Guaranteed authenticated — 401 automatically returned if anonymous $accountId = $principal->getClaims()->accountId; // ... } #[Route('/api/v1/news/{uuid}', methods: ['GET'])] public function getOne(IstPrincipal $principal, string $uuid): Response { // Accepts both Anonymous and Authenticated if ($principal instanceof AuthenticatedPrincipal) { // enrich response } // ... }
Per-controller policy override (optional)
use MicroModule\IstAuth\Contracts\Attribute\IstAuthLevel; use MicroModule\IstAuth\Contracts\Authorization\AuthLevel; #[Route('/api/v1/admin/reindex', methods: ['POST'])] #[IstAuthLevel(AuthLevel::Admin)] public function reindex(): Response { /* ... */ }
Attribute values merge with config policies; config wins on conflict.
Implementing resource ownership
Consumers provide a thin adapter in their own Infrastructure layer:
use MicroModule\IstAuth\Contracts\Authorization\ResourceOwnershipCheckerInterface; use MicroModule\IstAuth\Contracts\Exception\OwnershipFailedException; use MicroModule\IstAuth\Contracts\Principal\AuthenticatedPrincipal; final class NewsOwnershipCheckerAdapter implements ResourceOwnershipCheckerInterface { public function __construct(private NewsRepositoryInterface $query) {} public function assertOwns( AuthenticatedPrincipal $principal, string $resourceType, string $resourceId, ): void { if ($resourceType !== 'news') { throw new OwnershipFailedException(); } $news = $this->query->fetchOne(Uuid::fromNative($resourceId)); if ($news === null || $news->getOwnerId() !== $principal->getClaims()->accountId) { throw new OwnershipFailedException(); // never leak existence } } }
Register the alias (or tag autowire: true):
MicroModule\IstAuth\Contracts\Authorization\ResourceOwnershipCheckerInterface: alias: App\Infrastructure\Security\NewsOwnershipCheckerAdapter
Architecture
Listener topology (priorities are locked)
| Priority | Event | Listener | Purpose |
|---|---|---|---|
| 128 | kernel.request |
BypassRouteListener |
Match _route name against bypass regex; skip auth |
| 96 | kernel.request |
SubRequestIsolationListener |
Reset state on sub-requests, cap depth at 3, strip Authorization |
| 64 | kernel.request |
IstAuthenticationListener |
Extract Bearer, validate JWT, write PrincipalState |
| 32 | kernel.request |
IstAuthorizationListener |
Match route policy, enforce level + ownership |
PHP_INT_MAX/2 |
kernel.exception |
IstExceptionListener |
Map validation/authz exceptions → ER-1 / ER-2 |
| -10 | kernel.response |
CorrelationIdResponseListener |
Echo X-Correlation-Id header |
Canary tests (ListenerPriorityCanaryTest) use EventDispatcher::getListenerPriority()
to lock these priorities — any drift is a merge blocker.
Layer structure
src/
├── Contracts/ # Published language — consumers import ONLY from here
│ ├── Attribute/ # #[IstAuthLevel]
│ ├── Authorization/ # AuthLevel enum, ResourceOwnershipCheckerInterface
│ ├── Claim/ # IstClaims (readonly)
│ ├── Exception/ # Validation/Authorization exception hierarchy
│ ├── Http/ # CorrelationIdPropagatorInterface
│ ├── Metrics/ # MetricsRecorderInterface
│ └── Principal/ # IstPrincipal, Authenticated/Anonymous, PrincipalState
├── Domain/ # @internal — validators, ports, policy matcher
├── Infrastructure/ # @internal — listeners, key stores, processors
└── Presentation/ # @internal — argument resolvers
Enforced by depfile.yaml (Deptrac):
Contractsdepends on NOTHING in this bundleInfrastructuremay not depend onPresentation- Consumer code must only import
Contracts\*
Security invariants
The following are enforced by tests and CI scripts — any violation is a merge blocker:
- Algorithm pinning —
alg === 'HS256'strict equality before signature verification (src/Domain/Validator/) - Segment base64url validation before
json_decode(defeats byte injection,scripts/ci/no-raw-jwt.sh) hash_equalsvia firebase/php-jwt — never rolled locally- Payload ≤ 8KB, JSON depth ≤ 8 — DoS defence
- Secrets ≥ 43 base64url chars — boot-time fail-to-start
typ === 'IST'strict, case-sensitive- No
$e->getMessage()in error envelopes (SR-7,scripts/ci/forbidden-getmessage.sh) - Raw JWT never logged —
AuthorizationHeaderRedactorMonolog processor - Correlation-ID sanitized with
\A[A-Za-z0-9_-]{1,128}\z(byte-anchored, not^…$) - Bypass matches
_routename only — never raw paths
See tests/Unit/Domain/Validator/ for the full algorithm-confusion matrix
(lowercase alg, none, RS256, RS384, RS512, ES256, PS256 — all rejected).
Development
All commands run from the bundle repo root:
composer install # Unit + integration tests ./vendor/bin/phpunit --no-coverage # Static analysis (PHPStan level 8) ./vendor/bin/phpstan analyse # Code style (PSR-12 + opinionated additions) ./vendor/bin/ecs check ./vendor/bin/ecs check --fix # Layer dependency rules ./vendor/bin/deptrac analyse --config-file=depfile.yaml # Mutation testing (Domain layer) ./vendor/bin/infection --threads=4 --min-msi=85 --only-covered # Security gates bash scripts/ci/forbidden-getmessage.sh bash scripts/ci/no-raw-jwt.sh
Local development with a consumer service
When developing the bundle alongside a consuming project, use Composer's path repository feature:
// consumer's composer.json { "repositories": [ { "type": "path", "url": "/absolute/path/to/ist-auth-bundle", "options": { "symlink": true } } ], "require": { "micro-module/ist-auth-bundle": "@dev" }, "config": { "preferred-install": { "micro-module/ist-auth-bundle": "source" } } }
Note the per-package preferred-install override — without it, a global
"dist" setting silently converts the symlink into a copy.
Versioning
Development tags:
v0.1.0-dev— Bundle foundation (AbstractBundle + config schema)v0.2.0-dev— Domain + HS256 validator + exception hierarchyv0.3.0-dev— HttpKernel listeners, observability, error envelopes, anti-replay, News-MVP integration surface
v1.0.0 ships after full GATE-9G verification (24 acceptance criteria
- K6 p95 ≤ 1.8 ms + security review sign-off).
License
MIT. See LICENSE.
Contributing
Issues and pull requests welcome at https://github.com/temafey/micro_modules_ist_auth_bundle.
Security reports: follow the coordinated disclosure protocol documented in
SECURITY.md (7-day triage SLA).