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 Azure Laravel Package

thenetworg/oauth2-azure

Azure Active Directory OAuth 2.0 provider for the PHP League's OAuth client. Adds Azure AD and Microsoft Graph support, authorization code flow, on-behalf-of tokens, logout, B2C, and certificate-based auth.

View on GitHub
Deep Wiki
Context7

Getting Started

This is not a Laravel package — it is a League OAuth 2.0 Client provider. There is no service provider, facade, config file, or migration. You install it, bind it yourself, and drive it through League\OAuth2\Client semantics. Budget an hour for wiring the first time.

Install and configure

composer require thenetworg/oauth2-azure
// config/services.php
'azure' => [
    'client_id'     => env('AZURE_CLIENT_ID'),
    'client_secret' => env('AZURE_CLIENT_SECRET'),
    'redirect_uri'  => env('AZURE_REDIRECT_URI'),
    'tenant'        => env('AZURE_TENANT_ID', 'common'),   // 'common', 'organizations', 'consumers', or a tenant GUID
    'scopes'        => ['openid', 'profile', 'email', 'offline_access', 'User.Read'],
],
// app/Providers/AppServiceProvider.php
use TheNetworg\OAuth2\Client\Provider\Azure;

$this->app->bind(Azure::class, function () {
    $cfg = config('services.azure');

    $provider = new Azure([
        'clientId'     => $cfg['client_id'],
        'clientSecret' => $cfg['client_secret'],
        'redirectUri'  => $cfg['redirect_uri'],
        'tenant'       => $cfg['tenant'],
        'scopes'       => $cfg['scopes'],
    ]);

    $provider->defaultEndPointVersion = Azure::ENDPOINT_VERSION_2_0;
    // The provider holds scopes as a space-separated string property, not an array.
    $provider->scope = implode(' ', $cfg['scopes']);

    return $provider;
});

Use bind, not singleton — see the Octane note in Gotchas.

Where to look first

  1. The README's Authorization Code Flow snippet (it is written in vanilla PHP with $_SESSION — mentally translate to Laravel session/controllers).
  2. The Microsoft Graph section, for getRootMicrosoftGraphUri().
  3. The underlying thephpleague/oauth2-client docs for grant objects, AccessToken::hasExpired(), and error handling — everything generic lives there, not here.

First use case: "Sign in with Microsoft" and read /me

// routes/web.php
Route::get('/auth/azure',        [AzureController::class, 'redirect'])->name('azure.redirect');
Route::get('/auth/azure/callback', [AzureController::class, 'callback'])->name('azure.callback');

// app/Http/Controllers/AzureController.php
public function redirect(Azure $azure)
{
    $url = $azure->getAuthorizationUrl(['scope' => $azure->scope]);

    session(['azure.state' => $azure->getState()]);

    return redirect()->away($url);
}

public function callback(Request $request, Azure $azure)
{
    if ($request->has('error')) {
        abort(403, $request->string('error_description')->toString());
    }

    abort_unless(
        hash_equals((string) session()->pull('azure.state'), $request->string('state')->toString()),
        403, 'Invalid state'
    );

    $token = $azure->getAccessToken('authorization_code', [
        'scope' => $azure->scope,
        'code'  => $request->string('code')->toString(),
    ]);

    $me = $azure->get($azure->getRootMicrosoftGraphUri($token) . '/v1.0/me', $token);

    // $me['mail'], $me['displayName'], $me['id'] ...
}

When not to use this package. If all you need is "log the user in with Microsoft and forget the token," Laravel Socialite's built-in azure driver (or SocialiteProviders/Azure) is less code and gives you Socialite::driver('azure')->user() for free. Reach for thenetworg/oauth2-azure when you need: paginated Graph reads (getObjects), on-behalf-of flows, certificate-based client credentials, Azure AD B2C, the v1 resource model, or direct control over token objects and refresh.

Implementation Patterns

Persist tokens, not sessions. Session-stored tokens break on logout, mobile apps, queued jobs, and horizontal scaling. Store them encrypted against the user:

Schema::create('azure_tokens', function (Blueprint $t) {
    $t->id();
    $t->foreignId('user_id')->constrained()->cascadeOnDelete();
    $t->text('access_token');     // cast 'encrypted'
    $t->text('refresh_token')->nullable();
    $t->timestamp('expires_at')->nullable();
    $t->string('scope')->nullable();
    $t->timestamps();
});

Centralize the refresh in one place. Never call getAccessToken('refresh_token', …) from a controller:

final class AzureTokenService
{
    public function __construct(private Azure $azure) {}

    public function fresh(User $user): AccessToken
    {
        $row = $user->azureToken;

        $token = new AccessToken([
            'access_token'  => $row->access_token,
            'refresh_token' => $row->refresh_token,
            'expires_in'    => $row->expires_at?->diffInSeconds(now()),
        ]);

        if (! $token->hasExpired()) {
            return $token;
        }

        if (! $row->refresh_token) {
            throw new AzureSessionExpiredException();
        }

        return Cache::lock("azure:refresh:{$user->id}", 10)->block(5, function () use ($user, $row) {
            $token = $this->azure->getAccessToken('refresh_token', [
                'scope'         => $this->azure->scope,
                'refresh_token' => $row->refresh_token,
            ]);

            $user->azureToken->update([
                'access_token'  => $token->getToken(),
                'refresh_token' => $token->getRefreshToken() ?? $row->refresh_token,
                'expires_at'    => now()->addSeconds($token->getExpires() ?? 3600),
                'scope'         => $token->getValues()['scope'] ?? $this->azure->scope,
            ]);

            return $token;
        });
    }
}

The cache lock matters: refresh tokens rotate, and two concurrent requests using the same stale token will produce AADSTS70008 / invalid_grant and log the user out.

Graph calls. Always build the base URI from the token, not a hardcoded string — the provider returns a different shape for v1 vs v2:

$graph = $azure->getRootMicrosoftGraphUri($token);        // https://graph.microsoft.com
$me    = $azure->get($graph . '/v1.0/me', $token);
$body  = $azure->post($graph . '/v1.0/sendMail', json_encode($payload), $token);

Bulk reads use getObjects, with a tenant argument. This is the package's best feature and the most commonly missed one; it follows @odata.nextLink until exhausted:

foreach ($azure->getObjects('contoso.onmicrosoft.com', 'v1.0/users', $token) as $user) {
    // pages transparently
}

The $tenant argument is required because odata.nextLink URLs do not carry it. For anything over a few thousand objects, wrap this in a queued job and handle 429 throttling (see Gotchas).

Protect your own API with validateAccessToken — a middleware that maps JWT claims to a Laravel user and roles:

public function handle(Request $request, Closure $next, string $scope = 'access_as_user')
{
    $bearer = $request->bearerToken();
    abort_unless($bearer, 401);

    try {
        $claims = $this->azure->validateAccessToken($bearer);
    } catch (\Exception $e) {
        abort(401, 'Invalid token');
    }

    abort_unless(($claims['scp'] ?? '') === $scope, 403, 'Insufficient scope');

    // $claims['roles'], $claims['groups'], $claims['oid'], $claims['tid'], $claims['upn']
    $request->attributes->set('azure.claims', $claims);

    return $next($request);
}

On-behalf-of when your API needs Graph on the caller's behalf:

$graphToken = $azure->getAccessToken('jwt_bearer', [
    'scope'               => 'https://graph.microsoft.com/.default',
    'assertion'           => $bearerFromIncomingRequest,
    'requested_token_use' => 'on_behalf_of',
]);

jwt_bearer is the package's shortcut for urn:ietf:params:oauth:grant-type:jwt-bearer — use it, don't spell the URN out.

Multi-tenant. For SaaS, set tenant to common or organizations and read $claims['tid'] on callback to route into the right tenant record. For single-tenant enterprise apps, pin the tenant GUID in config so tokens from other directories are rejected. Guest users and consumers accounts behave differently in v1 vs v2 for email/preferred_username — don't assume preferred_username is a routable SMTP address.

Logout. $azure->getLogoutUrl($postLogoutRedirectUri) gives you the Microsoft-side session kill. Pair it with Auth::logout() and session()->invalidate(); the Microsoft SSO cookie survives unless you hit that URL.

Queued / scheduled Graph work (sync users, push calendar entries) should use a stored refresh token via the service above, or — cleaner for app-only work — client credentials with a separate provider instance using the client_credentials grant and .default scopes. Don't reuse the user's delegated token for background sync; it breaks the moment the user changes their password.

Testing. Don't hit Azure in CI. League\OAuth2\Client accepts an injected Guzzle client, so replay canned responses:

$mock = new GuzzleHttp\Handler\MockHandler([
    new GuzzleHttp\Psr7\Response(200, [], json_encode(['access_token' => 'x', 'expires_in' => 3600])),
]);
$provider = new Azure([...]);
$provider->setHttpClient(new GuzzleHttp\Client(['handler' => GuzzleHttp\HandlerStack::create($mock)]));

Gotchas and Tips

resource vs scope is the #1 time sink. The v1 endpoint authenticates with resource=https://graph.microsoft.com, the v2 endpoint with scope=https://graph.microsoft.com/User.Read. Mixing them produces AADSTS50001, AADSTS65001, or AADSTS70011. Set defaultEndPointVersion once, decide which model you use, and don't copy v1 examples into a v2 app. $provider->authWithResource auto-fills resource from $provider->resource/$provider->urlAPI — that's a v1-era convenience; leave it alone on v2.

The provider's scope is a public string property, but the constructor takes an array. Setting one and not the other means the authorization URL and the token request disagree, and Azure silently drops the missing scopes. Set both, from the same source, and pass 'scope' => $azure->scope explicitly on every getAccessToken call — the redirect leg and the token leg must match or you lose the refresh token.

No offline_access → no refresh token. Very common cause of "users logged out after an hour." It is a scope, not a config flag.

hasExpired() is off by a margin. League's AccessToken treats a token as expired slightly before its real exp (default is fine, but be aware). Conversely, if you reconstruct an AccessToken by hand from the DB, getExpires() is meaningless unless you set expires_in — as in the snippet above. Also expect clock skew between your host and Azure.

validateAccessToken is not a complete security boundary. It parses the JWT and checks issuer/expiry, but do not assume it performs full signature verification against Azure's JWKS in the way you'd want for a hostile network. For anything security-critical, verify the signature yourself against https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys and check aud, iss, exp, tid, and scp/roles. Also note the README's subtlety: a persisted AccessToken should be validated via $token->getIdToken(), not $token->getToken().

Redirect URI must match byte-for-byte, including trailing slash and scheme, against the App Registration. https://localhost vs http://localhost:8000 mismatches give AADSTS50011. redirectUri should come from config/env, not url()->current(), or proxy/load-balancer schemes will bite you.

get() and friends take refs without a leading slash. The README's examples mix 'myOrganization/groups' and 'https://graph.microsoft.com/v1.0/me'. Passing /v1.0/me produces a double slash and a confusing Graph 400. Always concatenate getRootMicrosoftGraphUri($token) . '/v1.0/...'.

Use getRootMicrosoftGraphUri(null) for scopes and getRootMicrosoftGraphUri($token) for URLs. They return different strings (v1 tokens get a trailing slash, v2 don't). Passing the wrong one gives you either a malformed scope or a malformed URL.

Throttling is your problem. getObjects happily paginates into Microsoft's 429 wall. Catch IdentityProviderException, read $e->getResponseBody(), honor Retry-After, and cap retries. Log the request-id/client-request-id from the error body — Microsoft support asks for it and you cannot reconstruct it later.

Catch IdentityProviderException, not Exception. Azure returns rich error bodies (error, error_description, error_codes, timestamp, trace_id, correlation_id); $e->getResponseBody() has them. Surface error_description only in logs, never to end users — it leaks tenant and policy internals.

Admin consent. New scopes on an existing app produce AADSTS65001: consent required until a tenant admin approves. In dev, hit getAuthorizationUrl(['prompt' => 'consent']) to force re-consent; in production, ship an admin-consent URL and document it. Incremental consent per-user is a support burden — request the full scope set at login.

Client secrets expire (max 24 months in Azure). Rotate them and add a calendar reminder, or move to certificate credentials: set clientCertificatePrivateKey and clientCertificateThumbprint, drop clientSecret. This also survives environments where secrets can't be stored as plain env vars.

Azure AD B2C is experimental. It needs pathAuthorize = '/oauth2/v2.0/authorize', pathToken = '/oauth2/v2.0/token', a custom policy passed as 'p' => 'B2C_1_signin', and B2C tokens don't carry the same claims — getResourceOwner() will return sparse data, and Graph calls against graph.microsoft.com generally don't apply. Test the exact policy names; a typo yields AADB2C90002.

Multipurpose refresh tokens (v1, multiple resources) need the claims parameter with a {"access_token": {"resource": "..."}} JSON blob on both the initial and the refresh request. If you're building against Graph + your own API from one refresh token, this is the path — but it's marked experimental and the v2 .default scope + multiple scopes is the better long-term design.

Extension points, if you must. Subclass Azure and override getBaseAuthorizationUrl(), getResourceOwnerDetailsUrl(), or getAccessToken() to inject extra parameters (e.g. a custom domain_hint, telemetry headers, or x-client-SKU). Inject a configured Guzzle client via setHttpClient() for proxies, mTLS, timeouts, or request logging. Prefer this over copy-pasting the provider's internals.

Laravel Octane. Under Octane the container persists between requests, so a singleton-bound provider will leak $provider->state and a previously set $provider->scope across users. Use bind (or scoped) so each request gets a fresh provider. Same applies to any $provider->resource mutation.

Don't dd() a provider. It wraps Guzzle and will dump the client secret and, after a token exchange, live tokens into your logs. Add clientSecret and access_token to config/logging.php's redaction list if you use structured logging on exceptions.

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.
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk