user-guides

Keycloak Integration with Entra ID - Authentication Flow


Table of Contents

  1. Overview
  2. User Categories
  3. Detailed Authentication Flow
  4. Visual Flow Diagram
  5. Implementation Guide

Overview

This document describes the authentication flow for end users accessing Power BI/Fabric workspaces through the MedAdv application using Keycloak SSO, with Microsoft Entra ID B2B guest access integration.

The flow supports multiple identity provider scenarios and ensures seamless authentication while maintaining security and compliance requirements.

Key Features


User Categories

The system supports four distinct user types, each with different authentication methods:

User Type Description Authentication Method Example
Entra-to-Entra Guest User from another Entra organization Home tenant (Entra IdP) user@partner.com (Entra tenant)
Non-Entra with MSA Account User with personal Microsoft Account Home tenant (Microsoft Account) user@gmail.com (with MSA)
Non-Entra Federated with Entra User from IdP federated with Entra Home tenant (Federated IdP) user@company.com (via Okta/ADFS)
Non-Entra, No MSA, No Federation User with no existing Microsoft identity Email One-Time Passcode (OTP) user@startup.com (OTP only)

Detailed Authentication Flow

Phase 1: Initial Authentication & Guest Invitation

Step 1: User Login to MedAdv

Technical Details:

Step 2: Power BI Access Request

Business Logic:

Step 3: Guest User Check

API Call:

GET https://graph.microsoft.com/v1.0/users?$filter=mail eq 'kc@contoso.com'

Possible Outcomes:

Step 4: Guest Invitation Creation

API Call:

POST https://graph.microsoft.com/v1.0/invitations
Content-Type: application/json

{
  "invitedUserEmailAddress": "kc@contoso.com",
  "inviteRedirectUrl": "https://app.powerbi.com/groups/{workspaceId}",
  "sendInvitationMessage": false,
  "invitedUserDisplayName": "KC User",
  "invitedUserType": "Guest"
}

Parameters:

Step 5: Redemption URL Retrieval

Response Example:

{
  "id": "7b92124c-9fa9-406f-8b8e-225270c3730f",
  "inviteRedeemUrl": "https://login.microsoftonline.com/redeem?...",
  "invitedUserDisplayName": "KC User",
  "invitedUserEmailAddress": "kc@contoso.com",
  "status": "PendingAcceptance",
  "invitedUser": {
    "id": "a87e3c80-7b72-4f92-a982-e895eea5730f"
  }
}

Key Fields:

HTTP Response:

HTTP/1.1 302 Found
Location: https://login.microsoftonline.com/redeem?rd=...

Consent Screen Elements:

Critical Requirement:

⚠️ User must explicitly click Accept to proceed. Canceling will abort the guest invitation flow.

After Acceptance:

Step 8: Group Membership Assignment

API Call:

POST https://graph.microsoft.com/v1.0/groups/{groupId}/members/$ref
Content-Type: application/json

{
  "@odata.id": "https://graph.microsoft.com/v1.0/users/{userId}"
}

Group Strategy:

Implementation Note:

Step 9: License Assignment Preparation

License Types:

Role-to-License Mapping Example:

const licenseMappings = {
  'viewer': 'POWER_BI_STANDARD', // Free
  'analyst': 'POWER_BI_PRO',
  'developer': 'POWER_BI_PREMIUM_PER_USER',
  'admin': 'POWER_BI_PREMIUM_PER_USER'
};

Step 10: License Assignment via Graph API

API Call:

POST https://graph.microsoft.com/v1.0/users/{userId}/assignLicense
Content-Type: application/json

{
  "addLicenses": [
    {
      "skuId": "f8a1db68-be16-40ed-86d5-cb42ce701560",
      "disabledPlans": []
    }
  ],
  "removeLicenses": []
}

SKU IDs (Examples):

Prerequisites:

Step 11: License Confirmation

Verification: Backend should verify license assignment status:

GET https://graph.microsoft.com/v1.0/users/{userId}/licenseDetails

Expected Response:

{
  "value": [
    {
      "id": "...",
      "skuId": "f8a1db68-be16-40ed-86d5-cb42ce701560",
      "skuPartNumber": "POWER_BI_PRO",
      "servicePlans": [...]
    }
  ]
}

Phase 3: Power BI Access & Authentication Routing

Step 12: Power BI Redirection

URL Format:

https://app.powerbi.com/groups/{workspaceId}/list?experience=power-bi

Alternative Formats:

Step 13: Authentication Request to Resource Entra

Authentication Flow Initiation:

HTTP/1.1 302 Found
Location: https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize?
  client_id={powerbi-client-id}&
  response_type=code&
  redirect_uri=https://app.powerbi.com/&
  scope=https://analysis.windows.net/powerbi/api/.default&
  state={state}

Step 14: Home Tenant Authentication Routing

Entra ID determines the appropriate authentication method based on the user’s identity configuration.

Step 14.1: Entra-to-Entra Guest User

Scenario: User’s home organization uses Microsoft Entra ID

Flow:

  1. Resource Entra redirects to user’s home Entra tenant
  2. User sees their corporate login page (e.g., login.microsoftonline.com/contoso.com)
  3. User authenticates with corporate credentials (username/password + MFA)
  4. Home Entra tenant issues SAML/OIDC assertion
  5. Assertion sent back to resource Entra tenant

Authentication Details:

Example User Journey:

Resource Entra → Home Entra (contoso.com) → User Login → MFA → 
Token Issued → Resource Entra
Step 14.2: Non-Entra with Microsoft Account (MSA)

Scenario: User has personal Microsoft Account (e.g., @gmail.com, @yahoo.com)

Flow:

  1. Entra redirects to Microsoft Account login (login.live.com)
  2. User sees Microsoft Account login page
  3. User authenticates with MSA credentials
  4. MSA service may require additional verification (SMS, email code)
  5. MSA service issues token
  6. Token sent to resource Entra tenant

Authentication Details:

Example User Journey:

Resource Entra → login.live.com → User Login → SMS Verification → 
Token Issued → Resource Entra
Step 14.3: Non-Entra Federated with Entra

Scenario: User’s organization uses third-party IdP federated with Entra (e.g., Okta, Keycloak, ADFS, Ping Identity)

Flow:

  1. Resource Entra redirects to user’s federated IdP
  2. User sees their corporate IdP login page
  3. User authenticates with corporate credentials
  4. Federated IdP performs authentication (may include SSO if already logged in)
  5. IdP issues SAML/OIDC assertion
  6. Assertion sent to resource Entra via federation trust

Authentication Details:

Example User Journey:

Resource Entra → Keycloak (contoso.com) → [SSO Session Exists] → 
SAML Assertion → Resource Entra

Benefits:

Step 14.4: Non-Entra, No MSA, No Federation

Scenario: User has no existing Microsoft identity and organization not federated

Flow:

  1. Entra initiates Email One-Time Passcode (OTP) flow
  2. System sends 6-8 digit code to user’s email (kc@contoso.com)
  3. User receives OTP email from Microsoft
  4. User enters OTP code in authentication page
  5. Entra validates OTP code
  6. Upon successful validation, session token issued

OTP Email Details:

Authentication Page:

┌────────────────────────────────────┐
│  Enter the code sent to            │
│  kc@contoso.com                    │
│                                    │
│  Code: [_][_][_][_][_][_][_][_]    │
│                                    │
│  [Verify]    [Resend Code]         │
└────────────────────────────────────┘

Example User Journey:

Resource Entra → OTP Email Sent → User Checks Email → 
Enters Code → Validation → Session Token → Resource Entra

Considerations:

Step 15: Token Reception at Resource Tenant

Token Validation Steps:

  1. Signature Verification: Validates token signature using public key
  2. Issuer Verification: Confirms token issued by trusted authority
  3. Audience Verification: Ensures token intended for this resource
  4. Expiration Check: Validates token not expired
  5. Claims Extraction: Extracts user identity claims

Identity Mapping:

Home Tenant Identity: kc@contoso.com
                 ↓
Resource Tenant Guest UPN: kc_contoso.com#EXT#@resourcetenant.onmicrosoft.com
                 ↓
Email Attribute: kc@contoso.com

Claims Mapping Example:

{
  "aud": "https://analysis.windows.net/powerbi/api",
  "iss": "https://sts.windows.net/{resource-tenant-id}/",
  "iat": 1708704000,
  "exp": 1708707600,
  "name": "KC User",
  "email": "kc@contoso.com",
  "oid": "{guest-user-object-id}",
  "tid": "{resource-tenant-id}",
  "unique_name": "kc_contoso.com#EXT#@resourcetenant.onmicrosoft.com",
  "upn": "kc_contoso.com#EXT#@resourcetenant.onmicrosoft.com"
}

Step 16: Power BI Workspace Access Granted

Authorization Checks:

  1. License Validation:
    • User has active Power BI license
    • License type supports requested operations
  2. Group Membership Validation:
    • User is member of authorized security group
    • Group has permissions to workspace
  3. Workspace Role Validation:
    • User assigned appropriate role (Viewer, Contributor, Member, Admin)
    • Role permissions match requested operations
  4. Conditional Access Policies:
    • Device compliance (if required)
    • Location/IP restrictions (if configured)
    • MFA requirements met

Access Token Structure:

{
  "aud": "https://analysis.windows.net/powerbi/api",
  "appid": "{power-bi-client-id}",
  "roles": ["Report.Read.All", "Dataset.Read.All"],
  "scp": "Dataset.Read.All Report.Read.All",
  "wids": ["{workspace-role-id}"]
}

Success Response:


Visual Flow Diagram

Sequence Diagram

Flow diagram
Click to view full size

Simplified Flow Diagram

Flow diagram
Click to view full size


Implementation Guide for Developers

Prerequisites

1. Azure/Entra ID Configuration

App Registration:

Required API Permissions:

Permission Type Purpose
User.Invite.All Application Create B2B guest invitations
User.ReadWrite.All Application Read and update user properties
GroupMember.ReadWrite.All Application Manage group memberships
Organization.Read.All Application Read organization license SKUs
Directory.Read.All Application Read directory data

Admin Consent Required: Yes (all application permissions)

Grant Admin Consent:

# Using Azure CLI
az ad app permission admin-consent \
  --id {application-id}

2. Power BI Configuration

Workspace Setup:

Tenant Settings:

3. Keycloak Configuration

Identity Provider Setup:

User Attribute Mapping:

{
  "email": "user.email",
  "firstName": "user.firstName",
  "lastName": "user.lastName",
  "roles": "user.roles"
}

Microsoft Graph API Implementation

Authentication Setup

Using Client Credentials Flow:

const msal = require('@azure/msal-node');

const msalConfig = {
  auth: {
    clientId: process.env.AZURE_CLIENT_ID,
    authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
    clientSecret: process.env.AZURE_CLIENT_SECRET,
  }
};

const cca = new msal.ConfidentialClientApplication(msalConfig);

async function getAccessToken() {
  const tokenRequest = {
    scopes: ['https://graph.microsoft.com/.default'],
  };
  
  try {
    const response = await cca.acquireTokenByClientCredential(tokenRequest);
    return response.accessToken;
  } catch (error) {
    console.error('Error acquiring token:', error);
    throw error;
  }
}

Core Functions

1. Check if User Exists
const axios = require('axios');

async function checkUserExists(email) {
  const accessToken = await getAccessToken();
  
  try {
    const response = await axios.get(
      `https://graph.microsoft.com/v1.0/users?$filter=mail eq '${email}'`,
      {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        }
      }
    );
    
    return response.data.value.length > 0 ? response.data.value[0] : null;
  } catch (error) {
    if (error.response && error.response.status === 404) {
      return null;
    }
    throw error;
  }
}
2. Create Guest Invitation
async function createGuestInvitation(email, displayName, redirectUrl) {
  const accessToken = await getAccessToken();
  
  const invitationData = {
    invitedUserEmailAddress: email,
    invitedUserDisplayName: displayName,
    inviteRedirectUrl: redirectUrl,
    sendInvitationMessage: false,
    invitedUserType: 'Guest'
  };
  
  try {
    const response = await axios.post(
      'https://graph.microsoft.com/v1.0/invitations',
      invitationData,
      {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        }
      }
    );
    
    return {
      userId: response.data.invitedUser.id,
      redeemUrl: response.data.inviteRedeemUrl,
      status: response.data.status
    };
  } catch (error) {
    console.error('Error creating invitation:', error.response?.data);
    throw error;
  }
}
3. Add User to Group
async function addUserToGroup(userId, groupId) {
  const accessToken = await getAccessToken();
  
  const memberData = {
    "@odata.id": `https://graph.microsoft.com/v1.0/users/${userId}`
  };
  
  try {
    await axios.post(
      `https://graph.microsoft.com/v1.0/groups/${groupId}/members/$ref`,
      memberData,
      {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        }
      }
    );
    
    return { success: true, message: 'User added to group successfully' };
  } catch (error) {
    if (error.response && error.response.status === 400) {
      // User might already be a member
      console.warn('User might already be a member of the group');
      return { success: true, message: 'User already a member' };
    }
    throw error;
  }
}
4. Assign License
async function assignLicense(userId, skuId) {
  const accessToken = await getAccessToken();
  
  const licenseData = {
    addLicenses: [
      {
        skuId: skuId,
        disabledPlans: []
      }
    ],
    removeLicenses: []
  };
  
  try {
    await axios.post(
      `https://graph.microsoft.com/v1.0/users/${userId}/assignLicense`,
      licenseData,
      {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        }
      }
    );
    
    return { success: true, message: 'License assigned successfully' };
  } catch (error) {
    console.error('Error assigning license:', error.response?.data);
    throw error;
  }
}
5. Get Available Licenses
async function getAvailableLicenses() {
  const accessToken = await getAccessToken();
  
  try {
    const response = await axios.get(
      'https://graph.microsoft.com/v1.0/subscribedSkus',
      {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        }
      }
    );
    
    // Filter for Power BI licenses
    const powerBILicenses = response.data.value.filter(sku => 
      sku.skuPartNumber.includes('POWER_BI')
    );
    
    return powerBILicenses.map(sku => ({
      skuId: sku.skuId,
      skuPartNumber: sku.skuPartNumber,
      available: sku.prepaidUnits.enabled - sku.consumedUnits,
      total: sku.prepaidUnits.enabled
    }));
  } catch (error) {
    console.error('Error fetching licenses:', error.response?.data);
    throw error;
  }
}

Complete Onboarding Flow Implementation

async function onboardGuestUser(userEmail, displayName, userRole, workspaceId) {
  try {
    console.log(`Starting onboarding for ${userEmail}...`);
    
    // Step 1: Check if user exists
    let user = await checkUserExists(userEmail);
    
    if (!user) {
      console.log('User not found, creating guest invitation...');
      
      // Step 2: Create guest invitation
      const redirectUrl = `https://app.powerbi.com/groups/${workspaceId}`;
      const invitation = await createGuestInvitation(
        userEmail,
        displayName,
        redirectUrl
      );
      
      console.log(`Invitation created. Redeem URL: ${invitation.redeemUrl}`);
      
      // Return redemption URL to redirect user
      return {
        status: 'invitation_created',
        redeemUrl: invitation.redeemUrl,
        userId: invitation.userId
      };
    }
    
    // User already exists, proceed with license and group assignment
    console.log('User exists, assigning permissions...');
    
    // Step 3: Determine group based on role
    const groupId = getRoleBasedGroupId(userRole);
    
    // Step 4: Add to group
    await addUserToGroup(user.id, groupId);
    console.log('User added to security group');
    
    // Step 5: Determine license based on role
    const licenseSkuId = getRoleBasedLicenseSkuId(userRole);
    
    // Step 6: Check license availability
    const availableLicenses = await getAvailableLicenses();
    const targetLicense = availableLicenses.find(l => l.skuId === licenseSkuId);
    
    if (!targetLicense || targetLicense.available <= 0) {
      throw new Error('No available licenses for assignment');
    }
    
    // Step 7: Assign license
    await assignLicense(user.id, licenseSkuId);
    console.log('License assigned successfully');
    
    // Step 8: Return success with Power BI URL
    return {
      status: 'onboarding_complete',
      powerBiUrl: `https://app.powerbi.com/groups/${workspaceId}`,
      userId: user.id
    };
    
  } catch (error) {
    console.error('Onboarding error:', error);
    throw {
      status: 'error',
      message: error.message,
      details: error.response?.data
    };
  }
}

// Helper function: Get group ID based on user role
function getRoleBasedGroupId(userRole) {
  const groupMappings = {
    'viewer': process.env.PBI_VIEWERS_GROUP_ID,
    'analyst': process.env.PBI_CONTRIBUTORS_GROUP_ID,
    'developer': process.env.PBI_MEMBERS_GROUP_ID,
    'admin': process.env.PBI_ADMINS_GROUP_ID
  };
  
  return groupMappings[userRole.toLowerCase()] || groupMappings['viewer'];
}

// Helper function: Get license SKU ID based on user role
function getRoleBasedLicenseSkuId(userRole) {
  const licenseMappings = {
    'viewer': process.env.PBI_PRO_SKU_ID,
    'analyst': process.env.PBI_PRO_SKU_ID,
    'developer': process.env.PBI_PPU_SKU_ID,
    'admin': process.env.PBI_PPU_SKU_ID
  };
  
  return licenseMappings[userRole.toLowerCase()] || licenseMappings['viewer'];
}


End of Document