oro/oauth2-server
OAuth2 server integration for OroPlatform applications, providing authorization and token endpoints, client and access token management, and secure API access for third-party integrations.
Read this first: it isn't a Laravel package. Despite the description, oro/oauth2-server is Oro Inc.'s fork of bshaffer/oauth2-server-php. There is no OroServiceProvider, no config/oauth2.php, no facade, no php artisan command. You are adopting a protocol library and writing the framework glue yourself.
composer require oro/oauth2-server
Then open vendor/oro/oauth2-server and read two things before touching your app:
composer.json — check the php, psr/*, and symfony/* constraints. This is a single-vendor fork; the constraints tell you which PHP minors are actually supported, and whether you need to pin anything.Storage directory — the interfaces you have to implement (ClientInterface, AccessTokenInterface, RefreshTokenInterface, AuthCodeInterface, ScopeInterface, etc.). This is your real work surface.Where to look in the code: the Oro\Bundle\OAuth2ServerBundle namespace is Oro's integration layer (Symfony/Doctrine glue). Do not follow its shape — you want the framework-agnostic OAuth2\* classes underneath. The bundle is a worked example of one integration, not an API contract for yours.
Your first use case. Do not try to build "an OAuth2 server" in week one. Build one thing: a token endpoint that issues a bearer token via client_credentials for a known, trusted partner client. That gives you three pieces end-to-end and no user-facing surface to secure:
StorageInterface implementation backed by your oauth_clients and oauth_access_tokens tables.Server instance with exactly one grant registered.Illuminate\Http\Request into whatever request shape the library expects and returns the response.That is realistically a day of glue. Once it works, add authorization_code + PKCE and refresh_token before you tell anyone the API is "OAuth2-ready."
Honest recommendation: if you are on greenfield Laravel, stop and use laravel/passport. It wraps league/oauth2-server, gives you migrations, guards, artisan commands, PKCE, and an active CVE-response process. Reach for oro/oauth2-server only if you are interoperating with an existing OroPlatform app, are already on a bshaffer/oauth2-server-php deployment and want a PHP-version bridge, or have a hard constraint against League.
Grant composition — add only what you support. The server is assembled by registering grant objects; each grant is an attack surface and a code path you own.
$server = new \OAuth2\Server($storage, [
'enforce_state' => true,
'access_lifetime' => 3600,
'refresh_lifetime' => 1209600,
// 'require_client_secret' is decided per-client, not globally
]);
$server->addGrantType(new \OAuth2\GrantType\ClientCredentials($storage));
$server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($storage));
$server->addGrantType(new \OAuth2\GrantType\RefreshToken($storage, [
'always_issue_new_refresh_token' => true,
'unset_refresh_token_after_use' => true,
]));
// Only add UserCredentials if you genuinely need the password grant.
Registering UserCredentials "just in case" is the most common mistake I see. The password grant is deprecated by modern guidance (OAuth 2.1 removes it); don't ship it to third parties.
Storage over Eloquent, not the bundled PDO adapter. The library ships PDO, MongoDB, and in-memory storage. In Laravel, write a custom adapter — the bundled PDO schema is opinionated and won't match your naming, multi-tenancy, or soft-delete conventions.
class EloquentOAuthStorage implements StorageInterface /* + the narrower interfaces */ {
public function getClientDetails($client_id) {
$client = OAuthClient::where('client_id', $client_id)->first();
if (! $client) return false;
return [
'client_id' => $client->client_id,
'client_secret' => $client->client_secret_hash, // stored hashed
'redirect_uri' => $client->redirect_uri,
'grant_types' => $client->grant_types,
'scope' => $client->scope,
'user_id' => $client->user_id,
];
}
public function getAccessToken($access_token) {
$row = OAuthAccessToken::where('token_hash', hash('sha256', $access_token))
->where('expires', '>', time())
->first();
return $row ? $row->toOAuthArray() : false;
}
}
Rules of thumb:
hash('sha256', $token) on write, lookup on hash. The library will hand you the raw string in getAccessToken(); that's the only place you need to compare.token_hash and expires. Every authenticated request hits this lookup; it is your hot path.false, not null. The library checks truthiness in ways that will bite you if you're inconsistent.The controller / route. Token endpoint is public; authorize endpoint requires auth.
Route::post('/oauth/token', [OAuthController::class, 'token'])->middleware('throttle:60,1');
Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->middleware(['web', 'auth']);
public function token(Request $request) {
$bridge = new IlluminateOAuthBridge($request); // your adapter
$response = $this->server->handleTokenRequest($bridge->toOAuthRequest());
return $bridge->toLaravelResponse($response);
}
Middleware for the resource server. The library validates; you gate. Do not skip the "did the user actually authorize this scope on this client" check — the library will confirm signature/expiry and often stop there.
class OAuthScope {
public function handle($request, Closure $next, string ...$required) {
$token = $request->bearerToken();
if (! $token) return response()->json(['error' => 'invalid_token'], 401);
$server = app(\OAuth2\ResourceServer::class);
if (! $server->verifyResourceRequest(
(new IlluminateOAuthBridge($request))->toOAuthRequest(),
$resp,
implode(' ', $required)
)) {
return response()->json(['error' => 'insufficient_scope'], 403);
}
$request->attributes->set('oauth_user', $server->getAccessTokenData(
(new IlluminateOAuthBridge($request))->toOAuthRequest()
));
return $next($request);
}
}
Bind OAuth2\ResourceServer in a service provider that reads your bearer token and calls verifyResourceRequest with your storage. This is the piece Passport gives you for free as a guard.
Client provisioning. Ship an artisan command — you'll need one, and doing it manually in SQL is how you get inconsistent grant_types arrays.
php artisan oauth:client --name="Partner X" --grant=client_credentials --scope=orders:read,orders:write
Print the secret exactly once, store the hash, and never echo it back through the admin UI.
Key management (authorization code + PKCE in particular). Generate a signing keypair out-of-band, store the private key in a secret manager (not .env), and expose the public key at a /.well-known/jwks.json if you use JWT access tokens. Rotate with a grace window: publish the new key alongside the old one, sign with new, verify with both, retire the old after the longest access-token lifetime.
Testing. You need a conformance suite before you tell anyone you're an OAuth2 server. The non-negotiables:
it('rejects a redirect_uri that is a prefix match')->get('/oauth/authorize?...'); // 400
it('rejects an expired access token')->get('/api/orders')->assertStatus(401);
it('rejects a reused refresh token')->post('/oauth/token')->assertStatus(400);
it('rejects a mismatched PKCE verifier')->post('/oauth/token')->assertStatus(400);
it('accepts a valid client_credentials token')->post('/oauth/token')->assertOk();
If you're migrating from bshaffer/oauth2-server-php, this suite is the whole point. Same storage, different library — the tests are what let you swap.
The request/response abstraction is the biggest integration tax. The library predates PSR-7 and uses its own OAuth2\Request / OAuth2\Response. Every framework entry point is a manual adapter. Symptoms of getting this wrong: empty grant_type (library sees a raw body, Laravel doesn't populate it), missing Authorization header (Symfony-style header extraction doesn't translate directly), and Content-Type mismatches on token responses. Write one IlluminateOAuthBridge class with toOAuthRequest() and toLaravelResponse() and use it everywhere. Don't scatter new OAuth2\Request($_GET, $_POST, ...) calls across controllers.
No service provider, no facade, no auto-discovery. You are wiring every binding manually. That's fine — but it means the Server and ResourceServer should each be singletons in a dedicated OAuthServiceProvider, or you'll rebuild the server per request and re-add grants (cheap but noisy, and easy to get out of sync with your feature flags).
enforce_state and require_exact_redirect_uri are the two flags that decide whether you have an OAuth2 server or a vulnerability. The library defaults are sane but the fork's may not be; verify against the source, not against a 2014 tutorial. Exact-match redirect URIs, always. Prefix match is a known CVE class.
Refresh-token rotation is opt-in and you want it. Set always_issue_new_refresh_token => true and unset_refresh_token_after_use => true, then add a reuse detector: if a retired token is presented, revoke the whole grant family and log an alert. The library will happily serve the same refresh token forever if you don't ask it not to — this is the difference between "we have refresh tokens" and "we have refresh-token replay detection."
Clock skew will produce intermittent 401s that take a day to reproduce. Configure leeway on the server ('leeway' => 60 in the options array), keep NTP tight on app and token-store hosts, and test the expiry boundary explicitly. The library's expires check compares epoch seconds; a one-second drift on a shared-nothing setup is a real failure mode at scale.
PHP version compatibility is on you. 2 stars, 0 dependents, no CVE-response process you can rely on. Add a composer audit job to CI and a quarterly upstream-diff review against bshaffer/oauth2-server-php and league/oauth2-server. Treat the package as vendored: consider a replace: entry in your own composer.json so you can patch locally and still install.
league/oauth2-server is the mental model for everything this fork lacks. If you find yourself implementing token introspection (RFC 7662), OIDC discovery, PKCE S256 verification from scratch, or RFC 9700 (current security best practice) token rotation, stop and re-evaluate. Those are the exact features the maintained successor shipped and this fork did not.
Token cleanup is a scheduled job you must write. Nothing in the library deletes expired tokens. A nightly php artisan oauth:prune job against indexed expires is table-stakes — without it, your token table grows unbounded and lookups slow. Batch by 10k rows; don't try to delete a million in one statement.
Storage outage means total auth outage. Every protected surface depends on one table or cache. Document the degradation policy (fail closed, with alerting) and monitor token-lookup latency, not just error rate. A slow token store is an outage in disguise.
When to bail. The honest threshold: if at any point you find yourself implementing more than two of {PKCE verification, OIDC, token introspection, refresh rotation, JWKS publishing, revocation propagation}, you have outgrown this package. Migrate to laravel/passport (Laravel) or league/oauth2-server (framework-agnostic) while your storage schema is still compatible. The migration cost goes up every week you defer it.
How can I help you explore Laravel packages today?