Skip to content

The Univeros module

Polaris for PHP as a Univeros module. One line in config/modules.php and a Univeros application gets authentication, MFA/OTP, sessions with rotating refresh tokens, multi-tenant organizations and RBAC: the 52 endpoints of polaris/core, served by the framework’s own pipeline.

PHP Univeros module Polaris for PHP MFA

univeros/polaris 2.x is the Univeros module built on Polaris for PHP: polaris/core carries the identity, MFA/OTP, session, organization and RBAC services, the endpoints and the schema, framework-free and contract-frozen; polaris/psr15, polaris/pdo and polaris/cli give it a PSR-15 pipeline, a PDO adapter and a console. This package is the glue between them and a Univeros host: a module that builds Polaris from the application’s environment and container, a middleware that serves the Polaris routes, a bridge that lets the framework’s TokenAuthenticationMiddleware accept Polaris access tokens, one Cycle migration and the polaris:* commands. Since 2.1 it also runs the polaris/* plugins (passkeys, social sign-in, API keys, an OAuth/OIDC provider, MCP, agents, audit and more) by configuration. The 1.x HTTP contract is unchanged: the 184 request/response sequences recorded against 1.0 replay through this module’s Relay pipeline in CI.

Upgrading from 1.x? Read UPGRADE.md first: 2.0 is a new major, and TOTP factors are re-enrolled.

Terminal window
composer require univeros/polaris:^2.4
config/modules.php
return [
new Univeros\Polaris\Module(),
];

That is the whole registration. The module contributes its middleware and its migration through the framework’s MiddlewareProviderInterface and MigrationDirectoriesProviderInterface, so public/index.php of the skeleton stays as composer create-project univeros/univeros wrote it.

Polaris reads the same variables 1.x read, through the framework’s Env ($_ENV, $_SERVER, then getenv(), so a .env loaded by EnvironmentConfiguration counts):

VariablePurpose
APP_KEYAt least 32 bytes; the key every pepper and the MFA-secret encrypter derive from.
AUTH_JWT_PRIVATE_KEY, AUTH_JWT_PUBLIC_KEYThe RS256 key pair (PEM) that signs and verifies access tokens.
AUTH_JWT_KID, AUTH_JWT_PREVIOUS_PUBLIC_KEY, AUTH_JWT_PREVIOUS_KIDOptional: the key id and the retiring key during a rotation.
AUTH_ISSUER, AUTH_AUDIENCEThe iss and aud claims (polaris and none by default).
AUTH_ACCESS_TOKEN_DENYLIST, AUTH_PASSWORD_BREACH_CHECK1, true or on enables instant revocation and the breached-password check.
DB_CONNECTION, DB_DATABASE, DB_HOST, DB_PORT, DB_USER, DB_PASSWORDThe database, as CycleOrmConfiguration reads it (postgres, mysql, sqlite). Polaris opens its own connection on the same settings.
POLARIS_DSN, POLARIS_DB_USER, POLARIS_DB_PASSWORDThe database of an application without an ORM.
POLARIS_PATH_PREFIXMounts the Polaris routes under a prefix (/ by default).
POLARIS_PLUGINSComma-separated plugin classes, built through the container, when no Univeros\Polaris\Plugins is bound (see Plugins).
POLARIS_LISTENERSmodule (default): the module runs Polaris::listeners(), then your bound dispatcher. host: your bound dispatcher gets the events as is and subscribes the listeners itself (2.0’s behaviour).

A Polaris\Config\Secrets or Polaris\Config\AuthConfig instance bound in the container before the module resolves wins over the environment, and so does every other port: a Polaris\Contract\DatabaseAdapter or a PDO, an OtpMailerInterface and an SmsSenderInterface (the defaults log and send nothing), a PSR-16 CacheInterface (rate limits, the denylist and the OTP send caps live there, so a production host binds one that outlives a request), a PSR-3 logger, a PSR-14 dispatcher, a clock, the rate limits, the encrypter, the metrics sink (without one, Univeros Observability’s Meter when bound), the TOTP provider and the QR renderer. Polaris events go to Polaris::listeners() (the audit log, notifications, metrics, and every plugin’s) and then to your PSR-14 dispatcher when you bind one, so your listeners see UserRegistered and the rest.

Nothing is built at boot: the one Polaris (and its connection, manifest and JWT configuration) exists from the first request or command that resolves it, so bin/altair db:migrate runs before the keys exist and bin/altair polaris:doctor is the boot check.

Every polaris/* plugin of Polaris for PHP runs through the module. Require the package, then bind the plugins in config/container.php (the way for a plugin taking arguments, such as URLs or PSR-17/18 factories only your application has):

use Polaris\Passkey\PasskeyPlugin;
use Univeros\Polaris\Plugins;
$container->instance(Plugins::class, new Plugins(
new PasskeyPlugin(origins: ['https://app.example.com'], rpName: 'Example'),
));

or name plugins without arguments in the environment: POLARIS_PLUGINS="Polaris\Audit\AuditPlugin". A bound Plugins wins over the variable; an entry that is not a plugin class fails boot naming it. Their routes are served under POLARIS_PATH_PREFIX, their middleware and listeners run, their permissions join the catalog. Install their tables with bin/altair polaris:schema:install-plugins (see below); their console commands appear as polaris:*. A plugin taking a baseUrl (oauth-provider, mcp, agents) wants the URL Polaris is mounted at, prefix included.

Every plugin’s own contract fixtures replay through the module in CI (composer test:plugins).

Order matters: list AuditPlugin, AdminPlugin and MessagingPlugin first, SsoPlugin before ScimPlugin, and OAuthPlugin before McpPlugin and AgentsPlugin, as each plugin’s class documentation asks. baseUrl is always where Polaris is mounted, prefix included (https://app.example.com/auth with POLARIS_PATH_PREFIX=/auth). PSR-17 factories: laminas/laminas-diactoros comes with the module. A PSR-18 client is yours to choose.

Plugin (composer require ...)ConstructRoutes, commands, notes
polaris/auditnew AuditPlugin(retention: [...], hashChain: true); drains need a PSR-18 client/audit/*; polaris:audit:prune, polaris:audit:verify (schedule the prune)
polaris/adminnew AdminPlugin()/admin/* operator API; polaris:admin:grant, polaris:admin:key create the first principals
polaris/messagingnew MessagingPlugin(channels: [...])becomes core’s mailer and SMS sender when you bind none; polaris:messaging:send
polaris/passkeynew PasskeyPlugin(origins: [...], rpName: '...'): exact origins (https, or http://localhost in development)/passkey/*; every passkey is an MFA factor passkey at /auth/mfa/verify and /auth/mfa/step-up; registerPasskey(), signInWithPasskey() in @polaris-auth/client
polaris/usernamenew UsernamePlugin()POST /username/sign-in, PATCH /username
polaris/passwordlessnew PasswordlessPlugin(baseUrl: ..., redirectUris: [...]), after MessagingPlugin (or bind a mailer and SMS sender)/magic-link/*, /email-otp/*, /phone/*, /one-time-token/*
polaris/socialnew SocialPlugin(baseUrl: ..., providers: ['google' => ['client_id' => ..., 'client_secret' => ...]], httpClient: ..., requestFactory: ..., streamFactory: ...)/social/*; register <baseUrl>/social/{provider}/callback with each provider
polaris/anonymousnew AnonymousPlugin(onConvert: fn(string $guestId, string $userId) => ...)/anonymous/*; polaris:anonymous:prune (schedule it)
polaris/multi-sessionnew MultiSessionPlugin()/multi-session/*; the X-Polaris-Device header and the polaris_ms_device cookie
polaris/sentinelnew SentinelPlugin(mode: 'observe'); bind a cache that outlives a requestjudges sign-up, sign-in, reset and code sends; /admin/sentinel/*; polaris:sentinel:lists
polaris/ssonew SsoPlugin(baseUrl: ..., httpClient: ..., requestFactory: ..., streamFactory: ...)/sso/*, /orgs/{id}/sso, /admin/sso; SAML and OIDC URLs derive from baseUrl
polaris/scimnew ScimPlugin(baseUrl: ...), after SsoPlugin/scim/v2/{connectionId}, /orgs/{id}/scim, /admin/scim
polaris/api-keysnew ApiKeysPlugin()/api-keys/*; a key (Bearer pk_... or x-api-key) authenticates every Polaris route as its owner
polaris/oauth-providernew OAuthPlugin(baseUrl: ..., consentUrl: ..., deviceUrl: ...); audiences: ['<baseUrl>/mcp'] is required with mcp (its bearer resolver refuses tokens for any other audience)/oauth2/*, /.well-known/openid-configuration, /.well-known/oauth-authorization-server (under a prefix also at the root, as RFC 8414 asks: /.well-known/oauth-authorization-server/auth), /orgs/{id}/oauth/clients, /admin/oauth/clients; its tokens authenticate every Polaris route within their scopes
polaris/mcpnew McpPlugin(baseUrl: ..., requests: new ServerRequestFactory(), responses: new ResponseFactory(), streams: new StreamFactory()) (the Laminas\Diactoros factories)POST /mcp, /.well-known/oauth-protected-resource/mcp (and the RFC 9728 root form /.well-known/oauth-protected-resource/auth/mcp under a prefix); polaris:mcp (stdio)
polaris/agentsnew AgentsPlugin(baseUrl: ..., capabilities: [...], approvalUrl: ...), after OAuthPlugin/agents/*, /admin/agents/*, /.well-known/agent-configuration; every capability is an MCP tool with mcp

Sending messages off the request. With univeros/messaging configured (it also needs symfony/event-dispatcher, which it does not pull in yet), give MessagingPlugin the module’s bus outbox:

use Altair\Messaging\Contracts\MessageBusInterface;
use Univeros\Polaris\Messaging\BusOutbox;
new MessagingPlugin(
channels: [...],
outbox: new BusOutbox(static fn(): MessageBusInterface => $container->get(MessageBusInterface::class)),
),

and route its sends to a transport: MESSENGER_TRANSPORT_ASYNC=redis://localhost:6379/messages and MESSENGER_ROUTING="Univeros\Polaris\Messaging\SendMessage:async". A send then answers Receipt::queued() on the request (the policy has counted it) and bin/altair worker --transports=async delivers it through the channels and the fallback, without counting it again; the module registers that handler. A send no channel delivered fails in the worker, so the transport retries it and then parks it on the failure transport. Unrouted, the bus delivers it on the spot.

Receiving audit drains in a Univeros application. polaris/audit’s WebhookSink signs each batch GitHub-style, X-Polaris-Signature: sha256=<hex> (HMAC-SHA256 over the body), names it with X-Polaris-Delivery and sends no timestamp header. Since univeros/framework 2.6 the Webhooks signers accept that prefix, so the receiving application verifies with WebhookVerifyMiddleware as is:

new \Altair\Webhooks\Middleware\WebhookVerifyMiddleware(
signer: new \Altair\Webhooks\Signing\HmacSha256Signer(),
secrets: $secrets, // resolves 'polaris_audit' to the drain's secret
deduplicator: $deduplicator, // dedupes on the delivery id
responseFactory: $responseFactory,
streamFactory: $streamFactory,
secretName: 'polaris_audit',
requireTimestamp: false,
signatureHeader: 'X-Polaris-Signature',
eventIdHeader: 'X-Polaris-Delivery',
);

Agents on your routes. With polaris/agents the module binds Polaris\Agents\Http\AgentRequestMiddleware: add it to your module’s middleware() and a request to a capability’s location must come from an agent holding a live grant (budget, receipt and your handler in one transaction; the handler reads the grant from the attribute polaris.agents.grant); any other URL passes through. For one route bound to one capability, build AgentsPlugin::of($graph)->requestMiddleware($graph, 'invite'). approvalUrl is your page where a person approves a grant: it lists GET /agents/approvals and posts POST /agents/approvals/{id}/decide (client.agents in @polaris-auth/client; polaris-core’s Slim demo has approve.html). Agents themselves use the SDKs, polaris/agent (PHP) or @polaris-auth/agent.

API keys and OAuth tokens authenticate the Polaris routes; your own routes behind the framework’s TokenAuthenticationMiddleware accept Polaris sessions only, unless you mount PluginTokenAuthenticationMiddleware (below).

Univeros\Polaris\Http\PolarisMiddleware runs first in the pipeline, ahead of the framework’s ExceptionHandlerMiddleware (which would rewrite every 4xx/5xx Polaris answers into problem+json), and serves every manifest path through Polaris\Psr15\Pipeline: the Polaris routes are not in the FastRoute table, bin/altair polaris:manifest lists them (--format=openapi renders the OpenAPI document), and with univeros/introspection installed bin/altair routes:list shows them beside yours. Any other path continues to the framework’s dispatcher. A JSON body is decoded from the request bytes, since ServerRequestFactory::fromGlobals() hands $_POST over as the parsed body.

For coding agents. The module contributes a section to the framework’s AI-readable manifests: bin/altair manifest:generate, run from your bin/altair, writes .agent/modules/univeros/polaris.md (every route core and your configured plugins serve, with its auth, step-up, required permissions and error codes, plus the permission catalog) and lists it in .agent/MANIFEST.md. The output is byte-stable for a given configuration, so commit .agent/ and gate it with bin/altair manifest:generate --check.

The full catalog with request and response shapes is docs/auth/api-reference.md (the design lives in docs/auth/; the implementation is polaris/core).

The module binds the framework’s token contracts over Polaris: TokenFactoryInterface to a bridge over Polaris’s token factory (invalid and expired tokens become the framework’s InvalidTokenException, so the middleware answers 401), TokenExtractorInterface to Authorization: Bearer, and TokenAuthenticationMiddleware with ['ssl' => false, 'onError' => new UnauthorizedResponder()] (a 401 JSON envelope on every failure, plain http accepted behind TLS termination). Scope it to your protected paths with the container’s own parameter mechanism and put it after the dispatcher:

// the application's module
public function apply(Container $container): void
{
$container->singleton(AppAuthentication::class, static fn(Container $c): AppAuthentication => new AppAuthentication(
$c->make(TokenAuthenticationMiddleware::class, ['rules' => [new RequestPathRule(['path' => ['/app']])]]),
));
}
public function middleware(): array
{
return [['middleware' => AppAuthentication::class, 'priority' => MiddlewarePriority::DISPATCHER + 5]];
}

The action then reads the token from the request attribute TokenInterface::TOKEN_KEY (through the framework’s InputParser, it is in the domain’s InputCollection): a Univeros\Polaris\Token\DualToken that implements both the framework’s and Polaris’s token contract, with the claims (sub, org, roles, mfa, …) under getMetadata(). examples/univeros does exactly this for GET /app/me.

TokenAuthenticationMiddleware accepts Polaris sessions only. To accept the plugins’ credentials too (a polaris/api-keys key as Bearer pk_... or x-api-key, a polaris/oauth-provider access token), mount Univeros\Polaris\Http\PluginTokenAuthenticationMiddleware instead, scoped the same way:

$c->make(PluginTokenAuthenticationMiddleware::class, ['rules' => [new RequestPathRule(['path' => ['/app']])]])

It asks the plugins’ resolvers with the request first, then falls back to the framework’s behaviour; a refused key is the same 401 envelope. Such a token is delegated: it may use only the subset of its owner’s permissions listed under getMetadata(Gate::DELEGATED), and only Polaris\Authorization\Gate applies that restriction. Authorize these routes through the Gate the module binds ($gate->allows($token, 'invoices.read')), never from the user id alone, or a key gets its owner’s full authority.

With polaris/testing as a dev dependency, Univeros\Polaris\Testing\PolarisTesting boots Polaris for your tests without a database or a .env, and mints tokens to call your protected routes:

$container = new Container();
PolarisTesting::bind($container); // in-memory database, fresh RS256 keys, APP_KEY, issuer
(new ModuleConfiguration([new Module()]))->apply($container);
$token = PolarisTesting::token($container, 'user-7', organizationId: 'org-1', roles: ['admin']);
$request = $request->withHeader('Authorization', 'Bearer ' . $token);

bind() keeps what the container already binds. token(..., mfa: true) marks the session as MFA’d, and delegated: ['invoices.read'] limits the token to that subset of the user’s permissions, as an API key or an OAuth token is.

The module ships one Cycle migration (migrations/20260910.000000_0_install_polaris.php) that installs the schema for the connection’s dialect and seeds the permission catalog and the system roles through Polaris\Pdo\SchemaInstaller, on the migrator’s own connection inside its transaction. The framework’s vendor/bin/altair boots a bare container, so the application ships its own entry point that builds config/container.php first and adds the Polaris commands:

#!/usr/bin/env php
<?php
use Altair\Cli\Application;
use Altair\Cli\Configuration\CliConfiguration;
use Univeros\Polaris\Console\Commands;
$root = dirname(__DIR__);
require $root . '/vendor/autoload.php';
chdir($root);
$container = require $root . '/config/container.php'; // configurations, then the modules
(new CliConfiguration(glob($root . '/vendor/univeros/framework/src/Altair/*/Cli') ?: []))->apply($container);
$application = $container->make(Application::class);
foreach (Commands::all($container) as $command) {
$application->add($command);
}
exit($application->run());
Terminal window
bin/altair db:migrate # installs Polaris on a fresh database
bin/altair polaris:schema:diff # proves the database matches the schema
bin/altair polaris:doctor # secrets, keys, auth settings, database, manifest
bin/altair polaris:manifest # the 52 routes (--format=openapi for the document)
bin/altair polaris:schema:export --target=sql:postgres
bin/altair polaris:schema:create / polaris:schema:drop

With univeros/doctor installed, bin/altair doctor runs three Polaris checks as well: polaris (what polaris:doctor checks), polaris_plugins (the configured plugins build, and every plugin’s baseUrl is mounted at POLARIS_PATH_PREFIX) and polaris_schema (the database matches the schema, the plugins’ tables included), each with the command that fixes it.

The migration installs core. The tables of the plugins you configure (and their permissions) come from polaris:schema:install-plugins, which creates only the tables the database lacks and seeds the permission catalog, so it is safe to re-run after enabling another plugin. With --sql it prints the statements instead, to paste into a migration of your own. polaris:schema:diff checks the plugins’ tables too.

Terminal window
bin/altair polaris:schema:install-plugins # after enabling a plugin
bin/altair polaris:schema:install-plugins --sql # the statements, for your own migration

The commands of the plugins you configure join as polaris:* (polaris:audit:prune, polaris:mcp, polaris:admin:grant, …), and polaris:manifest, polaris:schema:export, polaris:schema:create and polaris:doctor include the plugins’ routes, tables and permissions. Either builds Polaris, so once you configure plugins bin/altair needs the secrets (bin/setup writes them before db:migrate). Without plugins nothing is built at boot.

examples/univeros is the univeros/univeros skeleton with the module registered: bin/setup writes .env and an RS256 key pair, runs db:migrate, polaris:schema:diff and polaris:doctor; bin/walkthrough.sh registers a user, verifies the email, logs in, enrols TOTP, logs in through MFA, creates an organization and switches to it, then calls the application’s own /app/me. CI runs it on every push.

Terminal window
composer install
composer qa # phpcs, phpstan, phpunit (module and functional suites), the contract replay
composer test:contract # the 184 recorded 1.0 fixtures through this module's Relay pipeline

composer test runs the module suite and polaris/core’s functional suite through the bare PSR-15 pipeline; test:contract runs the same suite and the contract fixtures through tests/Harness.php, an Altair\Container\Container with the module applied and the skeleton’s Relay pipeline. Any difference is a bug in the module, never in the fixtures. Decisions taken while building 2.0 are logged in docs/decisions.md; the orientation for coding agents is AGENT.md.

Proprietary. See composer.json.

Polaris for PHP is created and maintained by 2am.tech.