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.
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) |
Technical Details:
Business Logic:
API Call:
GET https://graph.microsoft.com/v1.0/users?$filter=mail eq 'kc@contoso.com'
Possible Outcomes:
/invitations endpointAPI 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:
invitedUserEmailAddress: User’s email from Keycloak tokeninviteRedirectUrl: Target Power BI workspace URLsendInvitationMessage: Set to false for seamless flowinvitedUserType: Always “Guest” for external usersinviteRedeemUrl from responseResponse 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:
inviteRedeemUrl: URL for guest invitation redemptioninvitedUser.id: Guest user object ID (needed for license assignment)status: Should be “PendingAcceptance”inviteRedeemUrlHTTP 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:
kc_contoso.com#EXT#@resourcetenant.onmicrosoft.comAPI 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:
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'
};
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):
f8a1db68-be16-40ed-86d5-cb42ce701560edd24c03-0b36-4a3a-9607-3dc49e4c4069Prerequisites:
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": [...]
}
]
}
URL Format:
https://app.powerbi.com/groups/{workspaceId}/list?experience=power-bi
Alternative Formats:
https://app.powerbi.com/groups/{workspaceId}/reports/{reportId}https://app.powerbi.com/groups/{workspaceId}/dashboards/{dashboardId}https://app.powerbi.com/groups/{workspaceId}/datasets/{datasetId}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}
Entra ID determines the appropriate authentication method based on the user’s identity configuration.
Scenario: User’s home organization uses Microsoft Entra ID
Flow:
Authentication Details:
Example User Journey:
Resource Entra → Home Entra (contoso.com) → User Login → MFA →
Token Issued → Resource Entra
Scenario: User has personal Microsoft Account (e.g., @gmail.com, @yahoo.com)
Flow:
Authentication Details:
Example User Journey:
Resource Entra → login.live.com → User Login → SMS Verification →
Token Issued → Resource Entra
Scenario: User’s organization uses third-party IdP federated with Entra (e.g., Okta, Keycloak, ADFS, Ping Identity)
Flow:
Authentication Details:
Example User Journey:
Resource Entra → Keycloak (contoso.com) → [SSO Session Exists] →
SAML Assertion → Resource Entra
Benefits:
Scenario: User has no existing Microsoft identity and organization not federated
Flow:
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:
Token Validation Steps:
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"
}
Authorization Checks:
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:
Click to view full size
Click to view full size
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}
Workspace Setup:
Tenant Settings:
Identity Provider Setup:
User Attribute Mapping:
{
"email": "user.email",
"firstName": "user.firstName",
"lastName": "user.lastName",
"roles": "user.roles"
}
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;
}
}
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;
}
}
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;
}
}
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;
}
}
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;
}
}
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;
}
}
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