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:
| Variable | Required | Purpose |
|---|---|---|
APP_KEY |
yes | seeds HKDF peppers + Encrypter key |
AUTH_JWT_PRIVATE_KEY |
yes | PEM private key (sign access tokens) |
AUTH_JWT_PUBLIC_KEY |
yes | PEM public key (verify / JWKS) |
AUTH_JWT_KID |
rec. | key id for rotation (defaults to a hash of pubkey) |
AUTH_JWT_PREVIOUS_PUBLIC_KEY |
no | retiring public key, kept in the JWKS during a rotation (see key-rotation.md) |
AUTH_JWT_PREVIOUS_KID |
no | kid of the retiring key (defaults to a hash of it) |
AUTH_ISSUER |
rec. | overrides auth.issuer |
AUTH_AUDIENCE |
rec. | overrides auth.audience |
AUTH_ACCESS_TOKEN_DENYLIST |
no | 1 enables instant access-token revocation (cache watermark per request) |
AUTH_PASSWORD_BREACH_CHECK |
no | 1 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);
}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
BearerTokenExtractorthat readsAuthorization: Bearer <jwt>(the framework'sHeaderTokenExtractorreturns the scheme too, which the parser can't read); and aNullCredentialsExtractorthat 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 => falseon the middleware options: Polaris runs behind the host's TLS termination, where the PHP-visible scheme ishttpand 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
onErrorresponder that renders any auth failure as a401JSON envelope ({"error":"unauthorized", …}) plus aWWW-Authenticate: Bearerchallenge, matching what the protected domains emit.
AuthorizationMiddleware (permission / step-up) arrives with the RBAC phase.
Rate limiting requires a shared cache in production.
AuthRateLimitMiddlewarereuses the frameworkRateLimitMiddleware(one fixed-window limiter per endpoint group: login, register, password/forgot, token/refresh; budgets inRateLimitConfig, defaults from security.md §5). It keys on the client IP viaIpKeyResolver, which readsREMOTE_ADDR; a host behind a proxy that needs the real client IP must wireIpAddressMiddlewarewith a trusted-proxy allow-list before it (never trust a rawX-Forwarded-For). The module binds an in-processInMemoryCacheas theCacheInterfaceonly 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):
| Package | Why | Notes |
|---|---|---|
spomky-labs/otphp |
RFC 6238/4226 TOTP/HOTP generate + verify | pure PHP |
endroid/qr-code |
Render otpauth:// URI as QR (SVG default, PNG) |
wraps bacon/bacon-qr-code |
symfony/uid |
UUID v7 primary keys | symfony already partly vendored |
Already available (no new dep):
lcobucci/jwt+lcobucci/clock: JWT signing/verification (viauniveros/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/OtpMailerInterfaceports; concrete Twilio/SES/SMTP adapters live in the optionaluniveros/polaris-adapterspackage (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(),
];# 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 doctorThat 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
SchemaProviderInterface→ModuleAwareSchemaProviderbinding 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.