m4n50n/oauth2-azure-bundle
A Symfony bundle that simplifies Azure AD OAuth2 authentication by integrating the League OAuth2 Azure provider, letting you add Microsoft sign-in and protect routes with minimal configuration.
Read this first: it's a Symfony bundle, not a Laravel package. There is no service provider, no config/oauth2azure.php, and nothing to php artisan vendor:publish. In a Laravel app you will not install m4n50n/oauth2-azure-bundle; you install what it wraps:
composer require thenetworg/oauth2-azure
That library is a league/oauth2-client provider for Microsoft Entra ID (Azure AD). The bundle is a ~2-star, zero-dependent convenience layer that registers that provider in Symfony's DI container. Treat it as reference documentation for configuration shape, not as a dependency.
Minimum viable setup in Laravel:
config/services.php — add an azure block with client_id, client_secret, redirect, tenant.AppServiceProvider so you get one configured Azure provider instance.GET /auth/azure/redirect and GET /auth/azure/callback.// AppServiceProvider::register()
$this->app->singleton(\TheNetwOrg\OAuth2\Client\Provider\Azure::class, fn () => new \TheNetwOrg\OAuth2\Client\Provider\Azure([
'clientId' => config('services.azure.client_id'),
'clientSecret' => config('services.azure.client_secret'),
'redirectUri' => config('services.azure.redirect'),
'tenant' => config('services.azure.tenant', 'common'),
'scopes' => ['openid', 'profile', 'email', 'offline_access', 'User.Read'],
]));
Where to look first in the bundle repo: DependencyInjection/Configuration.php and Resources/config/services.*. That config tree is the only genuinely useful artifact — it enumerates every option the underlying provider accepts (tenant, scopes, resource, apiVersion, proxy, timeout, verify) and maps them to constructor keys. Copy that list into your services.php and you've extracted 100% of the bundle's value.
First real use case: internal-staff SSO. Restrict tenant to your directory GUID, request openid profile email offline_access, and you get corporate logins with zero password handling. That's the 20-minute win.
Plain league/oauth2-client flow (recommended for full control). This is what the bundle does internally, and it maps cleanly onto two Laravel routes:
// redirect
public function redirect(Azure $azure) {
$url = $azure->getAuthorizationUrl(['state' => Str::random(40)]);
session(['azure_state' => $azure->getState(), 'azure_pkce' => $azure->getPkceCode()]);
return redirect($url);
}
// callback
public function callback(Request $request, Azure $azure) {
abort_unless($request->state === session('azure_state'), 403);
$azure->setPkceCode(session('azure_pkce'));
$token = $azure->getAccessToken('authorization_code', ['code' => $request->code]);
$owner = $azure->getResourceOwner($token);
$user = User::updateOrCreate(
['azure_oid' => $owner->getId()],
['name' => $owner->getFirstName().' '.$owner->getLastName(),
'email' => $owner->getEmail(), 'tenant_id' => $owner->getTenantId()]
);
Auth::login($user);
return redirect('/dashboard');
}
Key the local user row on the object id (oid), not email and not UPN. Email changes on marriage/renaming; oid is stable per directory.
Socialite bridge. If the rest of your app is Socialite-driven, don't hand-roll the flow — use Socialite::buildProvider(Azure::class, config('services.azure')). You keep Socialite::driver() ergonomics and the same underlying provider, and ->stateless() works for API/token-based callers.
Multi-tenant variants. The bundle's pattern is worth stealing here: rather than one global provider, register named instances keyed by tenant.
$this->app->bind('azure.tenant', fn ($app, $args) => new Azure([...$base, 'tenant' => $args['tenant']]));
Useful for a customer-facing app where you support both contoso.onmicrosoft.com guests and your own workforce directory, or for CI/service-to-service tokens.
Client credentials for daemon jobs. Same provider, different grant:
$token = $azure->getAccessToken('client_credentials', ['scope' => 'https://graph.microsoft.com/.default']);
Group-based authorization. AzureResourceOwner::getGroups() returns group GUIDs. Resolve them to names once (cache a GUID→name map from Graph), then Gate::define('finance', fn ($u) => in_array(config('azure.finance_group'), $u->azure_groups)). Don't hardcode display names in code.
tenant => 'common' breaks client credentials. The common/organizations aliases only work for user-delegated flows. Daemon apps must use an explicit tenant ID or domain — otherwise you get AADSTS50194.resource=https://graph.microsoft.com; v2 uses scope=.../.default. Mixing them yields AADSTS70011 (invalid scope). Pick apiVersion => '2.0' and stay there; the bundle's config exposes the switch, which is exactly the trap — it invites you to leave it unset.http vs https. http://localhost:8000/auth/azure/callback in .env will not match http://127.0.0.1:8000/... in the portal. This is the most common "it works in staging, not locally" bug.offline_access is required for a refresh token — and Entra rotates refresh tokens, so always persist the newly returned one. A silent token refresh loop with a stale RT fails after ~90 days.snake_case; the provider constructor uses camelCase (clientId, redirectUri). If you port config straight across you'll get a provider with null credentials and a confusing AADSTS900144 at the token endpoint, not at construction. Validate eagerly in your singleton factory._claim_names instead of groups. Detect it and fall back to POST https://graph.microsoft.com/v1.0/me/getMemberObjects with User.Read + GroupMember.Read.All. Silent empty getGroups() is not "user has no groups."AADSTS##### response carries trace_id and correlation_id from login.microsoftonline.com; log them. Decode the id_token at jwt.ms (never in prod with real tokens) to see what claims actually arrived. GET https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration tells you the exact endpoints and whether your tenant is configured as expected.getPkceCode() through the session. Forgetting it, or losing state on a non-sticky-session load balancer, produces AADSTS501481/invalid-state errors at 3am.nbf/exp validation fails on containers with drifted clocks. Run NTP, or widen leeway via the provider's timeout/HTTP client options rather than disabling validation.thenetworg/oauth2-azure to a minor range, vendor the config array from Configuration.php, and keep an eye on Microsoft identity platform changes (Entra renamed endpoints in 2023; the library has trailed slightly).Azure and override getResourceOwnerDetailsUrl() / createResourceOwner() to attach Graph data to the resource owner in one round trip, rather than issuing a second HTTP call in your controller. That's ~15 lines and saves latency on every login.How can I help you explore Laravel packages today?