Skip to content

Configuration, Bindings & Dependencies

How a host configures Polaris, what the module binds in the container, and the libraries the module pulls in.


Polaris reads a single auth config namespace (via univeros/configuration). Every value has a safe default; a host overrides only what it needs. Shown with defaults:

return [
'auth' => [
'tenancy' => 'multi', // fixed for this build
'identifier' => 'email', // primary login identifier
'issuer' => 'https://auth.example.com',
'audience' => 'https://api.example.com',
'access_token' => [
'ttl' => 900, // seconds (15 min)
'signer' => 'RS256', // RS256 | EdDSA
'embed_scope' => false, // embed flat permissions vs roles only
'denylist' => false, // optional jti denylist for instant revoke
],
'refresh_token' => [
'ttl' => 2592000, // 30 days
'rotation' => true, // rotate on every use (recommended)
'reuse_detection' => true, // revoke family on replay
'sliding' => false, // extend expiry on use
'max_lifetime' => 7776000, // 90d cap when sliding
],
'password' => [
'algo' => 'argon2id',
'min_length' => 12,
'breach_check'=> false, // enable HIBP-style check (needs adapter)
],
'lockout' => [
'max_attempts' => 5,
'window' => 900, // 15 min
'lock_duration' => 900,
'trust_known_devices'=> true,
],
'otp' => [
'length' => 6,
'ttl' => 300, // 5 min (sms/email)
'max_attempts' => 5,
'resend_cooldown' => 30,
'totp' => [
'digits' => 6,
'period' => 30,
'algorithm' => 'SHA1', // authenticator-app standard
'window' => 1, // ±1 step skew tolerance
'issuer' => 'Univeros',// label shown in the authenticator app
],
],
'mfa' => [
'enforce' => false, // require MFA for all users
'grace_enroll' => true, // allow login-then-enroll when enforced
],
'step_up' => [ 'max_age' => 300 ],
'flows' => [
'require_verified_email' => true,
'token_delivery' => 'body', // body | cookie
'email_verification' => [ 'style' => 'link', 'ttl' => 86400 ],
'password_reset' => [ 'style' => 'link', 'ttl' => 3600 ],
'invitation' => [ 'ttl' => 604800 ], // 7 days
],
'rate_limits' => [ /* per-group overrides; see security.md §5 */ ],
],
];

A typed AuthConfig value object wraps this (validated at boot); domain services depend on AuthConfig, never on raw arrays; fail-fast on invalid/missing required values (issuer, keys).


Secrets come from env / a secret manager, never config files:

VariableRequiredPurpose
APP_KEYyesseeds HKDF peppers + Encrypter key
AUTH_JWT_PRIVATE_KEYyesPEM private key (sign access tokens)
AUTH_JWT_PUBLIC_KEYyesPEM public key (verify / JWKS)
AUTH_JWT_KIDrec.key id for rotation (defaults to a hash of pubkey)
AUTH_JWT_PREVIOUS_PUBLIC_KEYnoretiring public key, kept in the JWKS during a rotation (see key-rotation.md)
AUTH_JWT_PREVIOUS_KIDnokid of the retiring key (defaults to a hash of it)
AUTH_ISSUERrec.overrides auth.issuer
AUTH_AUDIENCErec.overrides auth.audience
AUTH_ACCESS_TOKEN_DENYLISTno1 enables instant access-token revocation (cache watermark per request)
AUTH_PASSWORD_BREACH_CHECKno1 enables breached-password screening (bind an adapter, e.g. the HIBP one)

Module::apply() asserts the required env is present and well-formed and throws a clear startup error otherwise (no silent insecure fallback).


Polaris binds its ports to concrete implementations and provides the framework auth contracts. Hosts override any binding after registering the module.

public function apply(Container $container): void
{
// ---- config ----
$container->singleton(AuthConfig::class, /* factory from 'auth' config + env */);
// ---- framework Http auth contract implementations ----
$container->singleton(IdentityProviderInterface::class, CycleIdentityProvider::class);
$container->singleton(IdentityValidatorInterface::class, fn($c) =>
new RepositoryIdentityValidator(
$c->get(IdentityProviderInterface::class),
['username' => 'email', 'hash' => 'password_hash'],
));
$container->singleton(TokenConfigurationInterface::class, /* from AuthConfig + keys */);
$container->singleton(TokenGeneratorInterface::class, LcobucciTokenGenerator::class);
$container->singleton(TokenParserInterface::class, /* Polaris parser over LcobucciTokenParser */);
$container->singleton(TokenFactoryInterface::class, PolarisTokenFactory::class);
$container->singleton(TokenValidatorInterface::class, PolarisTokenValidator::class);
$container->singleton(TokenExtractorInterface::class, HeaderTokenExtractor::class);
$container->singleton(CredentialsExtractorInterface::class, BodyCredentialsExtractor::class);
// ---- Polaris ports (host rebinds for production) ----
$container->singleton(PasswordHasherInterface::class, Argon2idPasswordHasher::class);
$container->singleton(SmsSenderInterface::class, LogSmsSender::class); // dev default
$container->singleton(OtpMailerInterface::class, LogOtpMailer::class); // dev default
$container->singleton(BreachedPasswordCheckInterface::class, NullBreachCheck::class);
$container->singleton(TotpProviderInterface::class, OtphpTotpProvider::class);
$container->singleton(QrCodeRendererInterface::class, EndroidQrRenderer::class);
$container->singleton(ClockInterface::class, SystemClock::class); // lcobucci/clock
// ---- domain services (autowired; bound as singletons for sharing) ----
$container->singleton(RegistrationService::class);
$container->singleton(LoginService::class);
$container->singleton(TokenService::class); // mint/rotate/refresh
$container->singleton(MfaService::class);
$container->singleton(OtpService::class);
$container->singleton(OrganizationService::class);
$container->singleton(MembershipService::class);
$container->singleton(RoleService::class);
$container->singleton(PermissionResolver::class);
$container->singleton(Gate::class);
}

Middleware contribution (MiddlewareProviderInterface)

Section titled “Middleware contribution (MiddlewareProviderInterface)”
public function middleware(): array
{
return [
// authenticate bearer/credentials → attaches TokenInterface to request
['middleware' => TokenAuthenticationMiddleware::class,
'priority' => MiddlewarePriority::DISPATCHER + 5],
// authorize: permission + step-up checks declared on the Action
['middleware' => AuthorizationMiddleware::class,
'priority' => MiddlewarePriority::DISPATCHER + 10],
// rate limiting for auth endpoints (own priority, outer)
['middleware' => AuthRateLimitMiddleware::class,
'priority' => MiddlewarePriority::EXCEPTION_HANDLER + 50],
];
}

TokenAuthenticationMiddleware is configured with rules (HttpAuthRule) so it only challenges protected paths and skips the public ones (/auth/login, /auth/register, /auth/email/verify[/resend], /auth/token/refresh, /auth/password/forgot, /auth/password/reset, JWKS). Everything else under /auth (logout, logout-all, sessions, sessions/{id}, password/change, me) requires a valid bearer token.

As shipped (issue #15), the wiring uses:

  • a BearerTokenExtractor that reads Authorization: Bearer <jwt> (the framework’s HeaderTokenExtractor returns the scheme too, which the parser can’t read); and a NullCredentialsExtractor that disables the credential-minting path: only a pre-issued bearer token authenticates a protected route, so login stays the sole credential entry point with its lockout / verified-email / timing / MFA gates.
  • ssl => false on the middleware options: Polaris runs behind the host’s TLS termination, where the PHP-visible scheme is http and the framework’s TLS guard would reject every proxied request. The host MUST enforce TLS at the edge (HSTS / redirect); transport security is not Polaris’s layer.
  • an onError responder that renders any auth failure as a 401 JSON envelope ({"error":"unauthorized", …}) plus a WWW-Authenticate: Bearer challenge, matching what the protected domains emit.

AuthorizationMiddleware (permission / step-up) arrives with the RBAC phase.

Rate limiting requires a shared cache in production. AuthRateLimitMiddleware reuses the framework RateLimitMiddleware (one fixed-window limiter per endpoint group: login, register, password/forgot, token/refresh; budgets in RateLimitConfig, defaults from security.md §5). It keys on the client IP via IpKeyResolver, which reads REMOTE_ADDR; a host behind a proxy that needs the real client IP must wire IpAddressMiddleware with a trusted-proxy allow-list before it (never trust a raw X-Forwarded-For). The module binds an in-process InMemoryCache as the CacheInterface only when the host has not bound one; that default does not persist across PHP worker processes, so a production host MUST bind a shared cache (Redis / APCu / Memcached) for the limits to hold. The in-memory default keeps the module bootable in dev/test, where one instance lives across a request.


Added to composer.json require (all actively maintained, permissive licenses; versions to be pinned at implementation time):

PackageWhyNotes
spomky-labs/otphpRFC 6238/4226 TOTP/HOTP generate + verifypure PHP
endroid/qr-codeRender otpauth:// URI as QR (SVG default, PNG)wraps bacon/bacon-qr-code
symfony/uidUUID v7 primary keyssymfony already partly vendored

Already available (no new dep):

  • lcobucci/jwt + lcobucci/clock: JWT signing/verification (via univeros/http).
  • Altair\Security\Encrypter, HkdfKey, Salt: encryption + key derivation.
  • Cycle ORM + migrations (via univeros/persistence).
  • password_hash/password_verify (PHP core, Argon2id with sodium).

No SMS/email SDK is added to core. Delivery is via the SmsSenderInterface / OtpMailerInterface ports; concrete Twilio/SES/SMTP adapters live in the optional univeros/polaris-adapters package (see implementation-plan.md).

The skeleton composer.json still has the placeholder name/namespace and must be corrected to match the source (Univeros\Polaris):

"name": "vendor/module",
"name": "univeros/polaris",
"description": "A Univeros module.",
"description": "Authentication, MFA/OTP, and user/organization management for Univeros apps.",
"autoload": {
"psr-4": { "VendorModule\\": "src/" }
"psr-4": { "Univeros\\Polaris\\": "src/" }
},
"autoload-dev": {
"psr-4": { "VendorModule\\Tests\\": "tests/" }
"psr-4": { "Univeros\\Polaris\\Tests\\": "tests/" }
},
"require": {
"php": ">=8.3",
"univeros/module": "^2.0",
"univeros/http": "^2.0",
"univeros/persistence": "^2.0",
"univeros/security": "^2.0",
"spomky-labs/otphp": "^11.3",
"endroid/qr-code": "^6.0",
"symfony/uid": "^7.0"
}

config/modules.php
return [
new Univeros\Polaris\Module(),
];
Terminal window
# 1. install
composer require univeros/polaris
# 2. provide secrets (env / secret manager)
export APP_KEY=# 32-byte base64
export AUTH_JWT_PRIVATE_KEY="$(cat private.pem)"
export AUTH_JWT_PUBLIC_KEY="$(cat public.pem)"
# 3. apply migrations (Polaris's tables join the host's tracking table)
bin/altair db:migrate
# 4. (optional) bind production providers in the host container
# SmsSenderInterface → TwilioSmsSender, OtpMailerInterface → SymfonyMailerAdapter
# 5. verify wiring
bin/altair routes:list --format=json | grep auth
bin/altair doctor

That single module registration contributes all /auth, /users, /orgs routes, the entities, the migrations, the middleware, and the bindings: no per-module host wiring, per the Univeros module contract.

The only host-level prerequisite (shared by every module, not specific to Polaris) is the one-time SchemaProviderInterfaceModuleAwareSchemaProvider binding that lets module entities join the host’s ORM schema. Once a host has that, registering Polaris needs nothing further beyond the secrets in §2.