singleloginsystem/app/Http/Controllers/EntraController.php
subhajit ee8c320431
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Merge branch 'feature/116196-editable-app-details-oidc'
2026-06-22 17:56:36 +05:30

232 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\{OauthToken, User};
use App\Services\UserAppAccessService;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\{RedirectResponse, Request};
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class EntraController extends Controller
{
// ── OAuth config from .env ────────────────────────────────────────────────
private const string PROVIDER = 'microsoft';
private const string SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
private string $tenantId;
private string $clientId;
private string $clientSecret;
private string $redirectUri;
public function __construct()
{
$this->tenantId = (string) config('services.microsoft.tenant_id');
$this->clientId = (string) config('services.microsoft.client_id');
$this->clientSecret = (string) config('services.microsoft.client_secret');
$this->redirectUri = route('entra.callback');
}
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
public function connect(#[CurrentUser] User $user): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$state = Str::random(40);
session(['oauth_state' => $state]);
$query = http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'response_mode' => 'query',
'scope' => self::SCOPES,
'state' => $state,
'prompt' => 'select_account',
]);
return redirect("https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/authorize?$query");
}
// ── Step 2: Handle callback, exchange code for tokens ────────────────────
public function callback(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
// CSRF state check
if (! hash_equals((string) session('oauth_state'), (string) $request->state)) {
session()->forget('oauth_state');
return redirect()->route('dashboard.user')
->with('entra_error', 'Invalid OAuth state. Please try again.');
}
session()->forget('oauth_state');
if ($request->has('error')) {
return redirect()->route('dashboard.user')
->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
}
if (! $request->filled('code')) {
return redirect()->route('dashboard.user')
->with('entra_error', 'No authorization code received from Microsoft.');
}
// Exchange authorization code for tokens
$response = Http::asForm()->post(
"https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $request->code,
'redirect_uri' => $this->redirectUri,
'grant_type' => 'authorization_code',
'scope' => self::SCOPES,
]
);
if ($response->failed()) {
return redirect()->route('dashboard.user')
->with('entra_error', $response->json('error_description', 'Failed to retrieve tokens from Microsoft.'));
}
$data = $response->json();
OauthToken::updateOrCreate(
[
'user_id' => $user->id,
'provider' => self::PROVIDER,
],
[
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? null,
'token_type' => $data['token_type'] ?? 'Bearer',
'scopes' => self::SCOPES,
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
]
);
return redirect()->route('dashboard.user')
->with('entra_success', 'Microsoft connected successfully!');
}
// ── Open Outlook (silent refresh if expired) ──────────────────────────────
public function openEntra(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$token = $user->oauthToken(self::PROVIDER);
if (! $token) {
return redirect()->route('entra.connect');
}
// Token still valid (5-min buffer)
if ($token->expires_at->gt(now()->addMinutes(5))) {
return redirect('https://outlook.office.com/mail/');
}
// Token expired — try silent refresh
if ($this->refreshToken($token)) {
return redirect('https://outlook.office.com/mail/');
}
// Refresh failed — force full re-login
$token->delete();
return redirect()->route('entra.connect');
}
// ── Disconnect ────────────────────────────────────────────────────────────
private function refreshToken(OauthToken $token): bool
{
if (! $token->refresh_token) {
return false;
}
$response = Http::asForm()->post(
"https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'refresh_token' => $token->refresh_token,
'grant_type' => 'refresh_token',
'scope' => self::SCOPES,
]
);
if ($response->failed()) {
return false;
}
$data = $response->json();
$token->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
]);
return true;
}
// ── Internal: Refresh access token using refresh_token ───────────────────
public function disconnect(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$user->oauthToken(self::PROVIDER)?->delete();
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft disconnected.');
}
}