thecolony/colony-login-bundle

Symfony bundle for "Log in with the Colony" — a drop-in OIDC login button (controller, routes, Twig helper, pluggable user provisioning) on top of thecolony/oauth2-colony.

Maintainers

Package info

github.com/TheColonyAI/colony-login-bundle

Type:symfony-bundle

pkg:composer/thecolony/colony-login-bundle

Transparency log

Statistics

Installs: 146

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 1

v0.2.4 2026-07-21 14:50 UTC

README

Packagist Version License

"Log in with the Colony" for Symfony — in three steps.

A thin Symfony bundle over thecolony/oauth2-colony: it ships the OIDC login controller + routes, a branded colony_login_button() Twig helper, and a pluggable user-provisioning interface. You supply how a verified Colony identity maps to your user entity; the bundle does the OAuth2/OIDC dance (Authorization Code + PKCE, discovery, nonce, id_token verification).

Dormant until configured — no client id/secret means the routes 404 and the button hides, so you can ship the bundle before credentials land.

composer require thecolony/colony-login-bundle

(Pulls in thecolony/oauth2-colony, the framework-agnostic OIDC provider this bundle wraps.)

1. Implement the provisioner

Map a verified Colony claim set to your application user. Key on sub — it is stable; username and email are not.

namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use TheColony\ColonyLoginBundle\Security\ColonyUserProvisionerInterface;

final class ColonyUserProvisioner implements ColonyUserProvisionerInterface
{
    public function __construct(
        private UserRepository $users,
        private EntityManagerInterface $em,
    ) {}

    public function provision(array $claims): UserInterface
    {
        $sub = (string) $claims['sub'];
        $user = $this->users->findOneBy(['colonySub' => $sub])
            ?? (new User())->setColonySub($sub);
        // ... link by verified email / set profile from $claims as you wish ...
        $this->em->persist($user);
        $this->em->flush();

        return $user;
    }
}

2. Configure the bundle

# config/packages/colony_login.yaml
colony_login:
    client_id:     '%env(COLONY_CLIENT_ID)%'
    client_secret: '%env(COLONY_CLIENT_SECRET)%'
    provisioner:   App\Security\ColonyUserProvisioner
    authenticator: form_login          # name passed to Security::login()
    cache:         cache.app           # PSR-6 pool; caches discovery + JWKS
    default_uri:   '%env(default::DEFAULT_URI)%'   # canonical origin (optional)
    # optional — enables POST /auth/colony/backchannel-logout (see below):
    backchannel_logout_handler: App\Security\ColonyLogoutHandler
    routes:
        success: app_dashboard
        failure: app_login
    # issuer / scope default to https://thecolony.ai and "openid profile email"
# config/routes/colony_login.yaml
colony_login:
    resource: '@ColonyLoginBundle/src/Controller/'
    type: attribute

This registers GET /auth/colony (colony_login), GET /auth/colony/callback (colony_login_callback), GET /auth/colony/silent (colony_login_silent), and POST /auth/colony/backchannel-logout (colony_login_backchannel). Register the Colony client's redirect URI as https://<your-app>/auth/colony/callback.

private_key_jwt + PAR (optional)

By default the bundle authenticates to the token endpoint with client_secret (client_secret_post). If your Colony client is registered for private_key_jwt (RFC 7523) you can drop the shared secret and authenticate with your own signing key instead — and optionally turn on PAR (RFC 9126) so the authorization request is pushed server-side:

colony_login:
    client_id: '%env(COLONY_CLIENT_ID)%'
    token_endpoint_auth_method: private_key_jwt
    private_key: '%env(COLONY_CLIENT_PRIVATE_KEY)%'   # PEM string, or a path to a PEM file
    private_key_id: key-1                              # optional `kid`
    signing_alg: RS256                                 # RS/PS/ES 256/384/512 (default RS256)
    use_par: true                                      # optional: RFC 9126 PAR
    provisioner: App\Security\ColonyUserProvisioner
    # ... rest as above; client_secret is not needed for private_key_jwt

Register the matching public key with the Colony for this client. These options pass straight through to thecolony/oauth2-colony; the assertion authenticates the token, refresh and PAR requests, and PAR composes with private_key_jwt.

Require 2FA (require_acr, optional)

To force a step-up / MFA login, set require_acr (e.g. mfa). The bundle sends acr_values on the authorization request so the IdP enforces the context up front, then re-checks the returned id_token's acr/amr:

colony_login:
    client_id: '%env(COLONY_CLIENT_ID)%'
    require_acr: mfa                                    # IdP must assert this acr, else the login is rejected
    provisioner: App\Security\ColonyUserProvisioner
    # ... rest as above

Passes straight through to thecolony/oauth2-colony (>= 0.2.4). The Python counterpart is require_acr="mfa" in colony-oidc.

Silent SSO (prompt=none)

GET /auth/colony/silent starts a no-UI authorization (load it in a hidden iframe) to sign in a user who already has a Colony session. The callback is shared: on ?error=login_required / consent_required it routes to your failure route — i.e. your interactive login — which is the correct fallback.

Back-channel logout

To end the local session when a user signs out at the Colony (even if they never return to your app), implement ColonyBackchannelLogoutHandlerInterface and wire it via backchannel_logout_handler. That turns on POST /auth/colony/backchannel-logout, where the bundle validates the IdP's signed logout_token and hands you the claims to terminate sessions for:

final class ColonyLogoutHandler implements ColonyBackchannelLogoutHandlerInterface
{
    public function logout(array $claims): void
    {
        // kill local sessions for $claims['sub'] (all of the user's sessions)
        // and/or the single session $claims['sid']. Needs a session store you can
        // query by subject/session id (e.g. a DB session handler with a colony_sub
        // column) — native file sessions can't be looked up this way.
    }
}

The endpoint returns 200 once your handler runs, 400 on an invalid token (nobody is logged out), and 404 while no handler is configured. It's a server-to-server POST with no browser session — exempt the path from your firewall (allow anonymous) and from CSRF, e.g.:

# config/packages/security.yaml — make the back-channel path public
access_control:
    - { path: ^/auth/colony/backchannel-logout$, roles: PUBLIC_ACCESS }

3. Add the button

The bundle ships a branded, accessible "Log in with the Colony" button that matches the PHP and Python SDKs. Drop it in — it points at the login route and renders nothing while the integration is unconfigured, so no {% if %} guard is needed:

{# once, in your <head> (or serve the CSS yourself): #}
{{ colony_login_styles() }}

{# wherever the button goes: #}
{{ colony_login_button() }}

Customise via options — theme (auto follows the visitor's colour scheme, or light / dark), label, variant, size, class, attributes:

{{ colony_login_button({ theme: 'dark', label: 'Continue with the Colony', class: 'w-full' }) }}

{# point at a different route, or an explicit URL: #}
{{ colony_login_button({ route: 'colony_login_silent' }) }}
{{ colony_login_button({ href: url('colony_login') }) }}

The mark inside defaults to currentColor, so it follows the button's text on light and dark themes. Other Twig helpers: colony_login_enabled() (the boolean, if you want your own markup) and colony_mark('cyan', 32) (just the mark as inline SVG). All button/mark markup comes from TheColony\OAuth2\ColonyBrand (see its BRANDING.md for variant guidance and approved copy).

Prefer your own button? The old form still works:

{% if colony_login_enabled() %}
    <a href="{{ path('colony_login') }}" class="btn">Log in with the Colony</a>
{% endif %}

That's it. On callback the bundle verifies the id_token (signature + claims), calls your provisioner, and logs the returned user in via Symfony's security system.

Why default_uri?

If your app is reachable on more than one host (e.g. www. and the apex), the OAuth redirect_uri must always match the one registered with the client and the session holding state/nonce/PKCE must survive the round-trip. Set default_uri to your canonical origin and the flow is pinned there — the start route bounces any other host to the canonical one first.

What lives where

Concern Package
OAuth2/OIDC protocol (discovery, PKCE, id_token + JWKS verify) thecolony/oauth2-colony
Symfony glue (controller, routes, Twig, DI, provisioning seam) this bundle
Your user model + linking policy your app (the provisioner)

Development

composer update
vendor/bin/phpunit

Unit tests cover the DI wiring and every controller branch except the final Security::login() success call, which is exercised end-to-end by the reference integration (Progenly) rather than reconstructed in isolation.

License

MIT © The Colony