mapado/oauth-server-bundle
Mapado OAuthServerBundle integrates an OAuth2 authorization server into Symfony applications, providing endpoints, storage entities, grant handlers, and security integration for issuing, refreshing, and validating access tokens.
Reality check before anything else: this is a Symfony Bundle, not a PHP library. It registers routes, DI services, Doctrine entities, and a security-firewall authenticator through FOS\OAuthServerBundle\FOSOAuthServerBundle. There is no service provider, no facade, and no adapter that loads it inside Illuminate\Foundation\Application. If your app is Laravel, stop here — use Laravel Passport (league/oauth2-server under the hood, actively maintained, first-party) for an OAuth2 authorization server, or Sanctum if all you need is first-party SPA/mobile tokens. Everything below assumes you have inherited a Symfony application that already ships FOSOAuthServerBundle semantics (the Client/AccessToken/RefreshToken/AuthCode entities and the oauth2 firewall), and this fork is what unblocks a PHP 8.x or newer-Symfony upgrade.
Step 1 — install and pin. The repo is a fork with 0 stars and 0 dependents; the only thing separating it from upstream is an undocumented patch set. Pin the exact commit, not a floating constraint:
composer require mapado/oauth-server-bundle:dev-master#<commit-sha>
Then git clone the fork next to your app and diff it against the upstream FOSOAuthServerBundle tag it claims to follow. That diff is now your code to own — write it down somewhere durable, because composer audit will not watch this package for you.
Step 2 — enable the bundle in config/bundles.php:
FOS\OAuthServerBundle\FOSOAuthServerBundle::class => ['all' => true],
Step 3 — open DependencyInjection/Configuration.php first. It is the source of truth for every config key, and it is the file that will tell you what the fork actually changed vs. the canonical docs at friendsofsymfony.github.io/FOSOAuthServerBundle. The shape is:
# config/packages/fos_oauth_server.yaml
fos_oauth_server:
db_driver: orm
client_class: App\Entity\Client
access_token_class: App\Entity\AccessToken
refresh_token_class: App\Entity\RefreshToken
auth_code_class: App\Entity\AuthCode
service:
user_provider: app.user_provider # any service implementing UserProviderInterface
options:
access_token_lifetime: 3600
refresh_token_lifetime: 1209600
Step 4 — extend the base entities. Each entity extends the bundle's Base* class and implements the matching interface; the parent supplies the storage-layer mapping, you supply the ID strategy (usually @ORM\Id @ORM\GeneratedValue on a subclass field, since BaseClient deliberately leaves the identifier to you):
// src/Entity/Client.php
use FOS\OAuthServerBundle\Entity\Client as BaseClient;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Client extends BaseClient
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
protected $id;
}
Do the same for AccessToken, RefreshToken, AuthCode, then bin/console doctrine:schema:update --dump-sql and eyeball it before migrating. These tables are now a data contract — every issued client, scope, and live refresh token lives in them.
Step 5 — routes and firewall. Import the bundle's routing (token + authorize endpoints) and wire the oauth2 firewall:
# config/routes/fos_oauth_server.yaml
fos_oauth_server_token:
resource: "@FOSOAuthServerBundle/Resources/config/routing/token.xml"
fos_oauth_server_authorize:
resource: "@FOSOAuthServerBundle/Resources/config/routing/authorize.xml"
# config/packages/security.yaml
security:
firewalls:
api:
pattern: ^/api
stateless: true
fos_oauth: true # <- the bundle's authenticator, no session, no form login
anonymous: true
First use case — get one token, then protect one route. Provision a client from a console command using the bundle's fos_oauth_server.client_manager, because the ClientManager is what handles secret hashing and redirect-URI normalisation:
$client = $this->clientManager->createClient();
$client->setName('partner-acme');
$client->setRedirectUris(['https://acme.example/cb']);
$client->setAllowedGrantTypes(['authorization_code', 'refresh_token']);
$this->clientManager->updateClient($client);
Then prove the loop end to end with client_credentials (or password if you've enabled it) before testing the browser authorize redirect — a working POST /oauth/v2/token proves the container, storage, and entity wiring all at once, and the token endpoint is where 90% of the failures surface:
curl -s -X POST https://app.test/oauth/v2/token \
-d grant_type=client_credentials \
-d client_id=<public_id> -d client_secret=<secret>
# => {"access_token":"...","expires_in":3600,"token_type":"bearer","scope":null}
Then hit your protected route with Authorization: Bearer <token> and confirm $this->getUser() is populated in the controller.
Entity extension is the primary extension point. Almost nothing in this bundle is meant to be subclassed except the four Base* entities. Storage is swappable in theory (db_driver), but in practice everyone runs ORM against the fork's mappings. If you need custom fields on Client (tenant ID, contact email, rate-limit tier), add them as columns on your subclass — that's the supported path.
Securing routes: three levels, pick one and be consistent. The fos_oauth=True firewall is the idiomatic pattern for a stateless API firewall and gets you the token→user resolution for free. The lighter-weight alternative is to leave the firewall as anonymous and check the grant manually in a controller or listener:
$server = $this->get('fos_oauth_server.server');
$token = $request->headers->get('Authorization');
if (!$server->verifyResourceRequest($request)) {
return new JsonResponse(['error' => 'invalid_token'], 401);
}
Pick the firewall for the bulk of your API and the manual check only for endpoints that need to tolerate an unauthenticated caller (public data with optional personalisation). Mixing the two across a firewall boundary is the most reliable way to get a route where auth silently doesn't run.
Custom grant types via GrantExtension. The bundle exposes fos_oauth_server.server (an OAuth2\Server built from the bshaffer storage interfaces) plus a grant_extensions tag. This is where you add product-specific behaviour — a password grant that authenticates against a legacy credential store, a custom grant for a trusted first-party mobile client:
class MobileIdGrantExtension implements GrantTypeInterface { /* getQuerystringIdentifier, validateRequest, createAccessToken */ }
Register it with tags: [{ name: fos_oauth_server.grant_extension, alias: mobile_id }] and whitelist the alias in each client's allowedGrantTypes. Everything in this area is still OAuth2 authorization-code-era API — no PKCE support, so mobile/native clients cannot be done to spec. That's a product constraint, not a config nudge.
Scopes as your authorisation model. Scopes are set on the Client (setAllowedScopes) and enforced by grant extensions/Scope classes. The pattern that holds up day to day is: scope names map 1:1 to coarse API capabilities (read:orders, write:orders, admin), a route's required scope is declared in a #[IsGranted]/@Security annotation or a route default, and a kernel listener checks the token's scope before the controller runs. Do not build fine-grained per-resource permissions on OAuth scopes — put those in your domain layer against the resolved user.
Two-token client lifecycle. Refresh is not automatic; teach your consumers to handle 401 → refresh → retry once:
curl -X POST .../oauth/v2/token -d grant_type=refresh_token \
-d refresh_token=$RT -d client_id=$ID -d client_secret=$SECRET
On the server side, refresh rotation happens inside the storage layer, so a failed refresh is almost always a client secret mismatch or an expired refresh token, not a code bug. Log the grant type and client public ID on every token-endpoint request; it turns a class of "it just broke" tickets into a one-line grep.
Machine-to-machine integrations. For server-to-server partner access, client_credentials plus a scope is the whole story: no user, no refresh token, no browser. Register one client per partner (never share secrets across partners), set a non-null user only when you actually need a delegated identity, and put the partner's rate-limit tier on the Client row so your throttling listener can read it off the resolved token.
Testing. Because the token endpoint is a real HTTP route, functional tests exercise the full stack — issue a token with client_credentials in setUp, cache it per test class, and use $client->request('GET', ..., [], [], ['HTTP_AUTHORIZATION' => 'Bearer '.$token]). Don't mock fos_oauth_server.server; you'll be testing your mock, not the security boundary.
The 0-star / 0-dependent signal is the headline gotcha. It means no one outside the originating org has exercised this code. There is no CVE feed, no issue triage, no release cadence, and no upstream to pull from. Budget for a named owner with audit responsibility, a documented patch diff against upstream, and a dated migration ticket — otherwise the correct decision is not to adopt it at all. Auth is a one-way door: a token-issuance bug is a breach.
The lineage is end-of-life. This is a fork of FOSOAuthServerBundle, which sits on bshaffer/oauth2-server-php. That upstream is superseded by league/oauth2-server. Consequences you will hit in practice: no PKCE (so native/mobile clients can't be secured to spec), no OpenID Connect (no ID tokens, no discovery, no userinfo endpoint), and gaps against OAuth 2.1 hardening guidance. If you need OIDC, this is the wrong package by a wide margin, not a configuration problem.
Do not extend it. Every custom grant or listener you add on bshaffer lineage is migration debt. Keep additions in your own controllers/listeners around the bundle, not inside it, so the eventual swap to league/oauth2-server-bundle (Symfony) or Passport (Laravel) is a wiring change rather than a rewrite.
Silent security drift is the highest-severity failure mode. A CVE lands in a transitive symfony/*, doctrine/*, or guzzle/* dependency and nothing tells you. Run composer outdated --direct and a lockfile diff review on a fixed schedule; treat the OAuth package's lockfile entry as security-critical and review any bump by hand.
Doctrine mappings become the schema you migrate from. Before you need to move off this, export the client/token tables and their semantics (which grant types are actually in use, which scopes, refresh-token TTLs in production). Migration sequencing is: inventory issued clients and live refresh tokens → dual-issue on the replacement → migrate registrations → drain refresh tokens (budget the full refresh-token TTL as the cutover tail) → decommission.
Token lookup is on the hot path. The default storage does a database lookup for every authenticated request. Cache access-token→user for the token's remaining TTL (Redis, keyed by token hash, TTL = expires_in minus a safety margin), and schedule a cleanup job for expired AccessToken/RefreshToken rows or that table will grow without bound and eventually dominate your DB.
Redirect-URI matching is exact and unforgiving. http:// vs https://, trailing slash, port, case in the host — all matter. When an authorize flow 400s, check the registered URI against the request URI character by character before you touch anything else. In production, never register wildcard or localhost redirect URIs; that's the classic open-redirect-to-token-theft vector.
Debugging order of operations for a failing token request: (1) grep the Symfony log for the client's public ID and the grant_type; (2) confirm the client's allowedGrantTypes includes that grant — a grant disabled on the client returns the same invalid_client/unsupported_grant_type shape as a server misconfiguration; (3) confirm the secret you're sending is the decoded one (client managers often display it once at creation); (4) only then go spelunking in fos_oauth_server.server's compiled service definition. Most "it worked yesterday" reports are clock skew on the token's expires_in or a secret rotated without reissuing to the consumer.
Config quirks worth pinning in a comment: user_provider must be a real UserProviderInterface service even if you only ever use client_credentials (the server is constructed eagerly); access_token_lifetime is per-token-lifetime, not a sliding window; and fos_oauth=True firewalls must be stateless: true — leaving session handling on a token firewall produces phantom logouts that look like token expiry.
Where to look when you're stuck: DependencyInjection/Configuration.php for valid keys, the bundle's Resources/config/*.xml for service IDs and tag names, and the upstream FOSOAuthServerBundle docs (at friendsofsymfony.github.io/FOSOAuthServerBundle) for conceptual guidance — but verify every code sample against this fork's actual source, because you cannot assume they match.
How can I help you explore Laravel packages today?