Search by

splash / oauth2

BadPixxel

Splash Oauth2 Features for API Connectors

Package info

gitlab.com/SplashTools/oauth2

Type:symfony-bundle

pkg:composer/splash/oauth2

Statistics

Installs: 8 443

Dependents: 2

Suggesters: 0

Stars: 0

3.0.x-dev 2026-09-10 10:24 UTC

This package is auto-updated.

Last update: 2026-09-10 08:24:19 UTC


README

Oauth2 features for Splash Connectors: the authorization flow, token storage, refresh and revocation, with nothing to write on the Connector side but the Provider endpoints.

The bundle is designed to run the same way from a Toolkit request, from the Cli, and from a Connector compiled as a Bridge worker (.splx): no Http session, no cookie, no reliance on the local router. The whole Oauth2 state lives in a signed state parameter and in the Connector configuration.

Installation

composer require splash/oauth2

Register the bundle, and the two bundles it relies on:

// config/bundles.php
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => array("all" => true),
Knp\Bundle\TimeBundle\KnpTimeBundle::class => array("all" => true),
Splash\Security\Oauth2\SplashOauth2Bundle::class => array("all" => true),

KnpUOAuth2ClientBundle provides the Oauth2 Clients registry. KnpTimeBundle provides the ago Twig filter the profile templates use to tell when the token expires: the Toolkit registers it by itself, any other host has to. Both are installed with this package.

Make a Connector Oauth2 aware

Four pieces, all shown by the Demo Connector under demo/:

1. The Connector implements Oauth2AwareInterface, uses Oauth2ConnectorTrait, and declares the code of its Oauth2 Client:

#[AutoconfigureTag(ConnectorInterface::TAG)]
class MyConnector extends AbstractConnector implements Oauth2AwareInterface
{
    use Oauth2ConnectorTrait;

    public function getOauth2ClientCode(): string
    {
        return MyProvider::CODE;
    }

    public function selfTest(): bool
    {
        // No Application, no Oauth2 flow: say it here rather than fail on connect
        return $this->verifyOauth2Application();
    }
}

The trait injects the Oauth2ClientManager, provides the profile templates, the master action (Provider callback) and the secured actions (connect, refresh, revoke). Api calls authenticate with getTokenOrRefresh().

2. The Provider extends ConfigurableProvider and declares the Provider endpoints. Credentials and redirect uri are injected at runtime by the bundle, never taken from the container:

class MyProvider extends ConfigurableProvider
{
    const CODE = "my_connector";

    public function getBaseAuthorizationUrl(): string { ... }
    public function getBaseAccessTokenUrl(array $params): string { ... }
    public function getResourceOwnerDetailsUrl(AccessToken $token): string { ... }
    protected function getDefaultScopes(): array { ... }
    protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface { ... }
}

ConfigurableProvider already authenticates Api requests with a Bearer header, drops the redirect_uri from refresh requests (RFC 6749 §6), and turns Authorization server errors into readable exceptions.

3. The Oauth2 Client is declared on knpu_oauth2_client, from the bundle extension, with empty credentials:

public function prepend(ContainerBuilder $container): void
{
    $container->prependExtensionConfig("knpu_oauth2_client", array(
        "clients" => array(
            MyProvider::CODE => array(
                "type" => "generic",
                "provider_class" => MyProvider::class,
                "client_id" => '',
                "client_secret" => '',
                "redirect_route" => "splash_connector_action_master",
                "redirect_params" => array("connectorName" => "myconnector"),
                // Providers requiring Pkce, without any session:
                // "client_class" => StatelessPkceClient::class,
            ),
        ),
    ));
}

4. The configuration form extends Oauth2ConfigurationForm (Connector with a shared Application) or PrivateAppConfigurationForm (customers always bring their own Application), and adds its own fields:

class EditFormType extends Oauth2ConfigurationForm
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        parent::buildForm($builder, $options);
        $builder->add("WsHost", UrlType::class, array(...));
    }
}

Only core form types are used: custom form types do not exist in a Connector container, nor on the host side of a Bridge.

Applications: shared or private

A Connector may authenticate with two Applications, the customer picks one from the configuration form (apiAppMode):

ModeCredentialsCustomer has to
shared (default)The Application published by Splash on the Provider, read from the process environmentClick connect, authorize
privateAn Application the customer created, stored on the Connector (apiKey, apiSecret)Create the Application, paste its credentials

A Connector declares its shared Application by overriding one method. Without it, the private mode is the only one and the form shows no switch:

public function getSharedOauth2Application(): ?Oauth2Application
{
    return Oauth2Application::fromEnv("MY_CONNECTOR_CLIENT_ID", "MY_CONNECTOR_CLIENT_SECRET");
}

Rules the bundle enforces:

  • Credentials are resolved at runtime, from $_SERVER, $_ENV or getenv(). Never use a %env()% container parameter for them: a compiled worker has no access to the host container, and its own container is compiled at build time.
  • Private fields only show up in private mode, on the next render. Connector forms are built from their persisted data, on a Toolkit as well as through a Bridge, so the switch has to be saved first.
  • Switching Application invalidates the Access Token. The Client ID the token was issued to is stored next to it (apiTokenClientId); a token that does not belong to the current Application is ignored, and the user has to connect again. Static tokens (Token, personal access tokens) are never invalidated.
  • The shared secret never leaves the process: Oauth2Application masks it on dump and on serialization, it is never written to the Connector configuration, the profile templates show the Client ID only, and a Bridge manifest describes form fields, not values.
  • Both Applications must register the same redirect uri on the Provider: the Connector Master Action Url, see below.

The Oauth2 flow

StepRouteWhoWhat happens
Connect/ws/{connector}/{webserviceId}/secured/connectLogged-in userResolves and stores the redirect uri, stores the Pkce verifier if any, redirects to the Provider with a signed state
Callback/ws/{connector}?code=…&state=…Provider, via the browserVerifies the state, finds the Connector back from the Webservice Id it carries, exchanges the code, stores the token
Refresh/ws/{connector}/{webserviceId}/secured/refreshLogged-in user, or any sync through getTokenOrRefresh()Renews the token from its refresh token
Revoke/ws/{connector}/{webserviceId}/secured/revokeLogged-in userForgets the token and every flow leftover

Three design points make this runtime independent:

  • The state is self carried. It holds the Webservice Id, an issue time and a nonce, signed with the kernel secret (Oauth2StateEncoder). No session is needed to route the callback, and a state expires after one hour.
  • The redirect uri comes from the request, not from the router. It is the Master Action Url as the host serves it: derived from the secured action request that started the flow (Oauth2RedirectUriResolver), stored on the Connector, and replayed as is on the token exchange. Inside a compiled worker, the local router knows neither the host route prefix nor the name the host gives the Connector: it is only the fallback, for flows started from the Cli.
  • Pkce without a session. StatelessPkceClient keeps the code verifier on the Connector configuration between the redirect and the callback, then forgets it. Knp's OAuth2PKCEClient requires a session and cannot be used.

Bridge compatibility contract

A Connector compiled as a Bridge worker runs in its own process, driven by the host over JSON-RPC. The host forwards Connector actions with a serialized Http context (method, uri, scheme, host, query, headers, body), from which the worker rebuilds a bare request: no session, no cookies, no security token. The bundle is written for that runtime, and so must be the Connector:

  • Never read the session, the cookies or the router context. Everything the flow needs travels in the request query, the signed state and the Connector configuration. setParameter() + updateConfiguration() is how state is persisted: the worker forwards it to the host as a configuration update event, synchronously.
  • Shared credentials must be real environment variables of the host process (Docker environment, Php-Fpm pool env[]). A worker inherits the process environment it is spawned with, and only that: variables loaded by Symfony Dotenv from a .env file are not exported to child processes.
  • APP_SECRET must be a real environment variable too, for the same reason: the worker signs and verifies states with its own kernel secret. A worker spawned without it falls back on the secret baked at build time, which is public.
  • All of the flow runs in the worker. The host only routes: connect, callback, refresh, revoke and token reads are executed by the worker, on the Connector configuration the host gave it. The host reaches the callback through a raw Connector, which identifies itself from the Webservice Id found in the state, as it does natively.
  • Bridged Connectors have a versioned name on the host, i.e. test@oauth2demo, so their Master Action Url differs from the native one: register both redirect uris on the Provider Application.
  • The worker registers only the bundles it is told to. Its config/bundles.php must list KnpUOAuth2ClientBundle, SplashOauth2Bundle and KnpTimeBundle, next to the Connector bundle. The packages are installed with splash/oauth2, but a bundle that is not registered does not exist: without KnpTimeBundle, the profile templates fail on the host with Unknown "ago" filter, at render time only. demo/stubs/config/bundles.php is the list to copy.

To compile a Connector, describe it for splash/bridge-builder in the project composer.json (extra.splash-bridge). The Demo Connector is the reference: demo/composer.json is what a Connector package looks like, and demo/stubs/config/ holds the bundles and configuration the worker needs.

composer require --dev splash/bridge-builder
php -d phar.readonly=0 vendor/bin/bridge-builder     # => dist/{name}-{version}.splx

Errors

Every step of the flow ends with an explicit Response: a redirect to the Provider, the closing page on success, or Connection Refused: <reason> with a 401 status. Reasons cover the missing Application, the missing or forged state, an unknown Webservice, a Provider refusal (error and error_description are reported as is), a missing code, a missing token or refresh token, and whatever the Authorization server answered.

The same reasons are pushed to the Splash logs: from a synchronization or a Bridge worker, the Response is never displayed, but the logs are. getTokenOrRefresh() returns null on failure, with the reason logged.

Connector parameters

Storage keys used on the Connector configuration, from Oauth2Parameters:

ConstantKeyContent
APP_MODEapiAppModeshared or private (Oauth2AppModes)
CLIENT_IDapiKeyPrivate Application Client ID
CLIENT_SECRETapiSecretPrivate Application Client Secret
ACCESS_TOKENAccessTokenAccess Token, as serialized by the League client
STATIC_TOKENTokenPersonal Access Token given by the user, used as is
TOKEN_CLIENT_IDapiTokenClientIdClient ID the Access Token was issued to
REDIRECT_URIapiRedirectUriRedirect uri used on Authorization, replayed on the token exchange
CODE_VERIFIERapiCodeVerifierPkce code verifier, between the redirect and the callback

Never hardcode these keys: always go through the dictionary.

Bundle configuration

# config/packages/splash_oauth2.yaml
splash_oauth2:
    # Force https on redirect uris. Providers require it, and a Toolkit behind a
    # TLS proxy only ever sees plain http. Turn it off for a plain http sandbox.
    force_https:    true

Development & tests

The repository ships a complete environment: a Toolkit hosting the Demo Connector natively and as a compiled worker, and an Oauth2 secured OpenApi Sandbox acting as the Authorization server (docker/, make up).

CommandWhat it does
make qualityLinters, code standards and PHPStan, mandatory before any commit
make bridgeCompile the Demo Connector into dist/oauth2demo-test.splx
make testBuild the worker, then run the whole suite in the Php container
make sandboxRebuild and start the Oauth2 Sandbox image
make tunnelExpose the Toolkit through ngrok, for real Providers callbacks

Test suites, in execution order:

  • Core: state encoder, Application model, redirect uri resolver. No kernel.
  • Integration: Connector wiring, stateless Pkce client, Application modes, and the compiled worker itself (manifest, registration, form description).
  • Operational: real flows against the Sandbox. The Oauth2 dance is played twice, through the Toolkit routes: on the native Demo Connector and on the compiled one. Connect, authorize on the Sandbox as a user, callback, refresh, revoke, and the error paths.

The CI runs the Operational suite on both Bridge transports, proc_open and socket. The Sandbox image is built per branch and used as a CI service; the worker is compiled in the job, from the sources under test.