splash / oauth2
Splash Oauth2 Features for API Connectors
Requires
- php: ^8.3
- ext-json: *
- knplabs/knp-time-bundle: ^2.0
- knpuniversity/oauth2-client-bundle: ~2.0
- splash/console: ^3.0
- splash/php-bundle: ^3.0
- symfony/form: ^7.4
- symfony/translation: ^7.4
- symfony/validator: ^7.4
Requires (Dev)
- badpixxel/php-sdk: ^3.0
- splash/bridge-builder: ^3.0
- splash/toolkit: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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
- Make a Connector Oauth2 aware
- Applications: shared or private
- The Oauth2 flow
- Bridge compatibility contract
- Errors
- Connector parameters
- Bundle configuration
- Development & tests
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):
| Mode | Credentials | Customer has to |
|---|---|---|
shared (default) | The Application published by Splash on the Provider, read from the process environment | Click connect, authorize |
private | An 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,$_ENVorgetenv(). 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:
Oauth2Applicationmasks 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
| Step | Route | Who | What happens |
|---|---|---|---|
| Connect | /ws/{connector}/{webserviceId}/secured/connect | Logged-in user | Resolves 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 browser | Verifies the state, finds the Connector back from the Webservice Id it carries, exchanges the code, stores the token |
| Refresh | /ws/{connector}/{webserviceId}/secured/refresh | Logged-in user, or any sync through getTokenOrRefresh() | Renews the token from its refresh token |
| Revoke | /ws/{connector}/{webserviceId}/secured/revoke | Logged-in user | Forgets 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.
StatelessPkceClientkeeps the code verifier on the Connector configuration between the redirect and the callback, then forgets it. Knp'sOAuth2PKCEClientrequires 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 poolenv[]). A worker inherits the process environment it is spawned with, and only that: variables loaded by Symfony Dotenv from a.envfile are not exported to child processes. APP_SECRETmust 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.phpmust listKnpUOAuth2ClientBundle,SplashOauth2BundleandKnpTimeBundle, next to the Connector bundle. The packages are installed withsplash/oauth2, but a bundle that is not registered does not exist: withoutKnpTimeBundle, the profile templates fail on the host withUnknown "ago" filter, at render time only.demo/stubs/config/bundles.phpis 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:
| Constant | Key | Content |
|---|---|---|
APP_MODE | apiAppMode | shared or private (Oauth2AppModes) |
CLIENT_ID | apiKey | Private Application Client ID |
CLIENT_SECRET | apiSecret | Private Application Client Secret |
ACCESS_TOKEN | AccessToken | Access Token, as serialized by the League client |
STATIC_TOKEN | Token | Personal Access Token given by the user, used as is |
TOKEN_CLIENT_ID | apiTokenClientId | Client ID the Access Token was issued to |
REDIRECT_URI | apiRedirectUri | Redirect uri used on Authorization, replayed on the token exchange |
CODE_VERIFIER | apiCodeVerifier | Pkce 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).
| Command | What it does |
|---|---|
make quality | Linters, code standards and PHPStan, mandatory before any commit |
make bridge | Compile the Demo Connector into dist/oauth2demo-test.splx |
make test | Build the worker, then run the whole suite in the Php container |
make sandbox | Rebuild and start the Oauth2 Sandbox image |
make tunnel | Expose 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.