If you are unfamiliar with Rayfin, it is Fabric’s new Backend as a Service for hosting web applications within Fabric. One of the first things I thought of when I saw Rayfin is how useful it would be to not just read and write data, but to control the Fabric platform itself via the Fabric Rest API. The use case being to allow managing of custom administration workflows in one place, within the platform that you are administrating.

At first glance the Rayfin auth controller doesn’t provide a solid means to get an access token, that would have scoping to https://api.fabric.microsoft.com/.default.

In order to achieve this then we will need the MSAL library to run clientside, and a Service Principal that we can use to delegate authentication through.

Recently we got the opportunity to do a mini hackathon at work and my team set out to prove this pattern. Below is the steps needed, and a sample code base can be found here Ines’ Rayfin Control Plane.

This assumes that you are using the Rayfin as a blankapp template, and doesn’t include any details on handling Fabric API rate limits or other considerations. Adjust as needed.

Or you can use the skill found here.

Step 1 - Add MSAL library and SPN Details#

At the time of writing I am using the following library added to the package.json dependencies.

{
    "dependencies":{
        "@azure/msal-browser": "^5.14.0",
    }
}

We will also need to configure the SPN wihin the Azure Entra ID portal. Create an App Registration that you want to use, and add a Redirect URI for an SPA of http://localhost:5173 and the URL of the app when it is hosted, i.e. https://hazy-jane-349bdbe845-swedencentral.webapp.fabricapps.net

Create a .env and use the following to maintain the Tenant and Client ID. We don’t need a secret for this process as the user credentials will be used for delegated authentication.

# Entra SPA app registration used by `~src/services/fabricToken`.ts to acquire
# tokens for api.fabric.microsoft.com. SPA client IDs and tenant IDs are
# public values and safe to commit.
VITE_FABRIC_ENTRA_CLIENT_ID=<YOUR-CLIENT-ID>
VITE_FABRIC_ENTRA_TENANT_ID=<YOUR-TENANT-ID>

We will also need to set the SPN with the scopes that we will allow the user to delegate for. Within the App Registration head over to the API Permissions, and add a permission. From here we can find Power BI Service > Delegated permissions and choose the scopes we will need.

Step 2 - Create a Fabric Token Service#

Now we need to create a service ~src/services/fabricToken.ts, that will use the SPN details we have provided to login a user and get a fabric access token that can be used to call the API.

First, import the MSAL browser library and set up scopes and configuration:


import {
  PublicClientApplication,
  type SsoSilentRequest,
} from '@azure/msal-browser';

/**
 * Scopes required for the control plane:
 * - Workspace.Read.All       — list workspaces
 * - Item.ReadWrite.All       — list pipelines (items of type DataPipeline)
 * - Item.Execute.All         — submit on-demand pipeline jobs and poll status
 */
const FABRIC_SCOPES = [
  'https://api.fabric.microsoft.com/Workspace.Read.All',
  'https://api.fabric.microsoft.com/Item.ReadWrite.All',
  'https://api.fabric.microsoft.com/Item.Execute.All',
];

function readClientConfig(): { clientId: string; tenantId: string } {
  const clientId = import.meta.env.VITE_FABRIC_ENTRA_CLIENT_ID;
  const tenantId = import.meta.env.VITE_FABRIC_ENTRA_TENANT_ID;
  if (!clientId || !tenantId) {
    throw new Error(
      'Missing Entra SPA app config. Set VITE_FABRIC_ENTRA_CLIENT_ID and VITE_FABRIC_ENTRA_TENANT_ID.'
    );
  }
  return { clientId, tenantId };
}

Then we will create an MSAL instance that we can reuse, caching the authentication in localStorage. This will use the Tenant ID and clientID from above.

let msalInstance: PublicClientApplication | null = null;

async function getMsalInstance(): Promise<PublicClientApplication> {
  if (msalInstance) return msalInstance;

  const { clientId, tenantId } = readClientConfig();

  msalInstance = new PublicClientApplication({
    auth: {
      clientId,
      authority: `https://login.microsoftonline.com/${tenantId}`,
      redirectUri: window.location.origin,
    },
    cache: {
      cacheLocation: 'localStorage',
    },
  });

  await msalInstance.initialize();
  return msalInstance;
}

/**
 * Call once at app startup (before any acquireFabricTokenSilent calls) to
 * pick up an access token if the page is returning from a login redirect.
 */
export async function handleMsalRedirect(): Promise<string | null> {
  const pca = await getMsalInstance();
  const result = await pca.handleRedirectPromise();
  return result?.accessToken ?? null;
}

Finally we can create a Fabric token with the MSAL instance, we will use the silent sso request to attempt to get this token without needing a pop up or user interaction.

/**
 * Acquire a Fabric REST API access token. Tries in order:
 *   1. ssoSilent (hidden iframe, using loginHint)
 *   2. acquireTokenSilent (cached account)
 *   3. acquireTokenRedirect (full-page redirect — required inside Fabric
 *      iframes where popups are blocked)
 */
export async function acquireFabricTokenSilent(
  loginHint: string
): Promise<string> {
  const pca = await getMsalInstance();

  const request: SsoSilentRequest = {
    scopes: FABRIC_SCOPES,
    loginHint,
  };

  try {
    const result = await pca.ssoSilent(request);
    return result.accessToken;
  } catch {
    // fall through
  }

  const accounts = pca.getAllAccounts();
  const account = accounts.find(
    (a) => a.username?.toLowerCase() === loginHint.toLowerCase()
  );

  if (account) {
    try {
      const silent = await pca.acquireTokenSilent({
        scopes: FABRIC_SCOPES,
        account,
      });
      return silent.accessToken;
    } catch {
      // fall through to redirect
    }
  }

  await pca.acquireTokenRedirect({
    scopes: FABRIC_SCOPES,
    loginHint,
  });

  throw new Error('Redirecting...');
}

Now that our service is complete, we can hook it in to the application. Open the page that you will be needing the token, and import the acquireFabricTokenSilent and handleMsalRedirect functions we created.

We will need a way to track the status of the token. For now a simple token state will suffice, that we can switch between.

import {
  acquireFabricTokenSilent,
  handleMsalRedirect,
} from '@/services/fabricToken';

type TokenState = 'loading' | 'ready' | 'error';

At the top of our page code, we can then hold the status of the token in state and use an effect to load a token on page render. Ignore the setFabricToken for now.

export function ControlPlanePage() {
  // ...
  const [tokenState, setTokenState] = useState<TokenState>('loading');
  const [tokenError, setTokenError] = useState<string | null>(null);

  // ...

  // --- Token acquisition (Rayfin SSO → MSAL for Fabric REST) ---
  useEffect(() => {
    if (!user?.email) return;
    let cancelled = false;
    (async () => {
      try {
        const redirectToken = await handleMsalRedirect();
        if (cancelled) return;
        if (redirectToken) {
          setFabricToken(redirectToken);
          setTokenState('ready');
          return;
        }

        const token = await acquireFabricTokenSilent(user.email);
        if (cancelled) return;
        setFabricToken(token);
        setTokenState('ready');
      } catch (err) {
        if (cancelled) return;
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes('Redirecting')) return; // page is navigating away
        setTokenError(msg);
        setTokenState('error');
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [user?.email]);

The user object in this code comes from the standard out of the box Auth service that Rayfin creates in the scaffold: import { type AuthUser, type IAuthService } from '@/services/IAuthService';

Step 3 - Fabric API Service#

We can now look at creating a service that we can reuse for handling our API calls for the UI. We will create a new ~src/services/fabricApi.ts for this. First we will need a few constants to hold the API endpoint and any relevant information. For my usecase this will be the Job types that are needed by the API such as Pipeline, RunNotebook or sparkjob. Then we will set some basic getters and setters for managing the token.

const FABRIC_API_BASE = 'https://api.fabric.microsoft.com/v1';

/** jobType path segment used by the Job Scheduler API for DataPipeline items. */
const PIPELINE_JOB_TYPE = 'Pipeline';
const SPARKJOB_JOB_TYPE = 'sparkjob';

let _accessToken: string | null = null;

export function setFabricToken(token: string) {
  _accessToken = token;
}

export function clearFabricToken() {
  _accessToken = null;
}

export function hasFabricToken(): boolean {
  return !!_accessToken;
}

We can now craft a method that will be used to send base requests to the Fabric API, and finally reuse them within the more specific functions. For example the below listWorkspaces function.

async function fabricFetch<T>(
  path: string,
  options?: RequestInit
): Promise<T> {
  if (!_accessToken) {
    throw new Error('No Fabric API token set.');
  }

  const res = await fetch(`${FABRIC_API_BASE}${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${_accessToken}`,
      'Content-Type': 'application/json',
      ...options?.headers,
    },
  });

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Fabric API ${res.status}: ${body}`);
  }

  if (res.status === 202) {
    return {
      status: 'accepted',
      location: res.headers.get('Location'),
    } as T;
  }

  if (res.status === 204) {
    return undefined as T;
  }

  return res.json() as Promise<T>;
}

// Type export for Fabric Workspace
export interface FabricWorkspace {
  id: string;
  displayName: string;
  description?: string;
  type: string;
  capacityId?: string;
}

// Type export for JobInstance
export interface JobInstance {
  id: string;
  itemId: string;
  jobType: string;
  invokeType?: string;
  status: 'NotStarted' | 'InProgress' | 'Completed' | 'Failed' | 'Cancelled' | string;
  startTimeUtc?: string;
  endTimeUtc?: string;
  failureReason?: { errorCode?: string; message?: string } | null;
  rootActivityId?: string;
}

// Type export for RunPipelineResult
export interface RunPipelineResult {
  status: 'accepted';
  /** URL of the created job instance — also exposes the job instance ID. */
  location: string | null;
}

// Lists workspaces from the Fabric API
export async function listWorkspaces(): Promise<FabricWorkspace[]> {
  const result = await fabricFetch<{ value: FabricWorkspace[] }>('/workspaces');
  return result.value;
}

/**
 * Submit an on-demand run of a Data Pipeline. Returns the Location header
 * pointing at the new job instance. The actual run is asynchronous — poll
 * with {@link getJobInstance}.
 */
export async function runPipeline(
  workspaceId: string,
  pipelineId: string,
  body?: { executionData?: Record<string, unknown>; parameters?: unknown[] }
): Promise<RunPipelineResult> {
  return fabricFetch<RunPipelineResult>(
    `/workspaces/${encodeURIComponent(workspaceId)}/items/${encodeURIComponent(
      pipelineId
    )}/jobs/${PIPELINE_JOB_TYPE}/instances`,
    {
      method: 'POST',
      body: body ? JSON.stringify(body) : undefined,
    }
  );
}

export async function getJobInstance(
  workspaceId: string,
  pipelineId: string,
  jobInstanceId: string
): Promise<JobInstance> {
  return fabricFetch<JobInstance>(
    `/workspaces/${encodeURIComponent(workspaceId)}/items/${encodeURIComponent(
      pipelineId
    )}/jobs/instances/${encodeURIComponent(jobInstanceId)}`
  );
}