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 : '/'; } }