micro-module/ist-auth-bundle

Pure HttpKernel JWT/IST authentication bundle for Symfony

Maintainers

Package info

github.com/temafey/micro_modules_ist_auth_bundle

Type:symfony-bundle

pkg:composer/micro-module/ist-auth-bundle

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-04-19 21:51 UTC

This package is auto-updated.

Last update: 2026-04-19 22:52:23 UTC


README

Pure HttpKernel JWT/IST authentication bundle for Symfony 8.

Latest Version PHP Version License

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 PrincipalState request 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.0
  • monolog/monolog ^3.0
  • predis/predis ^2.2 (only if using RedisReplayStore)
  • 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):

  • Contracts depends on NOTHING in this bundle
  • Infrastructure may not depend on Presentation
  • Consumer code must only import Contracts\*

Security invariants

The following are enforced by tests and CI scripts — any violation is a merge blocker:

  1. Algorithm pinningalg === 'HS256' strict equality before signature verification (src/Domain/Validator/)
  2. Segment base64url validation before json_decode (defeats byte injection, scripts/ci/no-raw-jwt.sh)
  3. hash_equals via firebase/php-jwt — never rolled locally
  4. Payload ≤ 8KB, JSON depth ≤ 8 — DoS defence
  5. Secrets ≥ 43 base64url chars — boot-time fail-to-start
  6. typ === 'IST' strict, case-sensitive
  7. No $e->getMessage() in error envelopes (SR-7, scripts/ci/forbidden-getmessage.sh)
  8. Raw JWT never loggedAuthorizationHeaderRedactor Monolog processor
  9. Correlation-ID sanitized with \A[A-Za-z0-9_-]{1,128}\z (byte-anchored, not ^…$)
  10. Bypass matches _route name 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 hierarchy
  • v0.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).