carlos-mg89/oauth
OAuth 2.0 package for Laravel/PHP that simplifies authorization flows, token management, and securing API routes, with configurable grants and middleware integration for adding delegated authentication to your app.
There is no Laravel service provider here — carlos-mg89/oauth is a plain PHP OAuth 1/2 client, so "getting started" means wiring three objects yourself: credentials, token storage, and the service factory.
1. Install and check requirements
composer require carlos-mg89/oauth
php -m | grep curl # the default CurlClient needs ext-curl
2. Look at these four things first, in this order
| File | Why |
|---|---|
src/OAuth/ServiceFactory.php |
The only entry point you need; maps short names (github, google) to service classes |
src/OAuth/Common/Consumer/Credentials.php |
Holds client id/secret/redirect URI |
src/OAuth/Common/Storage/TokenStorageInterface.php |
The extension point you will almost certainly reimplement for Laravel |
src/OAuth/OAuth2/Service/AbstractService.php |
Where getAuthorizationUri(), requestAccessToken(), request() live — read this before debugging anything |
3. First use case: OAuth2 authorization-code login (GitHub)
use OAuth\Common\Consumer\Credentials;
use OAuth\Common\Storage\Session;
use OAuth\ServiceFactory;
$factory = new ServiceFactory();
$credentials = new Credentials(
config('services.github.client_id'),
config('services.github.client_secret'),
route('oauth.callback', 'github')
);
$storage = new Session(); // native $_SESSION — see Gotchas
$github = $factory->createService('github', $credentials, $storage, ['user:email']);
if (request()->missing('code')) {
return redirect()->away($github->getAuthorizationUri());
}
$token = $github->requestAccessToken(request('code'));
$user = json_decode($github->request('user')->getBody(), true);
4. Register it in the container so controllers stay clean
// app/Providers/AppServiceProvider.php
$this->app->singleton(ServiceFactory::class, fn () => new ServiceFactory());
$this->app->bind(TokenStorageInterface::class, LaravelTokenStorage::class);
Once that binding exists, app(ServiceFactory::class)->createService(...) is the whole API surface you use day to day.
Don't scatter new Credentials(...) across controllers. Centralise provider config:
// config/services.php
'github' => [
'client_id' => env('GITHUB_CLIENT_ID'),
'client_secret' => env('GITHUB_CLIENT_SECRET'),
'scopes' => ['user:email', 'repo'],
],
// app/Services/OAuth/ProviderFactory.php
class ProviderFactory
{
public function __construct(private ServiceFactory $factory) {}
public function make(string $provider): ServiceInterface
{
$cfg = config("services.{$provider}");
return $this->factory->createService(
$provider,
new Credentials($cfg['client_id'], $cfg['client_secret'], route('oauth.callback', $provider)),
app(TokenStorageInterface::class),
$cfg['scopes'] ?? []
);
}
}
The shipped Session storage is unusable behind Laravel's session middleware. Implement the interface against a table so tokens survive and can be refreshed offline:
class LaravelTokenStorage implements TokenStorageInterface
{
public function __construct(private ?int $userId = null) {}
private function key(string $service): string
{
return "{$service}:{$this->userId}"; // per-user isolation
}
public function retrieveAccessToken($service) { /* return TokenInterface from row */ }
public function storeAccessToken($service, TokenInterface $token) { /* upsert, encrypt */ }
public function hasAccessToken($service) { ... }
public function clearToken($service) { ... }
public function clearAllTokens() { ... }
// Do not forget these four — they exist for the OAuth2 state parameter
public function retrieveAuthorizationState($service) { ... }
public function storeAuthorizationState($service, $state) { ... }
public function hasAuthorizationState($service) { ... }
public function clearAuthorizationState($service) { ... }
}
Store access_token, refresh_token, expires_at, scopes as separate columns and cast the token to encrypted — the library's Token object serialises cleanly, but a JSON blob is easier to query and to migrate.
public function redirect(string $provider)
{
return redirect()->away(app(ProviderFactory::class)->make($provider)->getAuthorizationUri());
}
public function callback(Request $request, string $provider)
{
// verify state before anything else
$service = app(ProviderFactory::class)->make($provider);
$token = $service->requestAccessToken($request->string('code'));
OAuthAccount::updateOrCreate(
['user_id' => $request->user()->id, 'provider' => $provider],
['token' => $token]
);
return redirect()->route('dashboard');
}
The default CurlClient blocks testing. Register a custom client implementing OAuth\Common\Http\Client\ClientInterface that delegates to Laravel's HTTP client — you get logging, retries, and Http::fake() for free:
$this->app->bind(ClientInterface::class, GuzzleClientAdapter::class);
$factory->setHttpClient(app(ClientInterface::class));
Nothing refreshes automatically. Schedule a command that selects accounts expiring within the hour and calls the provider's refresh endpoint:
$service = app(ProviderFactory::class)->make($account->provider);
$service->refreshAccessToken(); // only where the version/provider supports it
If refreshAccessToken() isn't available for your provider, POST the token endpoint via $service->request($tokenUrl, 'POST', $params) and persist the returned token yourself.
ServiceFactory::registerService('acme', AcmeService::class) lets you subclass OAuth\OAuth2\Service\AbstractService and override getAuthorizationEndpoint(), getAccessTokenEndpoint(), getAuthorizationMethod(), and the scope constants. This is the standard workaround when a bundled service lags behind a provider's API version.
OAuth1 has an extra leg; the shape differs:
$token = $service->requestRequestToken();
return redirect()->away($service->getAuthorizationUri(['oauth_token' => $token->getRequestToken()]));
// callback
$service->requestAccessToken($request->string('oauth_token'), $request->string('oauth_verifier'));
Bind OAuth\Common\Storage\Memory instead of the Eloquent adapter and a stub ClientInterface returning canned JSON. Assert the redirect URI contains client_id, scope, and state; assert the callback stores a token row. That covers ~90% of the code you actually own.
Session storage is native $_SESSION. OAuth\Common\Storage\Session writes to the PHP superglobal, not to Laravel's session driver. Under Laravel's StartSession middleware with a cache/redis/DB driver, $_SESSION is empty and the token vanishes between the redirect and the callback. Always replace it.
Storage keys are scoped by service name only. Two users logging into the same provider overwrite each other's token. Either construct one storage instance per user (as in Pattern 2) or namespace the key with the user id — the library does not do this for you.
The state methods are easy to miss. If you implement TokenStorageInterface by hand and skip retrieveAuthorizationState / storeAuthorizationState, you get a fatal error or silent CSRF bypass on the callback. Validate the returned state against the session yourself; the library generates and sends it but leaves verification to you.
Errors surface as exceptions, not return values. Catch OAuth\Common\Http\Exception\TokenResponseException (token endpoint 4xx) and OAuth\Common\Exception\Exception at the callback. A denied consent screen returns ?error=access_denied with no code — check for that before calling requestAccessToken(), or you'll get a confusing exception instead of a clean "user declined" message.
Bundled endpoints go stale. Service classes hardcode API URLs and often a version prefix. When a provider deprecates v3 for v4, you're editing a vendor file — instead subclass, override the constant, and registerService() your class. Keep a note of every override so upgrades don't silently revert them.
Scopes: constants vs strings. Many services expose SCOPE_* constants, but the factory accepts plain strings too. Passing an unregistered scope can throw InvalidScopeException on services that validate against a whitelist. When in doubt, pass the raw scope string the provider documents.
Token expiry isn't tracked for you. TokenInterface carries an expiry, but nothing checks it before request(). A 401 from the provider is your only signal. Wrap API calls and refresh on 401 rather than guessing.
Low bus factor. 7 stars, zero known dependents, and a NOASSERTION license (check the LICENSE file before commercial use — this is a fork of Lusitanian/PHPoAuthLib). Pin an exact version in composer.json, commit the lockfile, and assume you may need to fork. Diff against upstream before upgrading.
It may be the wrong tool. If you only need "log in with Google/GitHub/Facebook," Laravel Socialite is maintained, has a provider in the container, and handles the state parameter correctly. Reach for this library when you need OAuth 1, a provider Socialite doesn't ship, or direct API access with a stored token outside of the login flow.
ServiceFactory is cheap but not stateless-enough to share blindly. It holds the registered service map and HTTP client; create it once as a singleton (as in the binding above) rather than per-request new calls, and configure the HTTP client before the first createService().
How can I help you explore Laravel packages today?