Search by

uxf / security

uxf

Package info

gitlab.com/uxf/security

Issues

pkg:composer/uxf/security

Statistics

Installs: 14 090

Dependents: 1

Suggesters: 0

Stars: 0

3.84.2 2026-09-21 12:40 UTC

This package is auto-updated.

Last update: 2026-09-21 10:45:22 UTC


README

Install

$ composer req uxf/security
// config/packages/uxf.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->extension('uxf_security', [
        'user_class' => User::class,                // required
        'base_url' => 'https://uxf.cz',             // required; no trailing slash, it is also the OAuth issuer
        'public_key' => '%env(AUTH_PUBLIC_KEY)%',   // required
        'private_key' => '%env(AUTH_PRIVATE_KEY)%', // required
        // optional
        'access_token_lifetime' => 'P10Y',          // default 1 day
        'refresh_token_lifetime' => 'P20Y',         // default 1 month
        'refresh_token_cookie_path' => '/',         // default null (suggestion: /api/auth/refresh-token)
        'cookie_name' => 'Cookie-Name',             // default Authorization - used for header + cookie
        'cookie_secured' => false,                  // default true
        'cookie_http_only' => false,                // default true
        // OpenID Connect - optional
        'oidc' => [
            'apple' => [
                'client_id' => 'xxx',
            ],
            'facebook' => [
                'client_id' => 'xxx',
                'client_secret' => 'xxx',
            ],
            'gitlab' => [
                'client_id' => 'xxx',
                'client_secret' => 'xxx',
            ],
            'google' => [
                'client_id' => 'xxx',
            ],
            'microsoft' => [
                'client_id' => 'xxx',
            ],
            'mojeid' => [
                'client_id' => 'xxx',
            ],
            'seznam' => [
                'client_id' => 'xxx',
                'client_secret' => 'xxx',
            ],
            'uxf' => [
                'client_id' => 'xxx',
                'client_secret' => 'xxx',
                'app_name' => 'xxx',
            ],
        ],
    ]);
};

Symfony security config - recommended

// config/packages/security.php
return static function (ContainerConfigurator $config): void {
    $config->extension('security', [
        'providers' => [
            'provider' => [
                'entity' => [
                    'class' => \UXF\CMS\Entity\User::class,
                    'property' => 'email', // BC break in 3.68 (id -> email)
                ],
            ],
        ],
        ...
   ]);
}

Events

LogoutUserEvent

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use UXF\Security\Event\LogoutUserEvent;

#[AsEventListener(LogoutUserEvent::class)]
final readonly class LogoutUserListener
{
    public function __invoke(LogoutUserEvent $event): void
    {
        // edit response cookies/headers
        ...
    }
}

OAuth 2.1

The bundle can act as an OAuth 2.1 authorization server for the resource servers of the application. It is a generic server; MCP servers (Claude Code, claude.ai connectors, ...) are its first consumer and the source of the requirements.

Endpoints (all under the bundle's /api/auth prefix except the RFC-mandated well-known documents):

GET  /.well-known/oauth-authorization-server               # RFC 8414
GET  /.well-known/oauth-protected-resource/<path>          # RFC 9728, e.g. /.well-known/oauth-protected-resource/mcp/cms
GET  /api/auth/oauth/authorize                             # PKCE S256 + state + resource required; anonymous user -> login_url?redirect=...
POST /api/auth/oauth/authorize                             # consent form submit
POST /api/auth/oauth/token                                 # authorization_code, refresh_token (rotation)

Clients register either through a Client ID Metadata Document (an HTTPS client_id URL, used by Claude Code and claude.ai connectors) or up front in oauth.clients. Dynamic Client Registration (RFC 7591) is not implemented: MCP deprecated it in favour of CIMD.

Fetching a metadata document means following a URL a stranger supplied, so it is guarded: HTTPS only, standard port, no redirect is followed without checking the new target again, the connection is pinned to the address that was checked (DNS rebinding), private, reserved and carrier grade NAT addresses are refused for both IPv4 and IPv6, and the body is capped at 64 kB while being read. cimd.allowed_hosts narrows which hostnames may be fetched at all; it does not exempt them from the address check. There is no rate limit on this outgoing request yet, so it is still the cheapest way to make the server do work.

The OAuth issuer is always base_url, taken verbatim (RFC 8414 serves its metadata from the issuer root), so it must not end with a slash: a trailing one would put the metadata at https://uxf.cz//.well-known/…. The oauth node has its own three lifetimes, on purpose: the root ones belong to the cookie/API login, where a long value is a reasonable choice, and an OAuth access token cannot be revoked once it is signed.

There is a single scope, api, and it is not configurable: it carries no authorization of its own, what a user may do is decided by their roles, exactly as in the REST API. The authorization server metadata also advertises offline_access, which is how clients ask for a refresh token; one is issued either way.

oauth.resources is the allowlist of resource servers this application serves. It decides which resource a client may ask a token for (so the server never mints a token for a foreign audience), which paths the resource server firewall accepts, and what the protected resource metadata describes. Each entry must be an absolute http(s) URL without a fragment, query string or userinfo, and the list may not be empty once OAuth is on.

// config/packages/uxf.php
$containerConfigurator->extension('uxf_security', [
    // ...
    'oauth' => [
        'enabled' => true,
        'access_token_lifetime' => 'PT1H',                  // default 1 hour
        'refresh_token_lifetime' => 'P30D',                 // default 30 days
        'authorization_code_lifetime' => 'PT60S',           // default 60 seconds
        'login_url' => '/login',                            // your frontend login; must redirect back to ?redirect=<url> after login
        'resources' => ['https://uxf.cz/mcp/cms'],          // required; canonical URLs of your resource servers (RFC 8707)
        'cimd' => [                                         // Client ID Metadata Documents (Claude Code, claude.ai)
            'enabled' => true,
            'allowed_hosts' => [],                          // e.g. ['claude.ai'] for internal deployments
        ],
        'clients' => [                                      // pre-registered clients
            'my-frontend' => [
                'name' => 'My frontend',
                'redirect_uris' => ['https://uxf.cz/oauth/callback'],
                'secret' => '%env(OAUTH_MY_FRONTEND_SECRET)%', // null = public client
                'consent_required' => false,
            ],
        ],
    ],
]);

Resource server firewall of the application (here an MCP server under /mcp/, but the pattern is the same for any protected resource):

// config/packages/security.php
'firewalls' => [
    'mcp' => [
        'pattern' => '^/mcp/',
        'stateless' => true,
        'access_token' => [
            'token_handler' => \UXF\Security\Service\OAuth\ResourceServer\AccessTokenHandler::class,
            'token_extractors' => 'header',
            'failure_handler' => \UXF\Security\Service\OAuth\ResourceServer\AuthenticationFailureHandler::class,
        ],
        'entry_point' => \UXF\Security\Service\OAuth\ResourceServer\AuthenticationEntryPoint::class,
    ],
    'main' => [ /* your existing firewall with UXF\Security\Service\Authenticator */ ],
],
'access_control' => [
    ['path' => '^/mcp/', 'roles' => 'ROLE_USER'],
],

Some clients cannot do OAuth at all and only send a fixed header (Continue, the OpenAI agent APIs, mcp-remote --header). A signed-in user issues such a token for themselves, so build a button for it into your UI and let them paste the result into the client:

POST /api/auth/personal-token   # behind your cookie firewall, uses the caller's identity and roles
{"resource": "https://uxf.cz/mcp/cms", "lifetime": "P30D"}
→ {"accessToken": "...", "resource": "https://uxf.cz/mcp/cms", "expiresAt": "..."}

lifetime is optional and capped by oauth.refresh_token_lifetime, which is also the default. The token carries client_id: static so it is recognisable in logs, and it has no refresh token, so it ends only by expiring or by a key rotation. uxf:security:access-token is unrelated and keeps issuing API tokens only.

OAuth refresh tokens live in their own table, separate from the ones the cookie login issues, because their lifetimes differ. They rotate: using one deletes it and issues a replacement, so the same token never works twice. The replacement inherits the expiry of the token it replaced, which means a grant lasts exactly refresh_token_lifetime from when it was made however often it is refreshed; after that the client has to authorize again. Rotation alone is what RFC 9700 §2.2.2 asks for, so a token presented twice is simply unknown here and is not treated as evidence of theft.

An OAuth access token lasts oauth.access_token_lifetime, an hour by default. Nothing can revoke a signed access token, not even deleting the refresh token it came with, so raising that value means a stolen token stays usable for as long as it says.

Authorization codes and consent tokens are single use, enforced by a primary key rather than by a cache, so it holds across instances.

Nothing is deleted automatically, so schedule the cleanup command. It removes only rows whose lifetime has passed, which no lookup can reach any more: expired RefreshToken, OAuthRefreshToken and OAuthConsumedToken rows.

bin/console uxf:security:token-cleanup [--dry-run]

Tokens: API/cookie tokens have aud: "user", OAuth access tokens have aud: <resource URL> and header typ: at+jwt; each firewall accepts only its own kind. Security::getToken()->getAttribute('client_id') gives the OAuth client inside the resource server. /.well-known/* and /api/auth/oauth/token must be reachable anonymously; /api/auth/oauth/authorize must be behind the cookie firewall with PUBLIC_ACCESS so that the anonymous user can be redirected to login_url.

Example: connecting Claude Code to an MCP server of your application

End-to-end example for an application on https://app.example.com exposing its MCP tools under /mcp/cms (served by symfony/mcp-bundle or anything else behind the resource server firewall above).

1. Enable the authorization server and declare what it protects. The resource is the canonical URL of the endpoint, and base_url doubles as the OAuth issuer:

// config/packages/uxf.php
$containerConfigurator->extension('uxf_security', [
    'user_class' => User::class,
    'base_url' => 'https://app.example.com',            // no trailing slash
    'public_key' => '%env(AUTH_PUBLIC_KEY)%',
    'private_key' => '%env(AUTH_PRIVATE_KEY)%',
    'oauth' => [
        'enabled' => true,
        'login_url' => '/login',                        // must redirect back to ?redirect=<url> after login
        'resources' => ['https://app.example.com/mcp/cms'],
    ],
]);

Add the resource server firewall for ^/mcp/ as shown above, and keep /.well-known/* and /api/auth/oauth/token anonymous.

2. Add the server to Claude Code. No client registration is needed: the metadata advertises client_id_metadata_document_supported, so Claude Code identifies itself with its own Client ID Metadata Document.

claude mcp add --transport http cms https://app.example.com/mcp/cms

The first call gets a 401 with WWW-Authenticate pointing at /.well-known/oauth-protected-resource/mcp/cms. From there Claude Code finds the authorization server, opens a browser for login and consent, and receives a token whose aud is that one resource, so it is useless anywhere else. A refresh token comes along because offline_access is advertised.

3. Optional: pre-register the client instead. Use this when you do not want the server fetching a remote metadata document, or when you want the client to be auditable. Loopback redirect URIs are matched without the port (RFC 8252 §7.3), so register the portless form and let Claude Code pick a random one:

'clients' => [
    'claude-code' => [
        'name' => 'Claude Code',
        'redirect_uris' => ['http://localhost/callback'],
        'consent_required' => false,
    ],
],
claude mcp add --transport http --client-id claude-code cms https://app.example.com/mcp/cms

For a confidential client add --client-secret, which prompts for it, or pass it in MCP_CLIENT_SECRET.

4. Clients that cannot do OAuth. Issue a personal token as the signed-in user and hand it over as a fixed header:

curl -X POST https://app.example.com/api/auth/personal-token \
  -b "Authorization=<your login cookie>" \
  -H 'Content-Type: application/json' \
  -d '{"resource": "https://app.example.com/mcp/cms", "lifetime": "P30D"}'

claude mcp add --transport http cms https://app.example.com/mcp/cms \
  --header "Authorization: Bearer <accessToken from the response>"

OpenID Connect

Create new user

use Nette\Utils\Random;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use UXF\CMS\Entity\User;
use UXF\Security\Service\OIDC\NewUserEvent;

class NewUserEventSubscriber implements EventSubscriberInterface
{
    public function process(NewUserEvent $event): void
    {
        $event->user = new User($event->oidcInfo->email, '', Random::generate());
    }

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents(): array
    {
        return [
            NewUserEvent::class => 'process',
        ];
    }
}

Providers & URLs

# login
https://domain.com/api/auth/oidc/<provider>/login
# login with redirect (default is /)
https://domain.com/api/auth/oidc/<provider>/login?redirect=/some-path
# login callback
https://domain.com/api/auth/oidc/<provider>/callback

Custom provider

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use UXF\Security\Service\OIDC\Configuration;
use UXF\Security\Service\OIDC\OIDCConnector;
use UXF\Security\Service\OIDC\Provider\OIDCProvider;
use UXF\Security\Service\OIDC\Provider\OIDCProviderTrait;

#[AsTaggedItem(index: self::NAME)]
final readonly class FakeProvider implements OIDCProvider
{
    use OIDCProviderTrait;

    public const string NAME = 'fake';

    public function __construct(
        private OIDCConnector $connector,
        private string $clientId,
    ) {
    }

    public function getConfiguration(): Configuration
    {
        return new Configuration(
            providerName: self::NAME,
            clientId: $this->clientId,
            clientSecret: null,
            responseType: 'id_token',
            responseMode: 'form_post',
            scope: 'openid email',
            authorizationUrl: 'https://uxf.cz',
            jwksUrl: 'https://uxf.cz',
            tokenUrl: 'https://uxf.cz',
            userInfoUrl: null,
            oidcInfoCreator: fn (array $userInfo) => new OIDCInfo(
                providerName: self::NAME,
                sub: $userInfo['sub'],
                email: Email::of($userInfo['custom_email']),
                emailVerified: $userInfo['custom_verified'],
                claims: $userInfo,
            ),
        );
    }
}