Accessing The Power BI APIs in a Federated Azure AD Setup
Or the alternative title - combining ADFS w/SAML and Azure AD w/OAuth in the same authentication request just because it is possible :)
A few days ago I was asked to look into how the Power BI APIs could work in a kiosk-like use case with regards to the auth part. (People don't usually consult me when it comes to building snazzy user interfaces so someone else will be handling that.)
The Power BI APIs can be leveraged in two different contexts. The first one is the "user owns the data" setup - I log in interactively as a licensed user and access my reports/dashboards/whatever. Nothing special there. The other one is the "app owns the data" setup. This could be the aforementioned kiosk scenario, or any other use case where the end-user logging in isn't the vital component.
Now, if this was a simple well-documented one-step process I clearly would not be writing anything about it, so as you might imagine I hit a snag or two along the way.
Power BI has chosen a slightly non-standard setup for doing their authentication. You need to authenticate the app, but you also need to have an actual user object in addition to this. This user needs to have a Power BI Pro license, so I'm guessing the primary driver for this design is the licensing model on the back-end. (With a possible secondary driver being the access model inside Power BI - if you don't have a concept of assigning a report directly to an app you need to handle things differently.)
There is a .NET code sample (https://github.com/Microsoft/PowerBI-Developer-Samples), but when I try to run that I get an error from Azure AD about missing a client secret. The sample is based on using ADAL for authentication, which doesn't let me add this specific flow that easily so we need to hack together our own token acquisition method instead.
If you have identities synced into Azure AD this can be solved by doing a token request with the password grant type, and include the client secret (in addition to client id, username and password).
As the title indicates this is however a procedure for making things work when you have a federated setup, and that makes things slightly more elaborate. In my setup the federation is handled by ADFS using the WS-Trust protocols. (Technically you can federate using other solutions as well, but that is probably not the most common use case with regards to Azure AD.) This creates the curious situation where you need to authenticate the user on-premises (AD) while authenticating the app itself in the cloud (Azure AD).
The high level flow would be as follows:
- Get the ADFS endpoints from Azure AD
- Get a SAML assertion from ADFS using a username/password combo.
- Embed the SAML assertion along with a client id and secret, and acquire a JWT from Azure AD.
Perhaps better explained by a nice little sequence diagram:
This means that on a high-level it is entirely doable to have code that can figure out by itself if the identity is federated, and handle things accordingly. Since I'm not creating a generic solution that needs to handle both setups I can remove two steps; checking the "UserRealm" and acquiring the metadata from ADFS. Instead I go straight to the step of hitting the "usernamemixed" endpoint to acquire a token. This means that you need to check that you have the FQDN of your ADFS server, and have reasons to believe the rest of the setup is configured for a setup similar to mine :) (I have tested with ADFS 2016, and if you're using Office 365/AAD Connect odds are the configuration should be able to handle the requests.)
Alrighty, let's write some code. I'm doing this as un-sophisticated as I can both to make the code transparent enough to be translatable to something other than C#, as well as showing as much as possible of what is actually going on.
public class PowerBI
{
private static readonly string Username = "bob@contoso.com";
private static readonly string Password = "Pa$$w0rd";
private static readonly string stsFqdn = "https://adfs.contoso.com";
private static readonly string ResourceUrl = "https://analysis.windows.net/powerbi/api";
private static readonly string ClientId = "guid-from-Azure-Portal";
private static readonly string ClientSecret = "secret-from-Azure-Portal";
private static readonly string ApiUrl = "https://api.powerbi.com/";
private static readonly string GroupId = "group-guid";
private static readonly string ReportId = "report-guid";
private GenericToken getAccessToken()
{
var resource = Uri.EscapeDataString(ResourceUrl);
var uriId = Uri.EscapeDataString(ClientId);
var uriSecret = Uri.EscapeDataString(ClientSecret);
var uriUser = Uri.EscapeDataString(Username);
//Before making the OAuth request against AAD we need a SAML assertion issued by ADFS to embed
var assertion = getAssertion().Result;
HttpClient client = new HttpClient();
string requestUrl = $"https://login.microsoftonline.com/common/oauth2/token";
var ua = new UserAssertion(assertion, "urn:ietf:params:oauth:grant-type:saml1_1-bearer", uriUser);
UTF8Encoding encoding = new UTF8Encoding();
Byte[] byteSource = encoding.GetBytes(ua.Assertion);
string base64ua = Uri.EscapeDataString(Convert.ToBase64String(byteSource));
string request_content = $"resource={resource}&client_id={uriId}&grant_type=urn%3Aietf%3Aparams%3Aoauth
%3Agrant-type%3Asaml1_1-bearer&assertion={base64ua}&client_secret={uriSecret}&scope=openid";
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
try
{
request.Content = new StringContent(request_content, Encoding.UTF8, "application/x-www-form-urlencoded");
}
catch (Exception x)
{
var msg = x.Message;
}
HttpResponseMessage response = client.SendAsync(request).Result;
string responseString = response.Content.ReadAsStringAsync().Result;
GenericToken token = JsonConvert.DeserializeObject<GenericToken>(responseString);
return token;
}
private async Task<string> getAssertion()
{
HttpClient client = new HttpClient();
string requestUrl = $"{stsFqdn}/adfs/services/trust/2005/usernamemixed";
var saml = $"<s:Envelope xmlns:s='https://www.w3.org/2003/05/soap-envelope' xmlns:a='https://www.w3.org/2005/08/addressing'" +
"xmlns:u='https://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd'>\r\n" +
"<s:Header>\r\n<a:Action s:mustUnderstand='1'>https://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue</a:Action>\r\n" +
$"<a:MessageID>urn:uuid:{Guid.NewGuid().ToString()}</a:MessageID>\r\n" +
"<a:ReplyTo><a:Address>https://www.w3.org/2005/08/addressing/anonymous</a:Address></a:ReplyTo>\r\n" +
$"<a:To s:mustUnderstand='1'>{stsFqdn}/adfs/services/trust/2005/usernamemixed</a:To>\r\n" +
"<o:Security s:mustUnderstand='1' xmlns:o='https://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd'>" +
$"<u:Timestamp u:Id='_0'><u:Created>{DateTime.UtcNow.ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'")}</u:Created>" +
$"<u:Expires>{DateTime.UtcNow.AddMinutes(10).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'")}</u:Expires>" +
$"</u:Timestamp><o:UsernameToken u:Id='uuid-{Guid.NewGuid().ToString()}'>" +
$"<o:Username>{Username}</o:Username><o:Password>{Password}</o:Password></o:UsernameToken></o:Security>\r\n" +
"</s:Header>\r\n" +
"<s:Body>\r\n" +
"<trust:RequestSecurityToken xmlns:trust='https://schemas.xmlsoap.org/ws/2005/02/trust'>\r\n" +
"<wsp:AppliesTo xmlns:wsp='https://schemas.xmlsoap.org/ws/2004/09/policy'>\r\n" +
"<a:EndpointReference>\r\n" +
"<a:Address>urn:federation:MicrosoftOnline</a:Address>\r\n" +
"</a:EndpointReference>\r\n" +
"</wsp:AppliesTo>\r\n" +
"<trust:KeyType>https://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey</trust:KeyType>\r\n" +
"<trust:RequestType>https://schemas.xmlsoap.org/ws/2005/02/trust/Issue</trust:RequestType>\r\n" +
"</trust:RequestSecurityToken>\r\n</s:Body>\r\n</s:Envelope>";
string request_content = saml;
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
try
{
request.Headers.Add("SOAPAction", "https://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue");
request.Headers.Add("client-request-id", Guid.NewGuid().ToString());
request.Headers.Add("return-client-request-id", "true");
request.Headers.Add("Accept", "application/json");
request.Content = new StringContent(request_content, Encoding.UTF8, "application/soap+xml");
}
catch (Exception x)
{
var msg = x.Message;
}
HttpResponseMessage response = client.SendAsync(request).Result;
string responseString = await response.Content.ReadAsStringAsync();
XmlDocument doc = new XmlDocument();
doc.LoadXml(responseString);
var nodeList = doc.GetElementsByTagName("saml:Assertion");
var assertion = nodeList[0].OuterXml;
return assertion;
}
}
public class GenericToken
{
public string token_type { get; set; }
public string scope { get; set; }
public string resource { get; set; }
public string access_token { get; set; }
public string refresh_token { get; set; }
public string id_token { get; set; }
public string expires_in { get; set; }
}
This should hopefully get you to a point where you have an access token. Sure, you might still get a 403 (unauthorized) in the next step in your communication with Power BI, as an RBAC-model is in place. But in general you need to acquire an embed token to whatever dashboard/report you want to access with something along these lines:
var tokenCredentials = new TokenCredentials(token.access_token, "Bearer");
// Create a Power BI Client object. It will be used to call Power BI APIs.
using (var client = new PowerBIClient(new Uri(ApiUrl), tokenCredentials))
{
// Generate Embed Token.
var generateTokenRequestParameters = new GenerateTokenRequest(accessLevel: "view");
var tokenResponse = await client.Dashboards.GenerateTokenInGroupAsync(GroupId, dashboardId,
generateTokenRequestParameters);
}
More info here (I chose dashboard as an example):
https://msdn.microsoft.com/en-us/library/mt784614.aspx
While I have tried to make the code bug free there is the odd chance of things not working out when you use static SAML requests like these. For instance I noticed the XPath expression failed when testing with a different ADFS server since one used "saml:assertion" and the other used "saml:Assertion". It might also be that the EndpointReference is set to something other than "urn:federation:MicrosoftOnline". Minor things, but if you get an error in the response try to look closer at it to see if there are any clues as to what could be causing it to fail.
Further use of Power BI is outside the scope of this post, but I hope this walkthrough enables you to get your foot inside the door, and explore what the API has to offer.
Comments
- Anonymous
January 27, 2018
Always funny experiences when it comes to authenticating to APIs :)