Search by

univeros / polaris

tonydspaniard

Polaris for PHP as a Univeros module: authentication, MFA/OTP, organizations and RBAC on polaris/core.

Package info

github.com/univeros/polaris

Homepage

pkg:composer/univeros/polaris

Statistics

Installs: 242

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v2.4.0 2026-10-14 17:45 UTC

README

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.

Install

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.

The environment

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

Variable Purpose
APP_KEY At least 32 bytes; the key every pepper and the MFA-secret encrypter derive from.
AUTH_JWT_PRIVATE_KEY, AUTH_JWT_PUBLIC_KEY The RS256 key pair (PEM) that signs and verifies access tokens.
AUTH_JWT_KID, AUTH_JWT_PREVIOUS_PUBLIC_KEY, AUTH_JWT_PREVIOUS_KID Optional: the key id and the retiring key during a rotation.
AUTH_ISSUER, AUTH_AUDIENCE The iss and aud claims (polaris and none by default).
AUTH_ACCESS_TOKEN_DENYLIST, AUTH_PASSWORD_BREACH_CHECK 1, true or on enables instant revocation and the breached-password check.
DB_CONNECTION, DB_DATABASE, DB_HOST, DB_PORT, DB_USER, DB_PASSWORD The database, as CycleOrmConfiguration reads it (postgres, mysql, sqlite). Polaris opens its own connection on the same settings.
POLARIS_DSN, POLARIS_DB_USER, POLARIS_DB_PASSWORD The database of an application without an ORM.
POLARIS_PATH_PREFIX Mounts the Polaris routes under a prefix (/ by default).
POLARIS_PLUGINS Comma-separated plugin classes, built through the container, when no Univeros\Polaris\Plugins is bound (see Plugins).
POLARIS_LISTENERS module (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.

Plugins

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 ...) Construct Routes, commands, notes
polaris/audit new AuditPlugin(retention: [...], hashChain: true); drains need a PSR-18 client /audit/*; polaris:audit:prune, polaris:audit:verify (schedule the prune)
polaris/admin new AdminPlugin() /admin/* operator API; polaris:admin:grant, polaris:admin:key create the first principals
polaris/messaging new MessagingPlugin(channels: [...]) becomes core's mailer and SMS sender when you bind none; polaris:messaging:send
polaris/passkey new 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/username new UsernamePlugin() POST /username/sign-in, PATCH /username
polaris/passwordless new PasswordlessPlugin(baseUrl: ..., redirectUris: [...]), after MessagingPlugin (or bind a mailer and SMS sender) /magic-link/*, /email-otp/*, /phone/*, /one-time-token/*
polaris/social new SocialPlugin(baseUrl: ..., providers: ['google' => ['client_id' => ..., 'client_secret' => ...]], httpClient: ..., requestFactory: ..., streamFactory: ...) /social/*; register <baseUrl>/social/{provider}/callback with each provider
polaris/anonymous new AnonymousPlugin(onConvert: fn(string $guestId, string $userId) => ...) /anonymous/*; polaris:anonymous:prune (schedule it)
polaris/multi-session new MultiSessionPlugin() /multi-session/*; the X-Polaris-Device header and the polaris_ms_device cookie
polaris/sentinel new SentinelPlugin(mode: 'observe'); bind a cache that outlives a request judges sign-up, sign-in, reset and code sends; /admin/sentinel/*; polaris:sentinel:lists
polaris/sso new SsoPlugin(baseUrl: ..., httpClient: ..., requestFactory: ..., streamFactory: ...) /sso/*, /orgs/{id}/sso, /admin/sso; SAML and OIDC URLs derive from baseUrl
polaris/scim new ScimPlugin(baseUrl: ...), after SsoPlugin /scim/v2/{connectionId}, /orgs/{id}/scim, /admin/scim
polaris/api-keys new ApiKeysPlugin() /api-keys/*; a key (Bearer pk_... or x-api-key) authenticates every Polaris route as its owner
polaris/oauth-provider new 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/mcp new 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/agents new 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).

The routes

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).

Your own routes behind Polaris tokens

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.

API keys and OAuth tokens on your routes

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.

Testing your application

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 database and bin/altair

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());
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.

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.

The demo

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.

Development

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.

License

Proprietary. See composer.json.

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