Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Oauth2 Laravel Package

adoy/oauth2

A framework-agnostic PHP OAuth 2.0 server library that implements authorization and resource servers, common grant types, scopes, and token handling, giving PHP applications a lightweight foundation for secure OAuth2 authentication.

View on GitHub
Deep Wiki
Context7

Getting Started

adoy/oauth2 is a single-class OAuth 2.0 client (Adoy\OAuth2\Client, ~600 lines) that does three things: build an authorization URL, POST to a token endpoint, and make bearer-authenticated HTTP calls via cURL. That's the whole surface area — which is exactly why it's easy to adopt and easy to outgrow.

Install and first look

composer require adoy/oauth2   # pulls in ext-curl; no Laravel service provider ships with it

Read src/Adoy/OAuth2/Client.php top to bottom before writing any code. The two methods that matter are getAuthenticationUrl() and getAccessToken(); everything else (call(), authenticate(), setFormat(), setCurlOption()) is glue. There is no documentation site, so the README plus the class itself is your API reference.

Decide first whether you need it. In a Laravel app the honest first step is: does laravel/socialite already have a driver for your provider? If yes, use Socialite — it gives you the state handling, the Socialite::driver() contract, and testability for free. Reach for adoy/oauth2 when you're integrating a provider Socialite doesn't cover (regional IdPs, internal company SSO, older enterprise APIs) or when you need to pass provider-specific params that Socialite's driver abstraction hides from you.

Minimal wiring

// config/services.php
'acme' => [
    'client_id'     => env('ACME_CLIENT_ID'),
    'client_secret' => env('ACME_CLIENT_SECRET'),
    'redirect'      => env('ACME_REDIRECT_URI'),   // must byte-match what's registered
    'authorize_url' => 'https://acme.test/oauth/authorize',
    'token_url'     => 'https://acme.test/oauth/token',
    'scopes'        => ['read', 'offline_access'],
],
$client = new Adoy\OAuth2\Client(config('services.acme.client_id'), config('services.acme.client_secret'));
$client->setRedirectUri(config('services.acme.redirect'));
$client->setGrantType(Adoy\OAuth2\Client::GRANT_TYPE_AUTHORIZATION_CODE);
$client->setScope(implode(' ', config('services.acme.scopes')));

return redirect()->away($client->getAuthenticationUrl(config('services.acme.authorize_url')));

First real use case: an authorization-code "Sign in with X" flow in two controller actions (redirect out, callback in) plus a personal_access_tokens-adjacent table to persist the resulting token. Build that end-to-end first; the refresh and resource-call patterns follow naturally.

Implementation Patterns

Wrap it, don't sprinkle it. The class is stateful and mutable (setGrantType, setState, setAccessToken all mutate). Construct a fresh instance per operation behind a small factory so request-scoped state can't leak between flows:

class OAuthClientFactory
{
    public function make(string $provider): Client
    {
        $cfg = config("services.oauth.{$provider}");
        $client = new Client($cfg['client_id'], $cfg['client_secret']);
        $client->setRedirectUri($cfg['redirect']);
        $client->setScope(implode(' ', $cfg['scopes']));
        $client->setCurlOptions([CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5]);

        return $client;
    }
}

Two-action authorization-code flow. Store the state in the session on the way out, validate it on the way back (the library does not do this for you):

public function redirect(OAuthClientFactory $f, string $provider)
{
    $client = $f->make($provider);
    $client->setGrantType(Client::GRANT_TYPE_AUTHORIZATION_CODE);
    $client->setState($state = Str::random(40));
    session()->put("oauth.{$provider}.state", $state);

    return redirect()->away($client->getAuthenticationUrl(config("services.oauth.{$provider}.authorize_url")));
}

public function callback(Request $request, OAuthClientFactory $f, string $provider)
{
    $expected = (string) session()->pull("oauth.{$provider}.state");
    abort_unless($expected !== '' && hash_equals($expected, (string) $request->query('state')), 403);

    $client = $f->make($provider);
    $client->setGrantType(Client::GRANT_TYPE_AUTHORIZATION_CODE);

    $raw = $client->getAccessToken(config("services.oauth.{$provider}.token_url"), [
        'code'         => $request->query('code'),
        'redirect_uri' => config("services.oauth.{$provider}.redirect"),
    ]);

    $token = json_decode($raw, true);

    if (isset($token['error'])) {
        throw new RuntimeException($token['error'].': '.($token['error_description'] ?? ''));
    }

    // persist $token['access_token'], $token['refresh_token'], $token['expires_in']
}

Persist tokens with intent to refresh. Store refresh_token with Laravel's 'encrypted' cast, and store an absolute expires_at (now()->addSeconds($token['expires_in'] - 60)) rather than the raw expires_in, so a queued job can ask "is this stale?" without reading the response again.

public function refresh(OAuthClient $account, OAuthClientFactory $f): OAuthToken
{
    $client = $f->make($account->provider);
    $raw = $client->getAccessToken(config("services.oauth.{$account->provider}.token_url"), [
        'grant_type'    => 'refresh_token',
        'refresh_token' => $account->refresh_token,
    ]);
    // rotate: some providers return a new refresh_token, some don't — only overwrite when present
}

Resource calls through call(). Authenticate once, then issue requests; call() returns the raw body, so decode yourself:

$client->authenticate($account->access_token, Client::AUTH_METHOD_BEARER);
$body = $client->call('https://api.acme.test/v1/me', 'GET');
$me   = json_decode($body, true);

Use AUTH_METHOD_URI or AUTH_METHOD_PARAM for the handful of legacy APIs that reject the Authorization header. For client-credentials / machine-to-machine APIs, set GRANT_TYPE_CLIENT_CREDENTIALS and skip the redirect entirely — cache the token in Cache::remember() keyed on client ID + scopes.

PKCE by hand. The library predates PKCE, so add it as extra auth params and forward the verifier:

$verifier  = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');

$url = $client->getAuthenticationUrl($authorizeUrl, [
    'code_challenge'        => $challenge,
    'code_challenge_method' => 'S256',
]);
// ...later: 'code_verifier' => $verifier in the token request params

Wrap it behind an interface for tests. Because it talks straight to cURL, Http::fake() won't intercept it. Define interface OAuthGateway in your app, implement it with adoy/oauth2, and swap in an in-memory fake in tests. This single decision removes the biggest day-to-day friction of the package.

Gotchas and Tips

Errors are response bodies, not exceptions. getAccessToken() happily returns {"error":"invalid_grant"} with an HTTP 400. You must isset($result['error']) on every call. Combine with getLastResponseInfo() (http_code, curl_errno) and getLastResponse() when you need the raw payload for a log line.

Return types are raw strings. Default setFormat('json') gives you the undecoded body. Only a few historical versions returned arrays — never assume; json_decode($raw, true) at the boundary and hand typed arrays upward.

The constructor and getAccessToken() signature changed between 0.x and 1.x. Older code passes $redirectUri, $grantType, $scope, $state to the constructor and takes getAccessToken($endpoint, $grantType, $params); newer code uses setters and getAccessToken($endpoint, $params). Pin the version in composer.json and treat the two idioms as different libraries when reading blog posts — half the examples online are 0.x.

getAuthenticationUrl()'s second argument is an array of extra query params, not a redirect URI. Passing a string throws or silently produces a mangled URL depending on version. The redirect URI goes through setRedirectUri().

Redirect URI must match exactly. Trailing slash, http vs https, localhost vs 127.0.0.1, and URL-encoded query fragments all cause redirect_uri_mismatch. Copy the registered value from the provider console verbatim into .env, and note that the token request's redirect_uri param must match the one used at authorization time.

State is your responsibility. setState() only embeds the value in the URL; nothing validates it on return, and nothing prevents reuse. If you skip the hash_equals check above, you have a CSRF hole in your login flow. Also bind the state to the provider name so a callback from provider A can't satisfy a flow started for provider B.

TLS verification defaults have varied by tag. Be explicit: setVerifyPeer(true) plus a CA bundle path in production, and if you're tempted to setVerifyPeer(false) for local dev, gate it behind app()->isLocal() — never let it reach staging.

No PKCE, no OIDC discovery, no JWT validation, no introspection endpoint support. If your provider requires PKCE, requires id_token verification, or enforces OAuth 2.1, you're writing that layer yourself (see above). For .well-known/openid-configuration, fetch it once with Http::get() and cache the endpoint URLs; don't hardcode them per environment.

Refresh token rotation will bite you. Providers that rotate refresh tokens invalidate the old one the moment you use it. Do refresh inside a transaction or a cache lock (Cache::lock("oauth:{$id}", 10)) and only overwrite the stored token when the response actually contains one — otherwise a failed partial refresh locks the user out permanently.

License check. LGPL-2.1 is copyleft-lite: dynamically linking from your app is fine, but modifications to the library itself must be released under the same terms. If your legal review flags copyleft, the escape hatch is trivial — the whole package is roughly 40 lines of Http::asForm()->withBasicAuth()->post() in Laravel's HTTP client. Seriously consider that for a single provider; this package earns its place only when you're juggling several non-Socialite providers and want consistent state/URL/curl handling.

Never log getLastResponse(). The token endpoint's raw response contains refresh tokens and access tokens. If you add debugging output, redact access_token, refresh_token, and id_token keys before it hits the log.

Extension points are thin but real. Subclass Client to override getAccessToken() for provider-specific quirks (form-encoded params, extra auth headers via the third $headers argument, non-standard token envelopes), and use setCurlOption()/setCurlOptions() for proxies, timeouts, or client certificates. That subclass is also the natural place to bolt on instrumentation — timing, status codes, error taxonomy — that the base class doesn't provide.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums