TenantAtlas/app/Http/Controllers/RbacDelegatedAuthController.php

149 lines
5.1 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class RbacDelegatedAuthController extends Controller
{
public function start(Request $request): RedirectResponse
{
$tenantIdentifier = $request->string('tenant')->toString();
$tenant = $tenantIdentifier
? Tenant::query()->forTenant($tenantIdentifier)->firstOrFail()
: Tenant::current();
$targetTenant = $tenantIdentifier ?: $tenant->graphTenantId();
$clientId = config('graph.client_id');
$redirectUri = route('admin.rbac.callback');
abort_if(empty($clientId) || empty($redirectUri), Response::HTTP_BAD_GATEWAY, 'Graph client not configured');
$state = Str::uuid()->toString();
$request->session()->put('rbac_state', $state);
$request->session()->put('rbac_tenant', $tenant->getKey());
$returnTo = $this->sanitizeReturnPath(
$request->string('return')->toString()
?: route('filament.admin.resources.tenants.view', $tenant)
);
$request->session()->put('rbac_return', $returnTo);
$scopes = implode(' ', [
'openid',
'profile',
'offline_access',
'Directory.ReadWrite.All',
'Group.ReadWrite.All',
'DeviceManagementRBAC.ReadWrite.All',
'DeviceManagementConfiguration.ReadWrite.All',
]);
$url = "https://login.microsoftonline.com/{$targetTenant}/oauth2/v2.0/authorize?".http_build_query([
'client_id' => $clientId,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
'scope' => $scopes,
'state' => $state,
]);
return redirect()->away($url);
}
public function callback(Request $request): RedirectResponse
{
$expectedState = $request->session()->pull('rbac_state');
$tenantId = $request->session()->pull('rbac_tenant');
$returnTo = $request->session()->pull('rbac_return');
abort_if(! $expectedState, Response::HTTP_FORBIDDEN, 'RBAC state missing');
abort_if($expectedState !== $request->string('state')->toString(), Response::HTTP_FORBIDDEN, 'Invalid RBAC state');
abort_if(! $tenantId, Response::HTTP_BAD_REQUEST, 'Tenant context missing');
/** @var Tenant $tenant */
$tenant = Tenant::query()->findOrFail($tenantId);
$code = $request->string('code')->toString();
abort_if(empty($code), Response::HTTP_BAD_REQUEST, 'Authorization code missing');
$tokens = $this->exchangeAuthorizationCode($code);
if (empty($tokens['access_token'])) {
abort(Response::HTTP_BAD_GATEWAY, 'Failed to exchange code for token');
}
$accessToken = $tokens['access_token'];
$ttl = CarbonImmutable::now()->addMinutes(5);
// Store token keyed by user id (preferred) and session id (fallback) to survive SPA refreshes.
if (auth()->check()) {
Cache::put($this->cacheKey($tenant, auth()->id(), null), $accessToken, $ttl);
}
Cache::put($this->cacheKey($tenant, auth()->id(), $request->session()->getId()), $accessToken, $ttl);
$destination = $this->sanitizeReturnPath($returnTo) ?: route('filament.admin.resources.tenants.view', $tenant);
return redirect()->to($destination);
}
/**
* @return array{access_token:?string,refresh_token:?string,expires_in:?int}
*/
private function exchangeAuthorizationCode(string $code): array
{
$response = Http::asForm()->post(sprintf(
'https://login.microsoftonline.com/%s/oauth2/v2.0/token',
config('graph.tenant_id', 'common')
), [
'client_id' => config('graph.client_id'),
'client_secret' => config('graph.client_secret'),
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => route('admin.rbac.callback'),
]);
if ($response->failed()) {
return [];
}
$json = $response->json() ?: [];
return [
'access_token' => $json['access_token'] ?? null,
'refresh_token' => $json['refresh_token'] ?? null,
'expires_in' => $json['expires_in'] ?? null,
];
}
public static function cacheKey(Tenant $tenant, ?int $userId = null, ?string $sessionId = null): string
{
$suffix = $userId ? "user_{$userId}" : 'session_'.($sessionId ?: 'anon');
return sprintf('rbac_delegated_token_%s_%s', $tenant->getKey(), $suffix);
}
private function sanitizeReturnPath(?string $path): ?string
{
if (empty($path)) {
return null;
}
if (str_starts_with($path, 'http')) {
$parsed = parse_url($path);
$path = $parsed['path'] ?? '/';
}
return str_starts_with($path, '/') ? $path : '/';
}
}